From 48394a92d3e16d3f82d3b7813aa9622e77c65c39 Mon Sep 17 00:00:00 2001 From: sklump Date: Fri, 9 Aug 2019 14:48:00 -0400 Subject: [PATCH 1/4] Aries#0036,37 v1.0 roll-up Signed-off-by: sklump --- aries_cloudagent/admin/routes.py | 8 + aries_cloudagent/config/argparse.py | 23 + aries_cloudagent/defaults.py | 8 + aries_cloudagent/holder/indy.py | 81 +- aries_cloudagent/holder/tests/test_indy.py | 106 +- aries_cloudagent/ledger/indy.py | 35 + aries_cloudagent/messaging/.util.py.swp | Bin 0 -> 12288 bytes .../messaging/decorators/attach_decorator.py | 204 ++ .../decorators/tests/test_attach_decorator.py | 171 ++ .../messaging/issue_credential/__init__.py | 0 .../issue_credential/v1_0/__init__.py | 0 .../v1_0/handlers/__init__.py | 0 .../v1_0/handlers/credential_issue_handler.py | 35 + .../v1_0/handlers/credential_offer_handler.py | 86 + .../handlers/credential_proposal_handler.py | 54 + .../handlers/credential_request_handler.py | 56 + .../issue_credential/v1_0/manager.py | 596 ++++++ .../issue_credential/v1_0/message_types.py | 21 + .../v1_0/messages/__init__.py | 0 .../v1_0/messages/credential_issue.py | 75 + .../v1_0/messages/credential_offer.py | 82 + .../v1_0/messages/credential_proposal.py | 65 + .../v1_0/messages/credential_request.py | 75 + .../v1_0/messages/inner/__init__.py | 0 .../v1_0/messages/inner/credential_preview.py | 221 +++ .../v1_0/messages/inner/tests/__init__.py | 0 .../inner/tests/test_credential_preview.py | 221 +++ .../v1_0/messages/tests/__init__.py | 0 .../messages/tests/test_credential_issue.py | 148 ++ .../messages/tests/test_credential_offer.py | 115 ++ .../tests/test_credential_proposal.py | 127 ++ .../messages/tests/test_credential_request.py | 102 + .../issue_credential/v1_0/models/__init__.py | 0 .../v1_0/models/credential_exchange.py | 142 ++ .../messaging/issue_credential/v1_0/routes.py | 590 ++++++ .../messaging/present_proof/__init__.py | 0 .../messaging/present_proof/v1_0/__init__.py | 0 .../present_proof/v1_0/handlers/__init__.py | 0 .../v1_0/handlers/presentation_handler.py | 43 + .../handlers/presentation_proposal_handler.py | 56 + .../handlers/presentation_request_handler.py | 133 ++ .../messaging/present_proof/v1_0/manager.py | 365 ++++ .../present_proof/v1_0/message_types.py | 19 + .../present_proof/v1_0/messages/__init__.py | 0 .../v1_0/messages/inner/__init__.py | 0 .../messages/inner/presentation_preview.py | 441 +++++ .../v1_0/messages/inner/tests/__init__.py | 0 .../inner/tests/test_presentation_preview.py | 337 ++++ .../v1_0/messages/presentation.py | 80 + .../v1_0/messages/presentation_proposal.py | 57 + .../v1_0/messages/presentation_request.py | 80 + .../v1_0/messages/tests/__init__.py | 0 .../v1_0/messages/tests/test_presentation.py | 1726 +++++++++++++++++ .../tests/test_presentation_proposal.py | 134 ++ .../tests/test_presentation_request.py | 131 ++ .../v1_0/messages/util/__init__.py | 0 .../present_proof/v1_0/messages/util/indy.py | 78 + .../present_proof/v1_0/models/__init__.py | 0 .../v1_0/models/presentation_exchange.py | 116 ++ .../messaging/present_proof/v1_0/routes.py | 627 ++++++ .../messaging/tests/test_utils.py | 16 +- .../messaging/tests/test_valid.py | 85 + aries_cloudagent/messaging/util.py | 21 + aries_cloudagent/messaging/valid.py | 85 + 64 files changed, 8069 insertions(+), 8 deletions(-) create mode 100644 aries_cloudagent/messaging/.util.py.swp create mode 100644 aries_cloudagent/messaging/decorators/attach_decorator.py create mode 100644 aries_cloudagent/messaging/decorators/tests/test_attach_decorator.py create mode 100644 aries_cloudagent/messaging/issue_credential/__init__.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/__init__.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/handlers/__init__.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/handlers/credential_issue_handler.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/handlers/credential_offer_handler.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/handlers/credential_proposal_handler.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/handlers/credential_request_handler.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/manager.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/message_types.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/__init__.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_issue.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_offer.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_proposal.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_request.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/__init__.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/credential_preview.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/tests/__init__.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/tests/test_credential_preview.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/__init__.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_issue.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_offer.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_proposal.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_request.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/models/__init__.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/models/credential_exchange.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/routes.py create mode 100644 aries_cloudagent/messaging/present_proof/__init__.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/__init__.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/handlers/__init__.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_handler.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_proposal_handler.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_request_handler.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/manager.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/message_types.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/__init__.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/inner/__init__.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/inner/presentation_preview.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/__init__.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/presentation.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/presentation_proposal.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/presentation_request.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/tests/__init__.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation_proposal.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation_request.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/util/__init__.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/messages/util/indy.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/models/__init__.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/models/presentation_exchange.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/routes.py create mode 100644 aries_cloudagent/messaging/tests/test_valid.py create mode 100644 aries_cloudagent/messaging/valid.py diff --git a/aries_cloudagent/admin/routes.py b/aries_cloudagent/admin/routes.py index 02e1031f8d..b7764cda16 100644 --- a/aries_cloudagent/admin/routes.py +++ b/aries_cloudagent/admin/routes.py @@ -6,6 +6,12 @@ from ..messaging.connections.routes import register as register_connections from ..messaging.credentials.routes import register as register_credentials from ..messaging.introduction.routes import register as register_introduction +from ..messaging.issue_credential.v1_0.routes import ( + register as register_v10_issue_credential +) +from ..messaging.present_proof.v1_0.routes import ( + register as register_v10_present_proof +) from ..messaging.presentations.routes import register as register_presentations from ..messaging.schemas.routes import register as register_schemas from ..messaging.credential_definitions.routes import ( @@ -34,4 +40,6 @@ async def register_module_routes(app: web.Application): await register_basicmessages(app) await register_discovery(app) await register_trustping(app) + await register_v10_issue_credential(app) + await register_v10_present_proof(app) await register_wallet(app) diff --git a/aries_cloudagent/config/argparse.py b/aries_cloudagent/config/argparse.py index 4405c9bfa1..63261b2fb8 100644 --- a/aries_cloudagent/config/argparse.py +++ b/aries_cloudagent/config/argparse.py @@ -205,11 +205,28 @@ def add_arguments(self, parser: ArgumentParser): action="store_true", help="Auto-respond to basic messages", ) + parser.add_argument( + "--auto-respond-credential-proposal", + action="store_true", + help="Auto-respond to credential proposals with corresponding " + + "credential offers", + ) parser.add_argument( "--auto-respond-credential-offer", action="store_true", help="Auto-respond to credential offers with credential request", ) + parser.add_argument( + "--auto-respond-credential-request", + action="store_true", + help="Auto-respond to credential requests with corresponding credentials", + ) + parser.add_argument( + "--auto-respond-presentation-proposal", + action="store_true", + help="Auto-respond to presentation proposals with corresponding " + + "presentation requests", + ) parser.add_argument( "--auto-respond-presentation-request", action="store_true", @@ -243,8 +260,14 @@ def get_settings(self, args: Namespace) -> dict: if args.invite: settings["debug.print_invitation"] = True + if args.auto_respond_credential_proposal: + settings["debug.auto_respond_credential_proposal"] = True if args.auto_respond_credential_offer: settings["debug.auto_respond_credential_offer"] = True + if args.auto_respond_credential_request: + settings["debug.auto_respond_credential_request"] = True + if args.auto_respond_presentation_proposal: + settings["debug.auto_respond_presentation_proposal"] = True if args.auto_respond_presentation_request: settings["debug.auto_respond_presentation_request"] = True if args.auto_store_credential: diff --git a/aries_cloudagent/defaults.py b/aries_cloudagent/defaults.py index cbe5d63e4e..5a679b2d0b 100644 --- a/aries_cloudagent/defaults.py +++ b/aries_cloudagent/defaults.py @@ -16,6 +16,12 @@ from .messaging.credentials.message_types import MESSAGE_TYPES as CREDENTIAL_MESSAGES from .messaging.trustping.message_types import MESSAGE_TYPES as TRUSTPING_MESSAGES from .messaging.routing.message_types import MESSAGE_TYPES as ROUTING_MESSAGES +from .messaging.issue_credential.v1_0.message_types import ( + MESSAGE_TYPES as V10_ISSUE_CREDENTIAL_MESSAGES +) +from .messaging.present_proof.v1_0.message_types import ( + MESSAGE_TYPES as V10_PRESENT_PROOF_MESSAGES +) from .messaging.problem_report.message import ( MESSAGE_TYPE as PROBLEM_REPORT, @@ -34,7 +40,9 @@ def default_protocol_registry() -> ProtocolRegistry: DISCOVERY_MESSAGES, INTRODUCTION_MESSAGES, PRESENTATION_MESSAGES, + V10_PRESENT_PROOF_MESSAGES, CREDENTIAL_MESSAGES, + V10_ISSUE_CREDENTIAL_MESSAGES, ROUTING_MESSAGES, TRUSTPING_MESSAGES, {PROBLEM_REPORT: ProblemReport}, diff --git a/aries_cloudagent/holder/indy.py b/aries_cloudagent/holder/indy.py index 6c52ea7b01..8b5ff2de64 100644 --- a/aries_cloudagent/holder/indy.py +++ b/aries_cloudagent/holder/indy.py @@ -9,6 +9,10 @@ import indy.anoncreds from indy.error import ErrorCode, IndyError +from ..storage.indy import IndyStorage +from ..storage.error import StorageError, StorageNotFoundError +from ..storage.record import StorageRecord + from ..wallet.error import WalletNotFoundError from .base import BaseHolder @@ -17,6 +21,8 @@ class IndyHolder(BaseHolder): """Indy holder class.""" + RECORD_TYPE_METADATA = "attribute-metadata" + def __init__(self, wallet): """ Initialize an IndyHolder instance. @@ -66,7 +72,11 @@ async def create_credential_request( return credential_request, credential_request_metadata async def store_credential( - self, credential_definition, credential_data, credential_request_metadata + self, + credential_definition, + credential_data, + credential_request_metadata, + credential_attr_metadata=None ): """ Store a credential in the wallet. @@ -74,9 +84,12 @@ async def store_credential( Args: credential_definition: Credential definition for this credential credential_data: Credential data generated by the issuer + credential_request_metadata: credential request metadata generated + by the issuer + credential_attr_metadata: dict mapping attribute name to (optional) + encoding and MIME type to store as non-secret record, if specified """ - credential_id = await indy.anoncreds.prover_store_credential( self.wallet.handle, None, # Always let indy set the id for now @@ -86,6 +99,29 @@ async def store_credential( None, # We don't support revocation yet ) + if credential_attr_metadata: + metadata_tags = {} + for attr in credential_data["values"]: + meta = credential_attr_metadata.get(attr) + tag = { + key: meta.get( + key + ) for key in ('encoding', 'mime-type') if meta.get(key) + } + + if tag: # only include non-trivial tag per attr + metadata_tags[attr] = json.dumps(tag) + + if metadata_tags: + meta_record = StorageRecord( + type=IndyHolder.RECORD_TYPE_METADATA, + value=credential_id, + tags=metadata_tags, + id=f"{IndyHolder.RECORD_TYPE_METADATA}::{credential_id}" + ) + indy_stor = IndyStorage(self.wallet) + await indy_stor.add_record(meta_record) + return credential_id async def get_credentials(self, start: int, count: int, wql: dict): @@ -99,7 +135,8 @@ async def get_credentials(self, start: int, count: int, wql: dict): """ search_handle, record_count = await indy.anoncreds.prover_search_credentials( - self.wallet.handle, json.dumps(wql) + self.wallet.handle, + json.dumps(wql) ) # We need to move the database cursor position manually... @@ -108,7 +145,8 @@ async def get_credentials(self, start: int, count: int, wql: dict): await indy.anoncreds.prover_fetch_credentials(search_handle, start) credentials_json = await indy.anoncreds.prover_fetch_credentials( - search_handle, count + search_handle, + count ) await indy.anoncreds.prover_close_credentials_search(search_handle) @@ -212,6 +250,16 @@ async def delete_credential(self, credential_id: str): credential_id: Credential id to remove """ + try: + indy_stor = IndyStorage(self.wallet) + meta_record = await indy_stor.get_record( + IndyHolder.RECORD_TYPE_METADATA, + f"{IndyHolder.RECORD_TYPE_METADATA}::{credential_id}" + ) + await indy_stor.delete_record(meta_record) + except StorageNotFoundError: + pass # metadata record not present: carry on + try: await indy.anoncreds.prover_delete_credential( self.wallet.handle, credential_id @@ -224,6 +272,31 @@ async def delete_credential(self, credential_id: str): else: raise + async def get_metadata(self, credential_id: str, attr: str = None) -> dict: + """ + Get MIME type and encoding per attribute (or for all attributes). + + Args: + credential_id: credential id + attr: attribute of interest or omit for all + + Returns: metadata dict + + """ + try: + all_meta = await IndyStorage(self.wallet).get_record( + IndyHolder.RECORD_TYPE_METADATA, + f"{IndyHolder.RECORD_TYPE_METADATA}::{credential_id}" + ) + except StorageError: + return None # no metadata: not an error + + if attr: + attr_meta_json = all_meta.tags.get(attr) + return json.loads(attr_meta_json) if attr_meta_json else None + + return {attr: json.loads(all_meta.tags[attr]) for attr in all_meta.tags} + async def create_presentation( self, presentation_request: dict, diff --git a/aries_cloudagent/holder/tests/test_indy.py b/aries_cloudagent/holder/tests/test_indy.py index 25988454fe..8d731e0a59 100644 --- a/aries_cloudagent/holder/tests/test_indy.py +++ b/aries_cloudagent/holder/tests/test_indy.py @@ -3,9 +3,18 @@ from asynctest import TestCase as AsyncTestCase from asynctest import mock as async_mock -import pytest +from indy.error import IndyError, ErrorCode from aries_cloudagent.holder.indy import IndyHolder +from aries_cloudagent.storage.error import StorageError +from aries_cloudagent.storage.record import StorageRecord +from aries_cloudagent.wallet.indy import IndyWallet + +import pytest + +from ...messaging.issue_credential.v1_0.messages.inner.credential_preview import ( + CredentialPreview +) @pytest.mark.indy @@ -56,6 +65,76 @@ async def test_store_credential(self, mock_store_cred): assert cred_id == "cred_id" + @async_mock.patch("indy.non_secrets.get_wallet_record") + async def test_get_credential_attrs_metadata(self, mock_nonsec_get_wallet_record): + cred_id = "credential_id" + dummy_tags = {"a": "1", "b": "2"} + dummy_rec = { + "type": IndyHolder.RECORD_TYPE_METADATA, + "id": cred_id, + "value": "value", + "tags": { + attr: json.dumps(dummy_tags[attr]) for attr in dummy_tags + } + } + mock_nonsec_get_wallet_record.return_value = json.dumps(dummy_rec) + + mock_wallet = async_mock.MagicMock() + + holder = IndyHolder(mock_wallet) + + metadata = await holder.get_metadata(cred_id) + + mock_nonsec_get_wallet_record.assert_called_once_with( + mock_wallet.handle, + dummy_rec["type"], + f"{IndyHolder.RECORD_TYPE_METADATA}::{dummy_rec['id']}", + json.dumps( + { + "retrieveType": True, + "retrieveValue": True, + "retrieveTags": True + } + ) + ) + + assert metadata == dummy_tags + + @async_mock.patch("indy.non_secrets.get_wallet_record") + async def test_get_credential_attr_metadata(self, mock_nonsec_get_wallet_record): + cred_id = "credential_id" + dummy_tags = {"a": "1", "b": "2"} + dummy_rec = { + "type": IndyHolder.RECORD_TYPE_METADATA, + "id": cred_id, + "value": "value", + "tags": { + attr: json.dumps(dummy_tags[attr]) for attr in dummy_tags + } + } + mock_nonsec_get_wallet_record.return_value = json.dumps(dummy_rec) + + mock_wallet = async_mock.MagicMock() + + holder = IndyHolder(mock_wallet) + + a_metadata = await holder.get_metadata(cred_id, "a") + + mock_nonsec_get_wallet_record.assert_called_once_with( + mock_wallet.handle, + dummy_rec["type"], + f"{IndyHolder.RECORD_TYPE_METADATA}::{dummy_rec['id']}", + json.dumps( + { + "retrieveType": True, + "retrieveValue": True, + "retrieveTags": True + } + ) + ) + + assert a_metadata == dummy_tags["a"] + @async_mock.patch("indy.anoncreds.prover_search_credentials") @async_mock.patch("indy.anoncreds.prover_fetch_credentials") @async_mock.patch("indy.anoncreds.prover_close_credentials_search") @@ -152,13 +231,34 @@ async def test_get_credential(self, mock_get_cred): assert credential == json.loads("{}") @async_mock.patch("indy.anoncreds.prover_delete_credential") - async def test_get_credential(self, mock_del_cred): + @async_mock.patch("indy.non_secrets.get_wallet_record") + @async_mock.patch("indy.non_secrets.delete_wallet_record") + async def test_delete_credential( + self, + mock_nonsec_del_wallet_record, + mock_nonsec_get_wallet_record, + mock_prover_del_cred + ): mock_wallet = async_mock.MagicMock() holder = IndyHolder(mock_wallet) + mock_nonsec_get_wallet_record.return_value = json.dumps( + { + "type": "typ", + "id": "ident", + "value": "value", + "tags": { + "a": json.dumps("1"), + "b": json.dumps("2") + } + } + ) credential = await holder.delete_credential("credential_id") - mock_del_cred.assert_called_once_with(mock_wallet.handle, "credential_id") + mock_prover_del_cred.assert_called_once_with( + mock_wallet.handle, + "credential_id" + ) @async_mock.patch("indy.anoncreds.prover_create_proof") async def test_create_presentation(self, mock_create_proof): diff --git a/aries_cloudagent/ledger/indy.py b/aries_cloudagent/ledger/indy.py index 98066b4eea..09725b76ab 100644 --- a/aries_cloudagent/ledger/indy.py +++ b/aries_cloudagent/ledger/indy.py @@ -499,6 +499,41 @@ async def fetch_credential_definition(self, credential_definition_id: str): return parsed_response + async def credential_definition_id2schema_id(self, credential_definition_id): + """ + From a credential definition, get the identifier for its schema. + + Args: + credential_definition_id: The identifier of the credential definition + from which to identify a schema + """ + + # scrape sequence number from cd_id + seq_no = int(credential_definition_id.split(":")[3]) + + # get txn by sequence number, retrieve schema identifier components + request_json = await indy.ledger.build_get_txn_request( + None, + None, + seq_no=seq_no + ) + response = json.loads(await self._submit(request_json)) + + # transaction data format assumes node protocol 1.4 (circa 2018-07) or higher + data_txn = (response["result"].get("data", {}) or {}).get("txn", {}) + if data_txn.get("type", None) == "101": # marks indy-sdk schema txn type + (origin_did, name, version) = ( + data_txn["metadata"]["from"], + data_txn["data"]["data"]["name"], + data_txn["data"]["data"]["version"] + ) + return f"{origin_did}:2:{name}:{version}" + + raise LedgerTransactionError( + "Could not get schema identifier from ledger for " + + f"credential definition id {credential_definition_id}" + ) + async def get_key_for_did(self, did: str) -> str: """Fetch the verkey for a ledger DID. diff --git a/aries_cloudagent/messaging/.util.py.swp b/aries_cloudagent/messaging/.util.py.swp new file mode 100644 index 0000000000000000000000000000000000000000..4e1e5959093ce76824bc4cf9f3e3f85ac9b49d38 GIT binary patch literal 12288 zcmeI2O^g&p6vqpVAAtCQM`BVmXu92*W@Z;yGiKc&A%qBGgvEs2VNyF?Gt=19HR-N~ zodwi*P!7ffO*DG-%s~=O^eiUEc=YDM3r0jdcu*5P_^;}(o!w#aKw>o2$&c=-_p0i> ze^pl%*F^-Gsz12T6aR4C`OXVp5-)T;E4oUr8I> z#Uh`_)pj5fk9Bz{tAnBJiO`K-o|;aLt)g2&(g z^Z)<<@DL%_z!mTjcniD^e9!~?!Lwi=*bJ^dNXQr9b09$go&$euA>=1;9sCF`gAnw= z4shcE*unSUL+}B(1QIY0c7ki03HcU$4L$*b?E*Lco)0_E`eq65>R6@0nB9-Fbe$p3Q$}Tmq`>- zk4Y~5K5t3Uie+RMze91>b{wKyEz3F%msqm?fchalbnGbYh^Wt`3ls5phw3JD62*g>+5-Bjyew!jyq!jxi)Jcxdmxb$Rs3g!S<=UkwX9;KvqHoZ- z5dF-fxKhN_)B+&g)k7?GJWBX%vPC>jg0fUu)Rh*~nf#%uXRXHr$?U!#CX&Z9a=8_E zL;57eylnME62*AlTcZ=08!b4+nV4ydl=_(Zxg+$;oEjvzt9w?6j@4Mn<@xGlqnS>v z+^uM%@ToVZ*7~T>3XfV7QCW4XY)J=0x6e@Pq*aDc+I281-m?nH`&akA-;P8&qel&T zhUuB^tTm*s8JS4=-GBBkgYT;TFoY% zgE6hQYPFhTRG!RphIRrWq7{`#-BvWJT^ZiNRG}gu9>lyHgjE6~yjtO@Y(>9iF-9{K zGP9H|$z@c=8`Cmu%#S%e!GeSzh$1Lv9n&ir^#qS~{JLYxmj=8oxu;*bZ1WhMxhhxfTc$dy zxhF?wUu4;{jfIB2;4OIS$zAZy)hl~1IJ1s3YtK(qo66FdRsWtp>Fl&tS3iZS=XPIk zs=A5%Hh-Kp(}J{V$0|flQ&H=Os~>G`!8rLbRtBu;*v=58T2Gxc3$;BK)$izmVH)6skgao%`aZ%b(j6z>(*<(Coyk5x$e2b zEu^t6YB`0kHBqZIYBj5(R?c+RtsT`ogcU5iI5 zo1Y`rRTo2{ggt!p;K2hgLdHOJ)pzP{m&?O= InjectionContext: + """ + Accessor for the current request context. + + Returns: + The request context for this connection + + """ + return self._context + + async def cache_credential_exchange( + self, + credential_exchange_record: V10CredentialExchange + ): + """Cache a credential exchange to avoid redundant credential requests.""" + cache: BaseCache = await self.context.inject(BaseCache) + await cache.set( + "v10_credential_exchange::" + + f"{credential_exchange_record.credential_definition_id}::" + + f"{credential_exchange_record.connection_id}", + credential_exchange_record.credential_exchange_id, + 600, + ) + + async def prepare_send( + self, + credential_definition_id: str, + connection_id: str, + credential_values: dict + ) -> V10CredentialExchange: + """ + Set up a new credential exchange for an automated send. + + Args: + credential_definition_id: Credential definition id for offer + connection_id: Connection to create offer for + credential_values: The credential values to use if auto_issue is enabled + + Returns: + A new `V10CredentialExchange` record + + """ + + cache: BaseCache = await self._context.inject(BaseCache) + + # This cache is populated in credential_request_handler.py + # Do we have a source (parent) credential exchange for which + # we can re-use the credential request/offer? + source_credential_exchange_id = await cache.get( + "v10_credential_exchange::" + + f"{credential_definition_id}::" + + f"{connection_id}" + ) + + source_credential_exchange = None + + if source_credential_exchange_id: + + # The cached credential exchange ID may not have an associated credential + # request yet. Wait up to 30 seconds for that to be populated, then + # move on and replace it as the cached credential exchange + + lookup_start = time.perf_counter() + while True: + source_credential_exchange = await V10CredentialExchange.retrieve_by_id( + self._context, source_credential_exchange_id + ) + if source_credential_exchange.credential_request: + break + if lookup_start + 30 < time.perf_counter(): + source_credential_exchange = None + break + await asyncio.sleep(0.3) + + if source_credential_exchange: + + # Since we have the source exchange cache, we can re-use the schema_id, + # credential_offer, and credential_request to save a roundtrip + credential_exchange = V10CredentialExchange( + auto_issue=True, + connection_id=connection_id, + initiator=V10CredentialExchange.INITIATOR_SELF, + state=V10CredentialExchange.STATE_REQUEST_RECEIVED, + credential_definition_id=credential_definition_id, + schema_id=source_credential_exchange.schema_id, + credential_proposal_dict=( + source_credential_exchange.credential_proposal_dict + ), + credential_offer=source_credential_exchange.credential_offer, + credential_request=source_credential_exchange.credential_request, + credential_values=credential_values, + # We use the source credential exchange's thread id as the parent + # thread id. This thread is a branch of that parent so that the other + # agent can use the parent thread id to look up its corresponding + # source credential exchange object as needed + parent_thread_id=source_credential_exchange.thread_id, + ) + await credential_exchange.save( + self.context, + reason=( + "Aries#0036v1.0 create automated credential exchange " + "from cached request" + ) + ) + + else: + # If the cache is empty, we must use the normal credential flow while + # also instructing the agent to automatically issue the credential + # once it receives the credential request + + credential_exchange = await self.create_offer( + credential_definition_id, connection_id, True, credential_values + ) + + # Mark this credential exchange as the current cached one for this cred def + await self.cache_credential_exchange(credential_exchange) + + return credential_exchange + + async def perform_send( + self, + credential_exchange: V10CredentialExchange, + outbound_handler + ): + """Send the first message in a credential exchange.""" + + if credential_exchange.credential_request: + (credential_exchange, credential_message) = await self.issue_credential( + credential_exchange + ) + await outbound_handler( + credential_message, connection_id=credential_exchange.connection_id + ) + else: + credential_exchange, credential_offer_message = await self.offer_credential( + credential_exchange + ) + await outbound_handler( + credential_offer_message, + connection_id=credential_exchange.connection_id, + ) + + async def create_proposal( + self, + connection_id: str, + *, + auto_offer: bool = None, + comment: str = None, + credential_preview: CredentialPreview = None, + credential_definition_id: str + ): + """ + Create a credential proposal. + + Args: + connection_id: Connection to create proposal for + auto_offer: Should this proposal request automatically be handled to + offer a credential + credential_preview: The credential preview to use to create + the credential proposal + credential_definition_id: Credential definition id for the + credential proposal + + Return: + Resulting credential_exchange_record including credential proposal + + """ + # Credential definition id must be present + if not credential_definition_id: + raise CredentialManagerError( + "credential_definition_id is not set" + ) + + # Credential preview must be present + if not credential_preview: + raise CredentialManagerError( + "credential_preview is not set" + ) + + ledger: BaseLedger = await self.context.inject(BaseLedger) + async with ledger: + schema_id = await ledger.credential_definition_id2schema_id( + credential_definition_id + ) + + credential_proposal_message = CredentialProposal( + comment=comment, + credential_proposal=credential_preview, + schema_id=schema_id, + cred_def_id=credential_definition_id) + + credential_exchange_record = V10CredentialExchange( + connection_id=connection_id, + thread_id=credential_proposal_message._thread_id, + initiator=V10CredentialExchange.INITIATOR_SELF, + state=V10CredentialExchange.STATE_PROPOSAL_SENT, + credential_definition_id=credential_definition_id, + schema_id=schema_id, + credential_proposal_dict=credential_proposal_message.serialize(), + auto_offer=auto_offer + ) + await credential_exchange_record.save( + self.context, + reason="Aries#0036v1.0 create credential proposal") + return credential_exchange_record + + async def receive_proposal( + self, + credential_proposal_message: CredentialProposal, + connection_id + ): + """ + Receive a credential proposal. + + Args: + credential_proposal_message: Credential proposal to receive + connection_id: Connection to receive offer on + + Returns: + The credential_exchange_record + + """ + # go to cred def via ledger to get authoritative schema id + cred_def_id = credential_proposal_message.cred_def_id + if cred_def_id: + ledger: BaseLedger = await self.context.inject(BaseLedger) + async with ledger: + schema_id = await ledger.credential_definition_id2schema_id(cred_def_id) + else: + raise CredentialManagerError( + "credential definition identifier is not set in proposal" + ) + + credential_exchange_record = V10CredentialExchange( + connection_id=connection_id, + thread_id=credential_proposal_message._thread_id, + initiator=V10CredentialExchange.INITIATOR_EXTERNAL, + state=V10CredentialExchange.STATE_PROPOSAL_RECEIVED, + credential_definition_id=cred_def_id, + schema_id=schema_id, + credential_proposal_dict=credential_proposal_message.serialize(), + auto_offer=self.context.settings.get( + "debug.auto_respond_credential_proposal" + ), + auto_issue=self.context.settings.get( + "debug.auto_respond_credential_request" + ) + ) + await credential_exchange_record.save( + self.context, + reason="Aries#0036v1.0 receive credential proposal" + ) + + return credential_exchange_record + + async def create_offer( + self, + credential_exchange_record: V10CredentialExchange, + *, + comment: str = None + ): + """ + Create a credential offer. + + Args: + credential_exchange_record: Credential exchange to create offer for + comment: Optional human-readable comment pertaining to offer creation + + Return: + A tuple (credential_exchange, credential_offer_message) + + """ + credential_definition_id = credential_exchange_record.credential_definition_id + + issuer: BaseIssuer = await self.context.inject(BaseIssuer) + credential_offer = await issuer.create_credential_offer( + credential_definition_id + ) + + cred_preview = CredentialProposal.deserialize( + credential_exchange_record.credential_proposal_dict + ).credential_proposal + credential_offer_message = CredentialOffer( + comment=comment, + credential_preview=cred_preview, + offers_attach=[AttachDecorator.from_indy_dict(credential_offer)] + ) + + credential_offer_message._thread = { + "thid": credential_exchange_record.thread_id + } + + credential_exchange_record.thread_id = credential_offer_message._thread_id + credential_exchange_record.schema_id = credential_offer["schema_id"] + credential_exchange_record.credential_definition_id = ( + credential_offer["cred_def_id"] + ) + credential_exchange_record.state = V10CredentialExchange.STATE_OFFER_SENT + credential_exchange_record.credential_offer = credential_offer + await credential_exchange_record.save( + self.context, + reason="Aries#0036v1.0 create credential offer" + ) + + return credential_exchange_record, credential_offer_message + + async def receive_offer( + self, + credential_exchange_record: V10CredentialExchange, + ): + """ + Receive a credential offer. + + Args: + credential_exchange_record: Credential exchange record with offer to receive + + Returns: + The credential_exchange_record + + """ + credential_exchange_record.state = V10CredentialExchange.STATE_OFFER_RECEIVED + await credential_exchange_record.save( + self.context, + reason="Aries#0036v1.0 receive credential offer" + ) + + return credential_exchange_record + + async def create_request( + self, + credential_exchange_record: V10CredentialExchange, + connection_record: ConnectionRecord + ): + """ + Create a credential request. + + Args: + credential_exchange_record: Credential exchange to create request for + connection_record: Connection to create the request for + + Return: + A tuple (credential_exchange_record, credential_request_message) + + """ + credential_definition_id = credential_exchange_record.credential_definition_id + credential_offer = credential_exchange_record.credential_offer + + did = connection_record.my_did + + if credential_exchange_record.credential_request: + self._logger.warning( + "create_request called multiple times for v1.0 credential exchange: %s", + credential_exchange_record.credential_exchange_id + ) + else: + ledger: BaseLedger = await self.context.inject(BaseLedger) + async with ledger: + credential_definition = await ledger.get_credential_definition( + credential_definition_id + ) + + holder: BaseHolder = await self.context.inject(BaseHolder) + ( + credential_exchange_record.credential_request, + credential_exchange_record.credential_request_metadata, + ) = await holder.create_credential_request( + credential_offer, + credential_definition, + did + ) + + credential_request_message = CredentialRequest( + requests_attach=[ + AttachDecorator.from_indy_dict( + credential_exchange_record.credential_request + ) + ] + ) + credential_request_message._thread = { + "thid": credential_exchange_record.thread_id + } + + credential_exchange_record.state = V10CredentialExchange.STATE_REQUEST_SENT + await credential_exchange_record.save( + self.context, + reason="Aries#0036v1.0 create credential request" + ) + + return credential_exchange_record, credential_request_message + + async def receive_request( + self, + credential_request_message: CredentialRequest + ): + """ + Receive a credential request. + + Args: + credential_request_message: Credential request to receive + + """ + + assert len(credential_request_message.requests_attach or []) == 1 + credential_request = credential_request_message.indy_cred_req(0) + + credential_exchange_record = await V10CredentialExchange.retrieve_by_tag_filter( + self.context, + tag_filter={"thread_id": credential_request_message._thread_id}, + ) + credential_exchange_record.credential_request = credential_request + credential_exchange_record.state = V10CredentialExchange.STATE_REQUEST_RECEIVED + await credential_exchange_record.save( + self.context, + reason="Aries#0036v1.0 receive credential request" + ) + + return credential_exchange_record + + async def issue_credential( + self, + credential_exchange_record: V10CredentialExchange, + *, + comment: str = None, + credential_values: dict + ): + """ + Issue a credential. + + Args: + credential_exchange_record: The credential exchange we are issuing a + credential for + credential_values: dict of credential attribute {name: value} pairs + + Returns: + Tuple: (Updated credential exchange record, credential message obj) + + """ + + schema_id = credential_exchange_record.schema_id + + if credential_exchange_record.credential: + self._logger.warning( + "issue_credential called multiple times for " + + "v1.0 credential exchange: %s", + credential_exchange_record.credential_exchange_id + ) + else: + credential_offer = credential_exchange_record.credential_offer + credential_request = credential_exchange_record.credential_request + + ledger: BaseLedger = await self.context.inject(BaseLedger) + async with ledger: + schema = await ledger.get_schema(schema_id) + + issuer: BaseIssuer = await self.context.inject(BaseIssuer) + ( + credential_exchange_record.credential, + _credential_revocation_id + ) = await issuer.create_credential( + schema, + credential_offer, + credential_request, + credential_values + ) + + credential_exchange_record.state = V10CredentialExchange.STATE_ISSUED + await credential_exchange_record.save( + self.context, + reason="Aries#0036v1.0 receive credential" + ) + + credential_message = CredentialIssue( + comment=comment, + credentials_attach=[ + AttachDecorator.from_indy_dict(credential_exchange_record.credential) + ] + ) + credential_message._thread = { + "thid": credential_exchange_record.thread_id, + "pthid": credential_exchange_record.parent_thread_id + } + + return credential_exchange_record, credential_message + + async def store_credential( + self, + credential_message: CredentialIssue + ): + """ + Store a credential in the wallet. + + Args: + credential_message: credential to store + + """ + assert len(credential_message.credentials_attach or []) == 1 + credential = credential_message.indy_credential(0) + + try: + credential_exchange_record = ( + await V10CredentialExchange.retrieve_by_tag_filter( + self.context, + tag_filter={"thread_id": credential_message._thread_id} + ) + ) + except StorageNotFoundError: + if not credential_message._thread or not credential_message._thread.pthid: + raise + + # If the thread_id does not return any results, we check the + # parent thread id to see if this exchange is nested and is + # re-using information from parent. In this case, we need the parent + # exchange state object to retrieve and re-use the + # credential_request_metadata + credential_exchange_record = ( + await V10CredentialExchange.retrieve_by_tag_filter( + self.context, + tag_filter={"thread_id": credential_message._thread.pthid} + ) + ) + + credential_exchange_record._id = None + credential_exchange_record.thread_id = credential_message._thread_id + credential_exchange_record.credential_id = None + credential_exchange_record.credential = None + + ledger: BaseLedger = await self.context.inject(BaseLedger) + async with ledger: + credential_definition = await ledger.get_credential_definition( + credential["cred_def_id"] + ) + + holder: BaseHolder = await self.context.inject(BaseHolder) + credential_id = await holder.store_credential( + credential_definition, + credential, + credential_exchange_record.credential_request_metadata, + CredentialPreview.deserialize( + credential_exchange_record.credential_proposal_dict[ + "credential_proposal" + ] + ).metadata() + ) + + wallet_credential = await holder.get_credential(credential_id) + + credential_exchange_record.state = V10CredentialExchange.STATE_STORED + credential_exchange_record.credential_id = credential_id + credential_exchange_record.credential = wallet_credential + await credential_exchange_record.save( + self.context, + reason="Aries#0036v1.0 store credential" + ) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/message_types.py b/aries_cloudagent/messaging/issue_credential/v1_0/message_types.py new file mode 100644 index 0000000000..4fc623859d --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/message_types.py @@ -0,0 +1,21 @@ +"""Message and inner object type identifiers for Connections.""" + +MESSAGE_FAMILY = "did:sov:BzCbsNYhMrjHiqZDTUASHg;spec/issue-credential/1.0/" + +# Message types + +CREDENTIAL_PROPOSAL = f"{MESSAGE_FAMILY}propose-credential" +CREDENTIAL_OFFER = f"{MESSAGE_FAMILY}offer-credential" +CREDENTIAL_REQUEST = f"{MESSAGE_FAMILY}request-credential" +CREDENTIAL_ISSUE = f"{MESSAGE_FAMILY}issue-credential" + +TOP = "aries_cloudagent.messaging.issue_credential.v1_0" +MESSAGE_TYPES = { + CREDENTIAL_PROPOSAL: f"{TOP}.messages.credential_proposal.CredentialProposal", + CREDENTIAL_OFFER: f"{TOP}.messages.credential_offer.CredentialOffer", + CREDENTIAL_REQUEST: f"{TOP}.messages.credential_request.CredentialRequest", + CREDENTIAL_ISSUE: f"{TOP}.messages.credential_issue.CredentialIssue" +} + +# Inner object types +CREDENTIAL_PREVIEW = f"{MESSAGE_FAMILY}credential-preview" diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/__init__.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_issue.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_issue.py new file mode 100644 index 0000000000..614273d0b1 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_issue.py @@ -0,0 +1,75 @@ +"""A credential content message.""" + + +from typing import Sequence + +from marshmallow import fields + +from ....agent_message import AgentMessage, AgentMessageSchema +from ....decorators.attach_decorator import AttachDecorator, AttachDecoratorSchema +from ..message_types import CREDENTIAL_ISSUE + + +HANDLER_CLASS = ( + "aries_cloudagent.messaging.issue_credential.v1_0.handlers." + + "credential_issue_handler.CredentialIssueHandler" +) + + +class CredentialIssue(AgentMessage): + """Class representing a credential.""" + + class Meta: + """Credential metadata.""" + + handler_class = HANDLER_CLASS + schema_class = "CredentialIssueSchema" + message_type = CREDENTIAL_ISSUE + + def __init__( + self, + _id: str = None, + *, + comment: str = None, + credentials_attach: Sequence[AttachDecorator] = None, + **kwargs + ): + """ + Initialize credential issue object. + + Args: + comment: optional comment + credentials_attach: credentials attachments + + """ + super().__init__(_id=_id, **kwargs) + self.comment = comment + self.credentials_attach = list(credentials_attach) if credentials_attach else [] + + def indy_credential(self, index: int = 0): + """ + Retrieve and decode indy credential from attachment. + + Args: + index: ordinal in attachment list to decode and return + (typically, list has length 1) + + """ + return self.credentials_attach[index].indy_dict + + +class CredentialIssueSchema(AgentMessageSchema): + """Credential schema.""" + + class Meta: + """Credential schema metadata.""" + + model_class = CredentialIssue + + comment = fields.Str(comment="Human-readable comment", required=False) + credentials_attach = fields.Nested( + AttachDecoratorSchema, + required=True, + many=True, + data_key="credentials~attach" + ) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_offer.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_offer.py new file mode 100644 index 0000000000..827a10c042 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_offer.py @@ -0,0 +1,82 @@ +"""A credential offer content message.""" + + +from typing import Sequence + +from marshmallow import fields + +from ....agent_message import AgentMessage, AgentMessageSchema +from ....decorators.attach_decorator import AttachDecorator, AttachDecoratorSchema +from ..message_types import CREDENTIAL_OFFER +from .inner.credential_preview import CredentialPreview, CredentialPreviewSchema + + +HANDLER_CLASS = ( + "aries_cloudagent.messaging.issue_credential.v1_0.handlers." + + "credential_offer_handler.CredentialOfferHandler" +) + + +class CredentialOffer(AgentMessage): + """Class representing a credential offer.""" + + class Meta: + """CredentialOffer metadata.""" + + handler_class = HANDLER_CLASS + schema_class = "CredentialOfferSchema" + message_type = CREDENTIAL_OFFER + + def __init__( + self, + _id: str = None, + *, + comment: str = None, + credential_preview: CredentialPreview = None, + offers_attach: Sequence[AttachDecorator] = None, + **kwargs + ): + """ + Initialize credential offer object. + + Args: + comment: optional human-readable comment + credential_preview: credential preview + offers_attach: list of offer attachments + + """ + super().__init__(_id=_id, **kwargs) + self.comment = comment + self.credential_preview = ( + credential_preview if credential_preview else CredentialPreview() + ) + self.offers_attach = list(offers_attach) if offers_attach else [] + + def indy_offer(self, index: int = 0): + """ + Retrieve and decode indy offer from attachment. + + Args: + index: ordinal in attachment list to decode and return + (typically, list has length 1) + + """ + return self.offers_attach[index].indy_dict + + +class CredentialOfferSchema(AgentMessageSchema): + """Credential offer schema.""" + + class Meta: + """Credential offer schema metadata.""" + + model_class = CredentialOffer + + comment = fields.Str(required=False, allow_none=False) + credential_preview = fields.Nested(CredentialPreviewSchema, required=False) + offers_attach = fields.Nested( + AttachDecoratorSchema, + required=True, + many=True, + data_key="offers~attach" + ) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_proposal.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_proposal.py new file mode 100644 index 0000000000..997915f4e6 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_proposal.py @@ -0,0 +1,65 @@ +"""A credential proposal content message.""" + +from marshmallow import fields + +from ....agent_message import AgentMessage, AgentMessageSchema +from ..message_types import CREDENTIAL_PROPOSAL +from .inner.credential_preview import CredentialPreview, CredentialPreviewSchema + + +HANDLER_CLASS = ( + "aries_cloudagent.messaging.issue_credential.v1_0.handlers." + + "credential_proposal_handler.CredentialProposalHandler" +) + + +class CredentialProposal(AgentMessage): + """Class representing a credential proposal.""" + + class Meta: + """CredentialProposal metadata.""" + + handler_class = HANDLER_CLASS + schema_class = "CredentialProposalSchema" + message_type = CREDENTIAL_PROPOSAL + + def __init__( + self, + _id: str = None, + *, + comment: str = None, + credential_proposal: CredentialPreview = None, + schema_id: str = None, + cred_def_id: str = None, + **kwargs + ): + """ + Initialize credential proposal object. + + Args: + comment: optional human-readable comment + credential_proposal: proposed credential preview + schema_id: schema identifier + cred_def_id: credential definition identifier + """ + super().__init__(_id, **kwargs) + self.comment = comment + self.credential_proposal = ( + credential_proposal if credential_proposal else CredentialPreview() + ) + self.schema_id = schema_id + self.cred_def_id = cred_def_id + + +class CredentialProposalSchema(AgentMessageSchema): + """Credential proposal schema.""" + + class Meta: + """Credential proposal schema metadata.""" + + model_class = CredentialProposal + + comment = fields.Str(required=False, allow_none=False) + credential_proposal = fields.Nested(CredentialPreviewSchema, required=True) + schema_id = fields.Str(required=False, allow_none=False) + cred_def_id = fields.Str(required=False, allow_none=False) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_request.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_request.py new file mode 100644 index 0000000000..cdeedbd99d --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_request.py @@ -0,0 +1,75 @@ +"""A credential request content message.""" + + +from typing import Sequence + +from marshmallow import fields + +from ....agent_message import AgentMessage, AgentMessageSchema +from ....decorators.attach_decorator import AttachDecorator, AttachDecoratorSchema +from ..message_types import CREDENTIAL_REQUEST + + +HANDLER_CLASS = ( + "aries_cloudagent.messaging.issue_credential.v1_0.handlers." + + "credential_request_handler.CredentialRequestHandler" +) + + +class CredentialRequest(AgentMessage): + """Class representing a credential request.""" + + class Meta: + """CredentialRequest metadata.""" + + handler_class = HANDLER_CLASS + schema_class = "CredentialRequestSchema" + message_type = CREDENTIAL_REQUEST + + def __init__( + self, + _id: str = None, + *, + comment: str = None, + requests_attach: Sequence[AttachDecorator] = None, + **kwargs + ): + """ + Initialize credential request object. + + Args: + requests_attach: requests attachments + comment: optional comment + + """ + super().__init__(_id=_id, **kwargs) + self.comment = comment + self.requests_attach = list(requests_attach) if requests_attach else [] + + def indy_cred_req(self, index: int = 0): + """ + Retrieve and decode indy credential request from attachment. + + Args: + index: ordinal in attachment list to decode and return + (typically, list has length 1) + + """ + return self.requests_attach[index].indy_dict + + +class CredentialRequestSchema(AgentMessageSchema): + """Credential request schema.""" + + class Meta: + """Credential request schema metadata.""" + + model_class = CredentialRequest + + comment = fields.Str(required=False) + requests_attach = fields.Nested( + AttachDecoratorSchema, + required=True, + many=True, + data_key="requests~attach" + ) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/__init__.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/credential_preview.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/credential_preview.py new file mode 100644 index 0000000000..e5e67daf4d --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/credential_preview.py @@ -0,0 +1,221 @@ +"""A credential preview inner object.""" + + +from typing import Sequence + +import base64 + +from marshmallow import fields, validate + +from .....models.base import BaseModel, BaseModelSchema +from ...message_types import CREDENTIAL_PREVIEW + + +class CredentialAttrPreview(BaseModel): + """Class representing a preview of an attibute.""" + + DEFAULT_META = {"mime-type": "text/plain"} + + class Meta: + """Attribute preview metadata.""" + + schema_class = "CredentialAttrPreviewSchema" + + def __init__( + self, + *, + name: str, + value: str, + encoding: str = None, + mime_type: str = None, + **kwargs): + """ + Initialize attribute preview object. + + Args: + name: attribute name + value: attribute value + encoding: encoding (omit or "base64") + mime_type: MIME type + + """ + super().__init__(**kwargs) + self.name = name + self.value = value + self.encoding = encoding.lower() if encoding else None + self.mime_type = ( + mime_type.lower() + if mime_type and mime_type != CredentialAttrPreview.DEFAULT_META.get( + "mime-type" + ) + else None + ) + + @staticmethod + def list_plain(plain: dict): + """ + Return a list of `CredentialAttrPreview` for plain text from names/values. + + Args: + plain: dict mapping names to values + + Returns: + CredentialAttrPreview on name/values pairs with default MIME type + + """ + return [CredentialAttrPreview(name=k, value=plain[k]) for k in plain] + + def b64_decoded_value(self) -> str: + """Value, base64-decoded if applicable.""" + + return base64.b64decode(self.value.encode()).decode( + ) if ( + self.value and + self.encoding and + self.encoding.lower() == "base64" + ) else self.value + + def __eq__(self, other): + """Equality comparator.""" + + if all( + getattr(self, attr, CredentialAttrPreview.DEFAULT_META.get(attr)) == + getattr(other, attr, CredentialAttrPreview.DEFAULT_META.get(attr)) + for attr in vars(self) + ): + return True # all attrs exactly match + + if self.name != other.name: + return False # distinct attribute names + + if ( + self.mime_type or "text/plain" + ).lower() != (other.mime_type or "text/plain").lower(): + return False # distinct MIME types + + return self.b64_decoded_value() == other.b64_decoded_value() + + +class CredentialAttrPreviewSchema(BaseModelSchema): + """Attribute preview schema.""" + + class Meta: + """Attribute preview schema metadata.""" + + model_class = CredentialAttrPreview + + name = fields.Str(description="Attribute name", required=True, example="attr_name") + mime_type = fields.Str( + description="MIME type", + required=False, + data_key="mime-type", + example="text/plain" + ) + encoding = fields.Str( + description="Encoding (specify base64 or omit for none)", + required=False, + example="base64", + validate=validate.Equal("base64", error="Must be absent or equal to {other}") + ) + value = fields.Str( + description="Attribute value", + required=True, + example="attr_value" + ) + + +class CredentialPreview(BaseModel): + """Class representing a credential preview inner object.""" + + class Meta: + """Credential preview metadata.""" + + schema_class = "CredentialPreviewSchema" + message_type = CREDENTIAL_PREVIEW + + def __init__( + self, + *, + _type: str = None, + attributes: Sequence[CredentialAttrPreview] = None, + **kwargs): + """ + Initialize credential preview object. + + Args: + _type: formalism for Marshmallow model creation: ignored + attributes (list): list of attribute preview dicts; e.g., [ + { + "name": "attribute_name", + "mime-type": "text/plain", + "value": "value" + }, + { + "name": "icon", + "mime-type": "image/png", + "encoding": "base64", + "value": "cG90YXRv" + } + ] + + """ + super().__init__(**kwargs) + self.attributes = list(attributes) if attributes else [] + + @property + def _type(self): + """Accessor for message type.""" + return CredentialPreview.Meta.message_type + + def attr_dict(self, decode: bool = False): + """ + Return name:value pair per attribute. + + Args: + decode: whether first to decode attributes marked as having encoding + + """ + return { + attr.name: base64.b64decode(attr.value.encode()).decode() + if ( + attr.encoding + and attr.encoding.lower() == "base64" + and decode + ) else attr.value + for attr in self.attributes + } + + def metadata(self): + """Return per-attribute mapping from name to MIME type and encoding.""" + return { + attr.name: { + **{"mime-type": attr.mime_type for attr in [attr] if attr.mime_type}, + **{"encoding": attr.encoding for attr in [attr] if attr.encoding} + } for attr in self.attributes + } + + +class CredentialPreviewSchema(BaseModelSchema): + """Credential preview schema.""" + + class Meta: + """Credential preview schema metadata.""" + + model_class = CredentialPreview + + _type = fields.Str( + description="Message type identifier", + required=False, + example=CREDENTIAL_PREVIEW, + data_key="@type", + validate=validate.Equal( + CREDENTIAL_PREVIEW, + error="Must be absent or equal to {other}" + ) + ) + attributes = fields.Nested( + CredentialAttrPreviewSchema, + many=True, + required=True, + data_key="attributes" + ) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/tests/__init__.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/tests/test_credential_preview.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/tests/test_credential_preview.py new file mode 100644 index 0000000000..4316fccb33 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/tests/test_credential_preview.py @@ -0,0 +1,221 @@ +from unittest import TestCase + +from ....message_types import CREDENTIAL_PREVIEW +from ..credential_preview import ( + CredentialAttrPreview, + CredentialPreview, + CredentialPreviewSchema +) + + +CRED_PREVIEW = CredentialPreview( + attributes=( + CredentialAttrPreview.list_plain({'test': '123', 'hello': 'world'}) + + [ + CredentialAttrPreview( + name='icon', + value='cG90YXRv', + encoding='base64', + mime_type='image/png' + ) + ] + ) +) + + +class TestCredentialAttrPreview(TestCase): + """Attribute preview tests""" + + def test_eq(self): + attr_previews_none_plain = [ + CredentialAttrPreview( + name="item", + value="value" + ), + CredentialAttrPreview( + name="item", + value="value", + encoding=None, + mime_type=None + ), + CredentialAttrPreview( + name="item", + value="value", + encoding=None, + mime_type="text/plain" + ), + CredentialAttrPreview( + name="item", + value="value", + encoding=None, + mime_type="TEXT/PLAIN" + ) + ] + attr_previews_b64_plain = [ + CredentialAttrPreview( + name="item", + value="dmFsdWU=", + encoding="base64" + ), + CredentialAttrPreview( + name="item", + value="dmFsdWU=", + encoding="base64", + mime_type=None + ), + CredentialAttrPreview( + name="item", + value="dmFsdWU=", + encoding="base64", + mime_type="text/plain" + ), + CredentialAttrPreview( + name="item", + value="dmFsdWU=", + encoding="BASE64", + mime_type="text/plain" + ), + CredentialAttrPreview( + name="item", + value="dmFsdWU=", + encoding="base64", + mime_type="TEXT/PLAIN" + ) + ] + attr_previews_different = [ + CredentialAttrPreview( + name="item", + value="dmFsdWU=", + encoding="base64", + mime_type="image/png" + ), + CredentialAttrPreview( + name="item", + value="distinct value", + mime_type=None + ), + CredentialAttrPreview( + name="distinct_name", + value="distinct value", + mime_type=None + ), + CredentialAttrPreview( + name="item", + value="xyzzy" + ) + ] + + for lhs in attr_previews_none_plain: + for rhs in attr_previews_b64_plain: + assert lhs == rhs # values decode to same + + for lhs in attr_previews_none_plain: + for rhs in attr_previews_different: + assert lhs != rhs + + for lhs in attr_previews_b64_plain: + for rhs in attr_previews_different: + assert lhs != rhs + + for lidx in range(len(attr_previews_none_plain) - 1): + for ridx in range(lidx + 1, len(attr_previews_none_plain)): + assert attr_previews_none_plain[lidx] == attr_previews_none_plain[ridx] + + for lidx in range(len(attr_previews_b64_plain) - 1): + for ridx in range(lidx + 1, len(attr_previews_b64_plain)): + assert attr_previews_b64_plain[lidx] == attr_previews_b64_plain[ridx] + + for lidx in range(len(attr_previews_different) - 1): + for ridx in range(lidx + 1, len(attr_previews_different)): + assert attr_previews_different[lidx] != attr_previews_different[ridx] + + +class TestCredentialPreview(TestCase): + """Presentation preview tests.""" + + def test_init(self): + """Test initializer.""" + assert CRED_PREVIEW.attributes + + def test_type(self): + """Test type.""" + assert CRED_PREVIEW._type == CREDENTIAL_PREVIEW + + def test_preview(self): + """Test preview for attr-dict and metadata utilities.""" + assert CRED_PREVIEW.attr_dict(decode=False) == { + 'test': '123', + 'hello': 'world', + 'icon': 'cG90YXRv' + } + assert CRED_PREVIEW.attr_dict(decode=True) == { + 'test': '123', + 'hello': 'world', + 'icon': 'potato' + } + assert CRED_PREVIEW.metadata() == { + 'test': { + }, + 'hello': { + }, + 'icon': { + 'mime-type': 'image/png', + 'encoding': 'base64' + } + } + + def test_deserialize(self): + """Test deserialize.""" + obj = { + '@type': CREDENTIAL_PREVIEW, + 'attributes': [ + { + 'name': 'name', + 'mime-type': 'text/plain', + 'value': 'Alexander Delarge' + }, + { + 'name': 'pic', + 'mime-type': 'image/png', + 'encoding': 'base64', + 'value': 'Abcd0123...' + } + ] + } + + cred_preview = CredentialPreview.deserialize(obj) + assert type(cred_preview) == CredentialPreview + + def test_serialize(self): + """Test serialization.""" + + cred_preview_dict = CRED_PREVIEW.serialize() + assert cred_preview_dict == { + "@type": CREDENTIAL_PREVIEW, + "attributes": [ + { + "name": "test", + "value": "123" + }, + { + "name": "hello", + "value": "world" + }, + { + "name": "icon", + "mime-type": "image/png", + "encoding": "base64", + "value": "cG90YXRv" + } + ] + } + + +class TestCredentialPreviewSchema(TestCase): + """Test credential cred preview schema.""" + + def test_make_model(self): + """Test making model.""" + data = CRED_PREVIEW.serialize() + model_instance = CredentialPreview.deserialize(data) + assert isinstance(model_instance, CredentialPreview) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/__init__.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_issue.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_issue.py new file mode 100644 index 0000000000..8f3e277721 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_issue.py @@ -0,0 +1,148 @@ +from .....decorators.attach_decorator import AttachDecorator +from ...message_types import CREDENTIAL_ISSUE +from ..credential_issue import CredentialIssue + +from unittest import mock, TestCase + + +class TestCredentialIssue(TestCase): + """Credential issue tests""" + + indy_cred = { + "schema_id": "LjgpST2rjsoxYegQDRm7EL:2:bc-reg:1.0", + "cred_def_id": "LjgpST2rjsoxYegQDRm7EL:3:CL:18:tag", + "rev_reg_id": "LjgpST2rjsoxYegQDRm7EL:4:LjgpST2rjsoxYegQDRm7EL:3:CL:18:tag:CL_ACCUM:1", + "values": { + "busId": { + "raw": "11155555", + "encoded": "11155555" + }, + "legalName": { + "raw": "Babka Galaxy", + "encoded": "107723975795096474174315415205901102419879622561395089750910511985549475735747" + }, + "id": { + "raw": "5", + "encoded": "5" + }, + "orgTypeId": { + "raw": "1", + "encoded": "1" + }, + "effectiveDate": { + "raw": "2012-12-01", + "encoded": "58785836675119218543950531421539993546216494060018521243314445986885543138388" + }, + "jurisdictionId": { + "raw": "1", + "encoded": "1" + }, + "endDate": { + "raw": "", + "encoded": "102987336249554097029535212322581322789799900648198034993379397001115665086549" + } + }, + "signature": { + "p_credential": { + "m_2": "60025883287089799626689274984362649922028954710702989273350424792094051625907", + "a": "33574785085847496372223801384241174668280696192852342004649681358898319989377891201713237406189930904621943660579244780378356431325594072391319837474469436200535615918847408676250915598611100068705846552950672619639766733118699744590194148554187848404028169947572858712592004307286251531728499790515868404251079046925435202101170698552776314885035743276729493940581544827310348632105741785505818500141788882165796461479904049413245974826370118124656594309043126033311790481868941737635314924873471152593101941520014919522243774177999183508913726745154494726830096189641688720673911842149721875115446765101254783088102", + "e": "259344723055062059907025491480697571938277889515152306249728583105665800713306759149981690559193987143012367913206299323899696942213235956742929940839890541204554505134958365542601", + "v": "8609087712648327689510560843448768242969198387856549646434987127729892694214386082710530362693226591495343780017066542203667948482019255226968628218013767981247576292730389932608795727994162072985790185993138122475561426334951896920290599111436791225402577204027790420706987810169826735050717355066696030347321187354133263894735515127702270039945304850524250402144664403971571904353156572222923701680935669167750650688016372444804704998087365054978152701248950729399377780813365024757989269208934482967970445445223084620917624825052959697120057360426040239100930790635416973591134497181715131476498510569905885753432826750000829362210364061766697316138646771666357343198925355584209303847699218225254051213598531538421032318684976506329062116913654998320196203740062523483508588929287294193683755114531891923195772740958" + }, + "r_credential": { + "sigma": "1 00F38C50E192DAF9133130888DA4A3291754B1A7D09A7DCCDD408D4E13F57267 1 0C6C9D8510580A8C9D8F0E21F51FF76E8F1419C2C909BBB9761AD9E75E46517F 2 095E45DDF417D05FB10933FFC63D474548B7FFFF7888802F07FFFFFF7D07A8A8", + "c": "12F8B7BD08471C27F6AF8EE06374D200FCEA61718FACA61FD8B90EEED7A11AD6", + "vr_prime_prime": "103015BFD51C02121DF61993973F312D5972EFF3B3B1B80BC614D5A747510366", + "witness_signature": { + "sigma_i": "1 165767F82FF8FD92237985441D2C758706A5EC1D21FBEF8611C6AC4E3CAD10DA 1 1FC786E5CD2D8B30F1C567579B4EC143C5951B7464F78B86A03419CB335EA81B 1 0B1A1356056BEDF9C61AE2D66FF0405E3B1D934DAC97099BDF6AC3ECCBFAF745 1 106B15BC294810EEDF8AD363A85CC8ECC8AA061538BB31BAE5252377D77E7FA3 2 095E45DDF417D05FB10933FFC63D474548B7FFFF7888802F07FFFFFF7D07A8A8 1 0000000000000000000000000000000000000000000000000000000000000000", + "u_i": "1 017A61B7C8B5B80EB245BE6788A28F926D8CBB9829E657D437640EF09ACD0C80 1 1AF4229C05C728AEAEEE6FC411B357B857E773BA79FF677373A6BE8F60C02C3A 1 10CB82C4913E2324C06164BF22A2BD38CEE528C797C55061C2D2486C3F6BF747 1 116CE544B1CB99556BFC0621C57C3D9F2B78D034946322EEA218DFDBDD940EA3 2 095E45DDF417D05FB10933FFC63D474548B7FFFF7888802F07FFFFFF7D07A8A8 1 0000000000000000000000000000000000000000000000000000000000000000", + "g_i": "1 0042BF46E9BAE9696F394FE7C26AFDE3C8963A2A0658D4C32737405F1576EB46 1 0194E97A9D92D46AAD61DAE06926D3361F531EB10D03C7520F3BD69D3E49311C 2 095E45DDF417D05FB10933FFC63D474548B7FFFF7888802F07FFFFFF7D07A8A8" + }, + "g_i": "1 0042BF46E9BAE9696F394FE7C26AFDE3C8963A2A0658D4C32737405F1576EB46 1 0194E97A9D92D46AAD61DAE06926D3361F531EB10D03C7520F3BD69D3E49311C 2 095E45DDF417D05FB10933FFC63D474548B7FFFF7888802F07FFFFFF7D07A8A8", + "i": 1, + "m2": "84B5722AE3A1CF27CB1EA56CD33D289CB87A4401C6B103D0D7B7EA869DAF6BB3" + } + }, + "signature_correctness_proof": { + "se": "19792617148120152105226254239016588540058878757479987545108556827210662529343348161518678852958020771878595740749192412985440625444455760950622452787061547854765389520937092533324699495837410270589105368479415954380927050080439536019149709356488657394895381670676082762285043378943096265107585990717517541825549361747506315768406364562926877132553754434293723146759285511815164904802662712140021121638529229138315163496513377824821704164701067409581646133944445999621553849950380606679724798867481070896073389886302519310697801643262282687875393404841657943289557895895565050618203027512724917946512514235898009424924", + "c": "20346348618412341786428948997994890734628812067145521907471418530511751955386" + }, + "rev_reg": { + "accum": "21 12E821764448DE2B5754DEC16864096CFAE4BB68D4DC0CE3E5C4849FC7CBCCC0C 21 11677132B2DFB0C291D0616811BF2AC0CD464A35FF6927B821A5EACF24D94F3A5 6 5471991A0950DBD431A4DD86A8AD101E033AB5EBC29A97CAFE0E4F2C426F5821 4 1B34A4C75174974A698061A09AFFED62B78AC2AAF876BF7788BAF3FC9A8B47DF 6 7D7C5E96AE17DDB21EC98378E3185707A69CF86426F5526C9A55D1FAA2F6FA83 4 277100094333E24170CD3B020B0C91A7E9510F69218AD96AC966565AEF66BC71" + }, + "witness": { + "omega": "21 136960A5E73C494F007BFE156889137E8B6DF301D5FF673C410CEE0F14AFAF1AE 21 132D4BA49C6BD8AB3CF52929D115976ABB1785D288F311CBB4455A85D07E2568C 6 70E7C40BA4F607262697556BB17FA6C85E9C188FA990264F4F031C39B5811239 4 351B98620B239DF14F3AB0B754C70597035A3B099D287A9855D11C55BA9F0C16 6 8AA1C473D792DF4F8287D0A93749046385CE411AAA1D685AA3C874C15B8628DB 4 0D6491BF5F127C1A0048CF137AEE17B62F4E49F3BDD9ECEBD14D56C43D211544" + } + } + + cred_issue = CredentialIssue( + comment="Test", + credentials_attach=[AttachDecorator.from_indy_dict(indy_cred)] + ) + + def test_init(self): + """Test initializer""" + credential_issue = CredentialIssue( + comment="Test", + credentials_attach=[AttachDecorator.from_indy_dict(self.indy_cred)] + ) + assert credential_issue.credentials_attach[0].indy_dict == self.indy_cred + assert credential_issue.indy_credential(0) == self.indy_cred + + def test_type(self): + """Test type""" + credential_issue = CredentialIssue( + comment="Test", + credentials_attach=[AttachDecorator.from_indy_dict(self.indy_cred)] + ) + + assert credential_issue._type == CREDENTIAL_ISSUE + + @mock.patch( + "aries_cloudagent.messaging.issue_credential.v1_0.messages." + + "credential_issue.CredentialIssueSchema.load" + ) + def test_deserialize(self, mock_credential_issue_schema_load): + """ + Test deserialize + """ + obj = self.indy_cred + + credential_issue = CredentialIssue.deserialize(obj) + mock_credential_issue_schema_load.assert_called_once_with(obj) + + assert credential_issue is mock_credential_issue_schema_load.return_value + + @mock.patch( + "aries_cloudagent.messaging.issue_credential.v1_0.messages." + + "credential_issue.CredentialIssueSchema.dump" + ) + def test_serialize(self, mock_credential_issue_schema_dump): + """ + Test serialization. + """ + credential_issue = CredentialIssue( + comment="Test", + credentials_attach=[AttachDecorator.from_indy_dict(self.indy_cred)] + ) + + credential_issue_dict = credential_issue.serialize() + mock_credential_issue_schema_dump.assert_called_once_with(credential_issue) + + assert credential_issue_dict is mock_credential_issue_schema_dump.return_value + + +class TestCredentialIssueSchema(TestCase): + """Test credential cred request schema""" + + credential_issue = CredentialIssue( + comment="Test", + # credentials_attach=[AttachDecorator.from_indy_dict(TestCredentialIssue.indy_cred)] + credentials_attach=[AttachDecorator.from_indy_dict({'hello': 'world'})] + ) + + def test_make_model(self): + """Test making model.""" + data = self.credential_issue.serialize() + model_instance = CredentialIssue.deserialize(data) + assert isinstance(model_instance, CredentialIssue) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_offer.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_offer.py new file mode 100644 index 0000000000..33a766e18d --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_offer.py @@ -0,0 +1,115 @@ +from .....decorators.attach_decorator import AttachDecorator +from ...message_types import CREDENTIAL_OFFER +from ..credential_offer import CredentialOffer +from ..inner.credential_preview import CredentialAttrPreview, CredentialPreview + +from unittest import mock, TestCase + + +class TestCredentialOffer(TestCase): + """Credential offer tests""" + + indy_offer = { + "nonce": "614100168443907415054289", + "schema_id": "GMm4vMw8LLrLJjp81kRRLp:2:drinks:1.0", + "cred_def_id": "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag", + "key_correctness_proof": { + "c": "56585275561905717161839952743647026395542989876162452893531670700564212393854", + "xz_cap": "287165348340975789727971384349378142287959058342225940074538845935436773874329328991029519089234724427434486533815831859470220965864684738156552672305499237703498538513271159520247291220966466227432832227063850459130839372368207180055849729905609985998538537568736869293407524473496064816314603497171002962615258076622463931743189286900496109205216280607630768503576692684508445827948798897460776289403908388023698817549732288585499518794247355928287227786391839285875542402023633857234040392852972008208415080178060364227401609053629562410235736217349229809414782038441992275607388974250885028366550369268002576993725278650846144639865881671599246472739142902676861561148712912969364530598265", + "xr_cap": [ + [ + "member", + "247173988424242283128308731284354519593625104582055668969315003963838548670841899501658349312938942946846730152870858369236571789232183841781453957957720697180067746500659257059976519795874971348181945469064991738990738845965440847535223580150468375375443512237424530837415294161162638683584221123453778375487245671372772618360172541002473454666729113558205280977594339672398197686260680189972481473789054636358472310216645491588945137379027958712059669609528877404178425925715596671339305959202588832885973524555444251963470084399490131160758976923444260763440975941005911948597957705824445435191054260665559130246488082450660079956928491647323363710347167509227696874201965902602039122291827" + ], + [ + "favourite", + "1335045667644070498565118732156146549025899560440568943935771536511299164006020730238478605099548137764051990321413418863325926730012675851687537953795507658228985382833693223549386078823801188511091609027561372137859781602606173745112393410558404328055415428275164533367998547196095783458226529569321865083846885509205360165413682408429660871664533434140200342530874654054024409641491095797032894595844264175356021739370667850887453108137634226023771337973520900908849320630756049969052968900455735023806005098461831167599998292029791540116613937132049776519811961709679592741659868352478832873910002910063294074562896887581629929595271513565238416621119418443383796085468376565042025935483490" + ], + [ + "master_secret", + "1033992860010367458372180504097559955661066772142722707045156268794833109485917658718054000138242001598760494274716663669095123169580783916372365989852993328621834238281615788751278692675115165487417933883883618299385468584923910731758768022514670608541825229491053331942365151645754250522222493603795702384546708563091580112967031435038732735155283423684631622768416201085577137158105343396606143962017453945220908112975903537378485103755718950361047334234687103399968712220979025991673471498636490232494897885460464490635716242509247751966176791851396526210422140145723375747195416033531994076204650208879292521201294795264925045126704368284107432921974127792914580116411247536542717749670349" + ] + ] + } + } + preview = CredentialPreview( + attributes=CredentialAttrPreview.list_plain( + {'member': 'James Bond', 'favourite': 'martini'} + ) + ) + offer = CredentialOffer( + comment="shaken, not stirred", + credential_preview=preview, + offers_attach=[AttachDecorator.from_indy_dict(indy_offer)] + ) + + def test_init(self): + """Test initializer""" + credential_offer = CredentialOffer( + comment="shaken, not stirred", + credential_preview=self.preview, + offers_attach=[AttachDecorator.from_indy_dict(self.indy_offer)] + ) + assert credential_offer.credential_preview == self.preview + assert credential_offer.offers_attach[0].indy_dict == self.indy_offer + assert credential_offer.indy_offer(0) == self.indy_offer + + def test_type(self): + """Test type""" + credential_offer = CredentialOffer( + comment="shaken, not stirred", + credential_preview=self.preview, + offers_attach=[AttachDecorator.from_indy_dict(self.indy_offer)] + ) + + assert credential_offer._type == CREDENTIAL_OFFER + + @mock.patch( + "aries_cloudagent.messaging.issue_credential.v1_0.messages." + + "credential_offer.CredentialOfferSchema.load" + ) + def test_deserialize(self, mock_credential_offer_schema_load): + """ + Test deserialize + """ + obj = self.indy_offer + + credential_offer = CredentialOffer.deserialize(obj) + mock_credential_offer_schema_load.assert_called_once_with(obj) + + assert credential_offer is mock_credential_offer_schema_load.return_value + + @mock.patch( + "aries_cloudagent.messaging.issue_credential.v1_0.messages." + + "credential_offer.CredentialOfferSchema.dump" + ) + def test_serialize(self, mock_credential_offer_schema_dump): + """ + Test serialization. + """ + credential_offer = CredentialOffer( + comment="shaken, not stirred", + credential_preview=self.preview, + offers_attach=[AttachDecorator.from_indy_dict(self.indy_offer)] + ) + + credential_offer_dict = credential_offer.serialize() + mock_credential_offer_schema_dump.assert_called_once_with(credential_offer) + + assert credential_offer_dict is mock_credential_offer_schema_dump.return_value + + +class TestCredentialOfferSchema(TestCase): + """Test credential cred offer schema""" + + credential_offer = CredentialOffer( + comment="shaken, not stirred", + credential_preview=TestCredentialOffer.preview, + offers_attach=[AttachDecorator.from_indy_dict(TestCredentialOffer.indy_offer)] + ) + + def test_make_model(self): + """Test making model.""" + data = self.credential_offer.serialize() + model_instance = CredentialOffer.deserialize(data) + assert isinstance(model_instance, CredentialOffer) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_proposal.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_proposal.py new file mode 100644 index 0000000000..f40cc40138 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_proposal.py @@ -0,0 +1,127 @@ +from ..credential_proposal import CredentialProposal +from ..inner.credential_preview import CredentialAttrPreview, CredentialPreview +from ...message_types import CREDENTIAL_PREVIEW, CREDENTIAL_PROPOSAL + +from unittest import TestCase + + +CRED_PREVIEW = CredentialPreview( + attributes=( + CredentialAttrPreview.list_plain({"test": "123", "hello": "world"}) + + [ + CredentialAttrPreview( + name="icon", + value="cG90YXRv", + encoding="base64", + mime_type="image/png" + ) + ] + ) +) + + +class TestCredentialProposal(TestCase): + """Credential proposal tests.""" + + def test_init(self): + """Test initializer.""" + credential_proposal = CredentialProposal( + comment="Hello World", + credential_proposal=CRED_PREVIEW, + schema_id="GMm4vMw8LLrLJjp81kRRLp:2:tails_load:1560364003.0", + cred_def_id="GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" + ) + assert credential_proposal.credential_proposal == CRED_PREVIEW + + def test_type(self): + """Test type.""" + credential_proposal = CredentialProposal( + comment="Hello World", + credential_proposal=CRED_PREVIEW, + schema_id="GMm4vMw8LLrLJjp81kRRLp:2:tails_load:1560364003.0", + cred_def_id="GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" + ) + + assert credential_proposal._type == CREDENTIAL_PROPOSAL + + def test_deserialize(self): + """Test deserialize.""" + obj = { + "comment": "Hello World", + "credential_proposal": { + "@type": CREDENTIAL_PREVIEW, + "attributes": [ + { + "name": "name", + "value": "Alexander Delarge" + }, + { + "name": "pic", + "mime-type": "image/png", + "encoding": "base64", + "value": "Abcd0123..." + } + ] + }, + "schema_id": "GMm4vMw8LLrLJjp81kRRLp:2:tails_load:1560364003.0", + "cred_def_id": "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" + } + + cred_proposal = CredentialProposal.deserialize(obj) + assert type(cred_proposal) == CredentialProposal + + def test_serialize(self): + """Test serialization.""" + + cred_proposal = CredentialProposal( + comment="Hello World", + credential_proposal=CRED_PREVIEW, + schema_id="GMm4vMw8LLrLJjp81kRRLp:2:tails_load:1560364003.0", + cred_def_id="GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" + ) + + cred_proposal_dict = cred_proposal.serialize() + cred_proposal_dict.pop("@id") + + assert cred_proposal_dict == { + "@type": CREDENTIAL_PROPOSAL, + "comment": "Hello World", + "credential_proposal": { + "@type": CREDENTIAL_PREVIEW, + "attributes": [ + { + "name": "test", + "value": "123" + }, + { + "name": "hello", + "value": "world" + }, + { + "name": "icon", + "mime-type": "image/png", + "encoding": "base64", + "value": "cG90YXRv" + } + ] + }, + "schema_id": "GMm4vMw8LLrLJjp81kRRLp:2:tails_load:1560364003.0", + "cred_def_id": "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" + } + + +class TestCredentialProposalSchema(TestCase): + """Test credential cred proposal schema.""" + + credential_proposal = CredentialProposal( + comment="Hello World", + credential_proposal=CRED_PREVIEW, + schema_id="GMm4vMw8LLrLJjp81kRRLp:2:tails_load:1560364003.0", + cred_def_id="GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" + ) + + def test_make_model(self): + """Test making model.""" + data = self.credential_proposal.serialize() + model_instance = CredentialProposal.deserialize(data) + assert isinstance(model_instance, CredentialProposal) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_request.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_request.py new file mode 100644 index 0000000000..33fdafd962 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_request.py @@ -0,0 +1,102 @@ +from .....decorators.attach_decorator import AttachDecorator +from ...message_types import CREDENTIAL_REQUEST +from ..credential_request import CredentialRequest + +from unittest import mock, TestCase + + +class TestCredentialRequest(TestCase): + """Credential request tests""" + + indy_cred_req = { + "nonce": "1017762706737386703693758", + "prover_did": "GMm4vMw8LLrLJjp81kRRLp", + "cred_def_id": "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag", + "blinded_ms": { + "u": "83907504917598709544715660183444547664806528194879236493704185267249518487609477830252206438464922282419526404954032744426656836343614241707982523911337758117991524606767981934822739259321023980818911648706424625657217291525111737996606024710795596961607334766957629765398381678917329471919374676824400143394472619220909211861028497009707890651887260349590274729523062264675018736459760546731362496666872299645586181905130659944070279943157241097916683504866583173110187429797028853314290183583689656212022982000994142291014801654456172923356395840313420880588404326139944888917762604275764474396403919497783080752861", + "ur": "1 2422A7A25A9AB730F3399C77C28E1F6E02BB94A2C07D245B28DC4EE33E33DE49 1 1EF3FBD36FBA7510BDA79386508C0A84A33DF4171107C22895ACAE4FA4499F02 2 095E45DDF417D05FB10933FFC63D474548B7FFFF7888802F07FFFFFF7D07A8A8", + "hidden_attributes": [ + "master_secret" + ], + "committed_attributes": {} + }, + "blinded_ms_correctness_proof": { + "c": "77782990462020711078900471139684606615516190979556618670020830699801678914552", + "v_dash_cap": "1966215015532422356590954855080129096516569112935438312989092847889400013191094311374123910677667707922694722167856889267996544544770134106600289624974901761453909338477897555013062690166110508298265469948048257876547569520215226798025984795668101468265482570744011744194025718081101032551943108999422057478928838218205736972438022128376728526831967897105301274481454020377656694232901381674223529320224276009919370080174601226836784570762698964476355045131401700464714725647784278935633253472872446202741297992383148244277451017022036452203286302631768247417186601621329239603862883753434562838622266122331169627284313213964584034951472090601638790603966977114416216909593408778336960753110805965734708636782885161632", + "m_caps": { + "master_secret": "1932933391026030434402535597188163725022560167138754201841873794167337347489231254032687761158191503499965986291267527620598858412377279828812688105949083285487853357240244045442" + }, + "r_caps": {} + } + } + + cred_req = CredentialRequest( + comment="Test", + requests_attach=[AttachDecorator.from_indy_dict(indy_cred_req)] + ) + + def test_init(self): + """Test initializer""" + credential_request = CredentialRequest( + comment="Test", + requests_attach=[AttachDecorator.from_indy_dict(self.indy_cred_req)] + ) + assert credential_request.requests_attach[0].indy_dict == self.indy_cred_req + assert credential_request.indy_cred_req(0) == self.indy_cred_req + + def test_type(self): + """Test type""" + credential_request = CredentialRequest( + comment="Test", + requests_attach=[AttachDecorator.from_indy_dict(self.indy_cred_req)] + ) + + assert credential_request._type == CREDENTIAL_REQUEST + + @mock.patch( + "aries_cloudagent.messaging.issue_credential.v1_0.messages." + + "credential_request.CredentialRequestSchema.load" + ) + def test_deserialize(self, mock_credential_request_schema_load): + """ + Test deserialize + """ + obj = self.indy_cred_req + + credential_request = CredentialRequest.deserialize(obj) + mock_credential_request_schema_load.assert_called_once_with(obj) + + assert credential_request is mock_credential_request_schema_load.return_value + + @mock.patch( + "aries_cloudagent.messaging.issue_credential.v1_0.messages." + + "credential_request.CredentialRequestSchema.dump" + ) + def test_serialize(self, mock_credential_request_schema_dump): + """ + Test serialization. + """ + credential_request = CredentialRequest( + comment="Test", + requests_attach=[AttachDecorator.from_indy_dict(self.indy_cred_req)] + ) + + credential_request_dict = credential_request.serialize() + mock_credential_request_schema_dump.assert_called_once_with(credential_request) + + assert credential_request_dict is mock_credential_request_schema_dump.return_value + + +class TestCredentialRequestSchema(TestCase): + """Test credential cred request schema""" + + credential_request = CredentialRequest( + comment="Test", + requests_attach=[AttachDecorator.from_indy_dict(TestCredentialRequest.indy_cred_req)] + ) + + def test_make_model(self): + """Test making model.""" + data = self.credential_request.serialize() + model_instance = CredentialRequest.deserialize(data) + assert isinstance(model_instance, CredentialRequest) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/models/__init__.py b/aries_cloudagent/messaging/issue_credential/v1_0/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/models/credential_exchange.py b/aries_cloudagent/messaging/issue_credential/v1_0/models/credential_exchange.py new file mode 100644 index 0000000000..65c0d3ca27 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/models/credential_exchange.py @@ -0,0 +1,142 @@ +"""Aries#0036 v1.0 credential exchange information with non-secrets storage.""" + +from marshmallow import fields + +from ....models.base_record import BaseRecord, BaseRecordSchema + + +class V10CredentialExchange(BaseRecord): + """Represents an Aries#0036 credential exchange.""" + + class Meta: + """CredentialExchange metadata.""" + + schema_class = "V10CredentialExchangeSchema" + + RECORD_TYPE = "v10_credential_exchange" + RECORD_ID_NAME = "credential_exchange_id" + WEBHOOK_TOPIC = "Aries#0036 v1.0 credentials" + + INITIATOR_SELF = "self" + INITIATOR_EXTERNAL = "external" + + STATE_PROPOSAL_SENT = "proposal_sent" + STATE_PROPOSAL_RECEIVED = "proposal_received" + STATE_OFFER_SENT = "offer_sent" + STATE_OFFER_RECEIVED = "offer_received" + STATE_REQUEST_SENT = "request_sent" + STATE_REQUEST_RECEIVED = "request_received" + STATE_ISSUED = "issued" + STATE_STORED = "stored" + + def __init__( + self, + *, + credential_exchange_id: str = None, + connection_id: str = None, + thread_id: str = None, + parent_thread_id: str = None, + initiator: str = None, + state: str = None, + credential_definition_id: str = None, + schema_id: str = None, + credential_proposal_dict: dict = None, # serialized credential proposal message + credential_offer: dict = None, # indy credential offer + credential_request: dict = None, # indy credential request + credential_request_metadata: dict = None, + credential_id: str = None, + credential: dict = None, # indy credential + auto_offer: bool = False, + auto_issue: bool = False, + error_msg: str = None, + **kwargs + ): + """Initialize a new V10CredentialExchange.""" + super().__init__(credential_exchange_id, state, **kwargs) + self._id = credential_exchange_id + self.connection_id = connection_id + self.thread_id = thread_id + self.parent_thread_id = parent_thread_id + self.initiator = initiator + self.state = state + self.credential_definition_id = credential_definition_id + self.schema_id = schema_id + self.credential_proposal_dict = credential_proposal_dict + self.credential_offer = credential_offer + self.credential_request = credential_request + self.credential_request_metadata = credential_request_metadata + self.credential_id = credential_id + self.credential = credential + self.auto_offer = auto_offer + self.auto_issue = auto_issue + self.error_msg = error_msg + + @property + def credential_exchange_id(self) -> str: + """Accessor for the ID associated with this exchange.""" + return self._id + + @property + def record_value(self) -> dict: + """Accessor for the JSON record value generated for this credential exchange.""" + result = self.tags + for prop in ( + "credential_proposal_dict", + "credential_offer", + "credential_request", + "credential_request_metadata", + "error_msg", + "auto_offer", + "auto_issue", + "credential", + "parent_thread_id" + ): + val = getattr(self, prop) + if val: + result[prop] = val + return result + + @property + def record_tags(self) -> dict: + """Accessor for the record tags generated for this credential exchange.""" + result = {} + for prop in ( + "connection_id", + "thread_id", + "initiator", + "state", + "credential_definition_id", + "schema_id", + "credential_id", + ): + val = getattr(self, prop) + if val: + result[prop] = val + return result + + +class V10CredentialExchangeSchema(BaseRecordSchema): + """Schema to allow serialization/deserialization of credential exchange records.""" + + class Meta: + """V10CredentialExchangeSchema metadata.""" + + model_class = V10CredentialExchange + + credential_exchange_id = fields.Str(required=False) + connection_id = fields.Str(required=False) + thread_id = fields.Str(required=False) + parent_thread_id = fields.Str(required=False) + initiator = fields.Str(required=False) + state = fields.Str(required=False) + credential_definition_id = fields.Str(required=False) + schema_id = fields.Str(required=False) + credential_proposal_dict = fields.Dict(required=False) + credential_offer = fields.Dict(required=False) + credential_request = fields.Dict(required=False) + credential_request_metadata = fields.Dict(required=False) + credential_id = fields.Str(required=False) + credential = fields.Dict(required=False) + auto_offer = fields.Bool(required=False) + auto_issue = fields.Bool(required=False) + error_msg = fields.Str(required=False) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/routes.py b/aries_cloudagent/messaging/issue_credential/v1_0/routes.py new file mode 100644 index 0000000000..c91aa9aa49 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/routes.py @@ -0,0 +1,590 @@ +"""Connection handling admin routes.""" + +from aiohttp import web +from aiohttp_apispec import docs, request_schema, response_schema +from marshmallow import fields, Schema + +from ....holder.base import BaseHolder +from ....storage.error import StorageNotFoundError + +from ...connections.models.connection_record import ConnectionRecord +from ...valid import INDY_CRED_DEF_ID + +from .manager import CredentialManager +from .messages.credential_proposal import CredentialProposal +from .messages.inner.credential_preview import ( + CredentialAttrPreview, + CredentialPreview, + CredentialPreviewSchema +) +from .models.credential_exchange import ( + V10CredentialExchange, + V10CredentialExchangeSchema +) + + +class V10CredentialProposalRequestSchema(Schema): + """Request schema for sending a credential proposal admin message.""" + + connection_id = fields.UUID(description="Connection identifier", required=True) + credential_definition_id = fields.Str( + description="Credential definition identifier", + required=True, + **INDY_CRED_DEF_ID + ) + comment = fields.Str( + description="Human-readable comment", + required=False + ) + credential_proposal = fields.Nested(CredentialPreviewSchema, required=True) + + +class V10CredentialProposalResultSchema(V10CredentialExchangeSchema): + """Result schema for sending a credential proposal admin message.""" + + +class V10CredentialOfferRequestSchema(Schema): + """Request schema for sending a credential offer admin message.""" + + connection_id = fields.UUID(description="Connection identifier", required=True) + credential_definition_id = fields.Str( + description="Credential definition identifier", + required=True, + **INDY_CRED_DEF_ID + ) + auto_issue = fields.Bool( + description=( + "Whether to respond automatically to credential requests, creating " + "and issuing requested credentials" + ), + required=False, + default=False + ) + comment = fields.Str( + description="Human-readable comment", + required=False + ) + credential_preview = fields.Nested(CredentialPreviewSchema, required=True) + + +class V10CredentialOfferResultSchema(V10CredentialExchangeSchema): + """Result schema for sending a credential offer admin message.""" + + +class V10CredentialRequestResultSchema(Schema): + """Result schema for sending a credential request admin message.""" + + credential_id = fields.Str() + + +class V10CredentialIssueRequestSchema(Schema): + """Request schema for sending a credential issue admin message.""" + + comments = fields.Str( + description="Human-readable comment", + required=False + ) + credential_preview = fields.Nested(CredentialPreviewSchema, required=True) + + +class V10CredentialIssueResultSchema(Schema): + """Result schema for sending a credential issue admin message.""" + + credential_exchange = fields.Nested(V10CredentialExchangeSchema) + + +class V10CredentialExchangeListSchema(Schema): + """Result schema for an Aries#0036 v1.0 credential exchange query.""" + + results = fields.List( + fields.Nested(V10CredentialExchangeSchema), + description="Aries#0036 v1.0 credential exchange records" + ) + + +class V10AttributeMetadataResultSchema(Schema): + """Result schema for credential attribute metadatas by credential definition.""" + + # properties undefined + + +@docs( + tags=["*EXPERIMENTAL* Aries#0036 v1.0 credentials"], + summary="Get attribute metadata from wallet" +) +@response_schema(V10AttributeMetadataResultSchema(), 200) +async def attribute_metadata_get(request: web.BaseRequest): + """ + Request handler for getting credential attribute metadata. + + Args: + request: aiohttp request object + + Returns: + The metadata response + + """ + context = request.app["request_context"] + + credential_id = request.match_info["credential_id"] + + holder: BaseHolder = await context.inject(BaseHolder) + metadata = await holder.get_metadata(credential_id) + + return web.json_response(metadata) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + summary="Fetch all credential exchange records" +) +@response_schema(V10CredentialExchangeListSchema(), 200) +async def credential_exchange_list(request: web.BaseRequest): + """ + Request handler for searching connection records. + + Args: + request: aiohttp request object + + Returns: + The connection list response + + """ + context = request.app["request_context"] + tag_filter = {} + for param_name in ( + "connection_id", + "initiator", + "state", + "credential_definition_id", + "schema_id", + ): + if param_name in request.query and request.query[param_name] != "": + tag_filter[param_name] = request.query[param_name] + records = await V10CredentialExchange.query(context, tag_filter) + return web.json_response({"results": [record.serialize() for record in records]}) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + summary="Fetch a single credential exchange record" +) +@response_schema(V10CredentialExchangeSchema(), 200) +async def credential_exchange_retrieve(request: web.BaseRequest): + """ + Request handler for fetching a single connection record. + + Args: + request: aiohttp request object + + Returns: + The credential exchange record response + + """ + context = request.app["request_context"] + credential_exchange_id = request.match_info["cred_ex_id"] + try: + record = await V10CredentialExchange.retrieve_by_id( + context, + credential_exchange_id + ) + except StorageNotFoundError: + return web.HTTPNotFound() + return web.json_response(record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + summary="Send issuer a credential proposal" +) +@request_schema(V10CredentialProposalRequestSchema()) +@response_schema(V10CredentialProposalResultSchema(), 200) +async def credential_exchange_send_proposal(request: web.BaseRequest): + """ + Request handler for sending a credential proposal. + + Args: + request: aiohttp request object + + Returns: + The credential proposal details + + """ + + context = request.app["request_context"] + outbound_handler = request.app["outbound_message_router"] + + body = await request.json() + + connection_id = body.get("connection_id") + credential_definition_id = body.get("credential_definition_id") + comment = body.get("comment") + credential_preview = CredentialPreview( + attributes=[ + CredentialAttrPreview( + name=attr_preview['name'], + mime_type=attr_preview.get('mime-type', None), + encoding=attr_preview.get('encoding', None), + value=attr_preview['value'] + ) for attr_preview in body.get("credential_proposal")['attributes'] + ] + ) + + if not credential_preview: + raise web.HTTPBadRequest( + reason="credential_proposal must be provided." + ) + + credential_manager = CredentialManager(context) + + try: + connection_record = await ConnectionRecord.retrieve_by_id( + context, + connection_id + ) + except StorageNotFoundError: + raise web.HTTPBadRequest() + + if not connection_record.is_ready: + return web.HTTPForbidden() + + credential_exchange_record = await credential_manager.create_proposal( + connection_id, + comment=comment, + credential_preview=credential_preview, + credential_definition_id=credential_definition_id + ) + + await outbound_handler( + CredentialProposal.deserialize( + credential_exchange_record.credential_proposal_dict + ), + connection_id=connection_id + ) + + return web.json_response(credential_exchange_record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + summary="Send holder a credential offer, free from reference to any proposal" +) +@request_schema(V10CredentialOfferRequestSchema()) +@response_schema(V10CredentialOfferResultSchema(), 200) +async def credential_exchange_send_free_offer(request: web.BaseRequest): + """ + Request handler for sending a free credential offer. + + An issuer initiates a such a credential offer, which is free any + holder-initiated corresponding proposal. + + Args: + request: aiohttp request object + + Returns: + The credential offer details + + """ + + context = request.app["request_context"] + outbound_handler = request.app["outbound_message_router"] + + body = await request.json() + + connection_id = body.get("connection_id") + credential_definition_id = body.get("credential_definition_id") + auto_issue = body.get( + "auto_issue", + context.settings.get("debug.auto_respond_credential_request") + ) + comment = body.get("comment", None) + credential_preview = CredentialPreview( + attributes=[ + CredentialAttrPreview( + name=attr_preview['name'], + value=attr_preview['value'], + encoding=attr_preview.get('encoding', None), + mime_type=attr_preview.get('mime_type', None) + ) for attr_preview in body.get("credential_preview")["attributes"] + ] + ) + + if auto_issue and not credential_preview: + raise web.HTTPBadRequest( + reason="If auto_issue is set to" + + " true then credential_preview must also be provided." + ) + credential_proposal = CredentialProposal( + comment=comment, + credential_proposal=credential_preview, + cred_def_id=credential_definition_id + ) + + credential_manager = CredentialManager(context) + + try: + connection_record = await ConnectionRecord.retrieve_by_id( + context, + connection_id + ) + except StorageNotFoundError: + raise web.HTTPBadRequest() + + if not connection_record.is_ready: + return web.HTTPForbidden() + + credential_exchange_record = V10CredentialExchange( + connection_id=connection_id, + initiator=V10CredentialExchange.INITIATOR_SELF, + credential_definition_id=credential_definition_id, + credential_proposal_dict=credential_proposal.serialize(), + auto_issue=auto_issue) + + ( + credential_exchange_record, + credential_offer_message, + ) = await credential_manager.create_offer( + credential_exchange_record, + comment=comment + ) + + await outbound_handler(credential_offer_message, connection_id=connection_id) + + return web.json_response(credential_exchange_record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + summary="Send holder a credential offer in reference to a proposal" +) +@response_schema(V10CredentialOfferResultSchema(), 200) +async def credential_exchange_send_bound_offer(request: web.BaseRequest): + """ + Request handler for sending a credential offer to proposal. + + Args: + request: aiohttp request object + + Returns: + The credential exchange details with credential offer + + """ + + context = request.app["request_context"] + outbound_handler = request.app["outbound_message_router"] + + credential_exchange_id = request.match_info["cred_ex_id"] + credential_exchange_record = await V10CredentialExchange.retrieve_by_id( + context, + credential_exchange_id + ) + assert credential_exchange_record.state == ( + V10CredentialExchange.STATE_PROPOSAL_RECEIVED + ) + + connection_id = credential_exchange_record.connection_id + try: + connection_record = await ConnectionRecord.retrieve_by_id( + context, + connection_id + ) + except StorageNotFoundError: + raise web.HTTPBadRequest() + + if not connection_record.is_ready: + return web.HTTPForbidden() + + credential_manager = CredentialManager(context) + + ( + credential_exchange_record, + credential_offer_message, + ) = await credential_manager.create_offer( + credential_exchange_record, + comment=None + ) + + await outbound_handler(credential_offer_message, connection_id=connection_id) + + return web.json_response(credential_exchange_record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + summary="Send a credential request" +) +@response_schema(V10CredentialRequestResultSchema(), 200) +async def credential_exchange_send_request(request: web.BaseRequest): + """ + Request handler for sending a credential request. + + Args: + request: aiohttp request object + + Returns: + The credential request details + + """ + + context = request.app["request_context"] + outbound_handler = request.app["outbound_message_router"] + + credential_exchange_id = request.match_info["cred_ex_id"] + credential_exchange_record = await V10CredentialExchange.retrieve_by_id( + context, + credential_exchange_id + ) + connection_id = credential_exchange_record.connection_id + + assert credential_exchange_record.state == ( + V10CredentialExchange.STATE_OFFER_RECEIVED + ) + + credential_manager = CredentialManager(context) + + try: + connection_record = await ConnectionRecord.retrieve_by_id( + context, + connection_id + ) + except StorageNotFoundError: + raise web.HTTPBadRequest() + + if not connection_record.is_ready: + return web.HTTPForbidden() + + ( + credential_exchange_record, + credential_request_message, + ) = await credential_manager.create_request( + credential_exchange_record, + connection_record + ) + + await outbound_handler(credential_request_message, connection_id=connection_id) + return web.json_response(credential_exchange_record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + summary="Send a credential" +) +@request_schema(V10CredentialIssueRequestSchema()) +@response_schema(V10CredentialIssueResultSchema(), 200) +async def credential_exchange_issue(request: web.BaseRequest): + """ + Request handler for sending a credential. + + Args: + request: aiohttp request object + + Returns: + The credential details. + + """ + context = request.app["request_context"] + outbound_handler = request.app["outbound_message_router"] + + body = await request.json() + comment = body.get("comment") + credential_preview = CredentialPreview.deserialize(body["credential_preview"]) + + credential_exchange_id = request.match_info["cred_ex_id"] + cred_exch_record = await V10CredentialExchange.retrieve_by_id( + context, credential_exchange_id + ) + connection_id = cred_exch_record.connection_id + + assert cred_exch_record.state == V10CredentialExchange.STATE_REQUEST_RECEIVED + + credential_manager = CredentialManager(context) + + try: + connection_record = await ConnectionRecord.retrieve_by_id( + context, + connection_id + ) + except StorageNotFoundError: + raise web.HTTPBadRequest() + + if not connection_record.is_ready: + return web.HTTPForbidden() + + ( + cred_exch_record, + credential_issue_message, + ) = await credential_manager.issue_credential( + cred_exch_record, + comment=comment, + credential_values=credential_preview.attr_dict(decode=False) + ) + + await outbound_handler(credential_issue_message, connection_id=connection_id) + return web.json_response(cred_exch_record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + summary="Remove an existing credential exchange record", +) +async def credential_exchange_remove(request: web.BaseRequest): + """ + Request handler for removing a credential exchange record. + + Args: + request: aiohttp request object + """ + context = request.app["request_context"] + credential_exchange_id = request.match_info["cred_ex_id"] + try: + credential_exchange_id = request.match_info["cred_ex_id"] + credential_exchange_record = await V10CredentialExchange.retrieve_by_id( + context, + credential_exchange_id + ) + except StorageNotFoundError: + return web.HTTPNotFound() + await credential_exchange_record.delete_record(context) + return web.json_response({}) + + +async def register(app: web.Application): + """Register routes.""" + + app.add_routes( + [ + web.get( + "/v1.0/credential_metadata/{credential_id}", + attribute_metadata_get + ), + web.get("/v1.0/issue_credential_exchange", credential_exchange_list), + web.get( + "/v1.0/issue_credential_exchange/{cred_ex_id}", + credential_exchange_retrieve + ), + web.post( + "/v1.0/issue_credential_exchange/send_proposal", + credential_exchange_send_proposal + ), + web.post( + "/v1.0/issue_credential_exchange/send_offer", + credential_exchange_send_free_offer + ), + web.post( + "/v1.0/issue_credential_exchange/{cred_ex_id}/send_offer", + credential_exchange_send_bound_offer + ), + web.post( + "/v1.0/issue_credential_exchange/{cred_ex_id}/send_request", + credential_exchange_send_request + ), + web.post( + "/v1.0/issue_credential_exchange/{cred_ex_id}/issue", + credential_exchange_issue + ), + web.post( + "/v1.0/issue_credential_exchange/{cred_ex_id}/remove", + credential_exchange_remove + ) + ] + ) diff --git a/aries_cloudagent/messaging/present_proof/__init__.py b/aries_cloudagent/messaging/present_proof/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/present_proof/v1_0/__init__.py b/aries_cloudagent/messaging/present_proof/v1_0/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/present_proof/v1_0/handlers/__init__.py b/aries_cloudagent/messaging/present_proof/v1_0/handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_handler.py b/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_handler.py new file mode 100644 index 0000000000..ab8d93e4c9 --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_handler.py @@ -0,0 +1,43 @@ +"""Aries#0037 v1.0 presentation handler.""" + + +from ....base_handler import ( + BaseHandler, + BaseResponder, + HandlerException, + RequestContext +) + +from ..manager import PresentationManager +from ..messages.presentation import Presentation + + +class PresentationHandler(BaseHandler): + """Message handler class for presentations.""" + + async def handle(self, context: RequestContext, responder: BaseResponder): + """ + Message handler logic for presentations. + + Args: + context: request context + responder: responder callback + """ + self._logger.debug(f"PresentationHandler called with context {context}") + assert isinstance(context.message, Presentation) + self._logger.info( + f"Received presentation: {context.message.indy_proof(0)}" + ) + + if not context.connection_ready: + raise HandlerException("No connection established for presentation request") + + presentation_manager = PresentationManager(context) + + presentation_exchange_record = await presentation_manager.receive_presentation( + context.message.indy_proof(), + context.message._thread_id + ) + + if context.settings.get("debug.auto_verify_presentation"): + await presentation_manager.verify_presentation(presentation_exchange_record) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_proposal_handler.py b/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_proposal_handler.py new file mode 100644 index 0000000000..1e98ea5b24 --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_proposal_handler.py @@ -0,0 +1,56 @@ +"""Aries0037 v1.0 presentation proposal handler.""" + + +from ....base_handler import ( + BaseHandler, + BaseResponder, + HandlerException, + RequestContext +) + +from ..manager import PresentationManager +from ..messages.presentation_proposal import PresentationProposal + + +class PresentationProposalHandler(BaseHandler): + """Message handler class for presentation proposals.""" + + async def handle(self, context: RequestContext, responder: BaseResponder): + """ + Message handler logic for presentation proposals. + + Args: + context: proposal context + responder: responder callback + """ + self._logger.debug(f"PresentationProposalHandler called with context {context}") + + assert isinstance(context.message, PresentationProposal) + + self._logger.info( + "Received presentation proposal: %s", + context.message.serialize(as_string=True) + ) + + if not context.connection_ready: + raise HandlerException( + "No connection established for presentation proposal" + ) + + presentation_manager = PresentationManager(context) + presentation_exchange_record = await presentation_manager.receive_proposal( + connection_id=context.connection_record.connection_id, + presentation_proposal_message=context.message + ) + + # If auto_respond_presentation_proposal is set, reply with proof req + if context.settings.get("debug.auto_respond_presentation_proposal"): + ( + presentation_exchange_record, + presentation_request_message, + ) = await presentation_manager.create_request( + presentation_exchange_record=presentation_exchange_record, + comment=context.message.comment + ) + + await responder.send_reply(presentation_request_message) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_request_handler.py b/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_request_handler.py new file mode 100644 index 0000000000..9493a3d246 --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_request_handler.py @@ -0,0 +1,133 @@ +"""Aries#0037 v1.0 Presentation request handler.""" + + +from ....base_handler import ( + BaseHandler, + BaseResponder, + HandlerException, + RequestContext +) + +from .....holder.base import BaseHolder +from .....storage.error import StorageNotFoundError + +from ..manager import PresentationManager +from ..messages.inner.presentation_preview import PresentationPreview +from ..messages.presentation_request import PresentationRequest +from ..messages.presentation_proposal import PresentationProposal +from ..models.presentation_exchange import V10PresentationExchange + + +class PresentationRequestHandler(BaseHandler): + """Message handler class for Aries#0037 v1.0 presentation requests.""" + + async def handle(self, context: RequestContext, responder: BaseResponder): + """ + Message handler logic for Aries#0037 v1.0 presentation requests. + + Args: + context: request context + responder: responder callback + """ + self._logger.debug(f"PresentationRequestHandler called with context {context}") + + assert isinstance(context.message, PresentationRequest) + + self._logger.info( + "Received presentation request: %s", + context.message.serialize(as_string=True) + ) + + if not context.connection_ready: + raise HandlerException("No connection established for presentation request") + + presentation_manager = PresentationManager(context) + + indy_proof_request = context.message.indy_proof_request(0) + presentation_proposal_dict = PresentationProposal( + comment=context.message.comment, + presentation_proposal=PresentationPreview.from_indy_proof_request( + indy_proof_request + ) + ).serialize() + + # Get credential exchange record (holder sent proposal first) + # or create it (verifier sent request first) + try: + presentation_exchange_record = ( + await V10PresentationExchange.retrieve_by_tag_filter( + context, + { + "thread_id": context.message._thread_id + } + ) + ) + presentation_exchange_record.presentation_proposal_dict = ( + presentation_proposal_dict + ) + except StorageNotFoundError: # verifier sent this request free of any proposal + presentation_exchange_record = V10PresentationExchange( + connection_id=context.connection_record.connection_id, + thread_id=context.message._thread_id, + initiator=V10PresentationExchange.INITIATOR_EXTERNAL, + presentation_proposal_dict=presentation_proposal_dict, + presentation_request=indy_proof_request, + auto_present=context.settings.get( + "debug.auto_respond_presentation_request" + ) + ) + + presentation_exchange_record.presentation_request = indy_proof_request + + presentation_exchange_record = await presentation_manager.receive_request( + presentation_exchange_record + ) + + # If auto_present is enabled, respond immediately with presentation + if presentation_exchange_record.auto_present: + hold: BaseHolder = await context.inject(BaseHolder) + req_creds = { + "self_attested_attributes": {}, + "requested_attributes": {}, + "requested_predicates": {} + } + + for category in ("requested_attributes", "requested_predicates"): + for referent in indy_proof_request[category]: + credentials = ( + await hold.get_credentials_for_presentation_request_by_referent( + indy_proof_request, + (referent,), + 0, + 2, + {} + ) + ) + if len(credentials) != 1: + self._logger.warning( + f"Could not automatically construct presentation for " + + f"presentation request {indy_proof_request['name']}" + + f":{indy_proof_request['version']} because referent " + + f"{referent} did not produce exactly one credential " + + f"result. The wallet returned {len(credentials)} " + + f"matching credentials." + ) + return + + req_creds[category][referent] = { + "cred_id": credentials[0]["cred_info"]["referent"], + "revealed": True + } + + ( + presentation_exchange_record, + presentation_message + ) = await presentation_manager.create_presentation( + presentation_exchange_record=presentation_exchange_record, + requested_credentials=req_creds, + comment="auto-presented for proof request nonce={}".format( + indy_proof_request["nonce"] + ) + ) + + await responder.send_reply(presentation_message) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/manager.py b/aries_cloudagent/messaging/present_proof/v1_0/manager.py new file mode 100644 index 0000000000..5e9c961501 --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/manager.py @@ -0,0 +1,365 @@ +"""Classes to manage presentations.""" + +import json +import logging + +from ....config.injection_context import InjectionContext +from ....error import BaseError +from ....holder.base import BaseHolder +from ....ledger.base import BaseLedger +from ....messaging.decorators.attach_decorator import AttachDecorator +from ....verifier.base import BaseVerifier + +from .models.presentation_exchange import V10PresentationExchange +from .messages.presentation_proposal import PresentationProposal +from .messages.presentation_request import PresentationRequest +from .messages.presentation import Presentation + + +class PresentationManagerError(BaseError): + """Presentation error.""" + + +class PresentationManager: + """Class for managing presentations.""" + + def __init__(self, context: InjectionContext): + """ + Initialize a PresentationManager. + + Args: + context: The context for this credential + """ + + self._context = context + self._logger = logging.getLogger(__name__) + + @property + def context(self) -> InjectionContext: + """ + Accessor for the current request context. + + Returns: + The injection context for this presentation manager + + """ + return self._context + + async def create_exchange_for_proposal( + self, + connection_id: str, + presentation_proposal_message: PresentationProposal, + auto_present: bool = None + ): + """ + Create a presentation exchange record for input presentation proposal. + + Args: + connection_id: connection identifier + presentation_proposal_message: presentation proposal to serialize + to exchange record + auto_present: whether to present proof upon receiving proof request + (default to configuration setting) + + Returns: + Presentation exchange record + + """ + presentation_exchange_record = V10PresentationExchange( + connection_id=connection_id, + thread_id=presentation_proposal_message._thread_id, + initiator=V10PresentationExchange.INITIATOR_SELF, + state=V10PresentationExchange.STATE_PROPOSAL_SENT, + presentation_proposal_dict=presentation_proposal_message.serialize(), + auto_present=auto_present + ) + await presentation_exchange_record.save( + self.context, + reason="Aries#0037v1.0 create presentation proposal" + ) + + return presentation_exchange_record + + async def receive_proposal( + self, + connection_id: str, + presentation_proposal_message: PresentationProposal + ): + """ + Receive a presentation proposal. + + Args: + presentation_proposal_message: Presentation proposal message to receive + + Returns: + Presentation exchange record + + """ + presentation_exchange_record = V10PresentationExchange( + connection_id=connection_id, + thread_id=presentation_proposal_message._thread_id, + initiator=V10PresentationExchange.INITIATOR_EXTERNAL, + state=V10PresentationExchange.STATE_PROPOSAL_RECEIVED, + presentation_proposal_dict=presentation_proposal_message.serialize() + ) + await presentation_exchange_record.save( + self.context, + reason="Aries#0037v1.0 receive presentation request" + ) + + return presentation_exchange_record + + async def create_request( + self, + # TODO recall that in routes, to create free request, first populate proposal + presentation_exchange_record: V10PresentationExchange, + name: str = None, + version: str = None, + nonce: str = None, + comment: str = None + ): + """ + Create a presentation request. + + Args: + presentation_exchange_record: Presentation exchange record for which + to create presentation request + comment: Optional human-readable comment pertaining to offer creation + + Return: + A tuple (presentation_exchange_record, presentation_request_message) + + """ + indy_proof_request = ( + PresentationProposal.deserialize( + presentation_exchange_record.presentation_proposal_dict + ).presentation_proposal.indy_proof_request( + name=name, + version=version, + nonce=nonce + ) + ) + + presentation_request_message = PresentationRequest( + comment=comment, + request_presentations_attach=[ + AttachDecorator.from_indy_dict(indy_proof_request) + ] + ) + presentation_request_message._thread = { + "thid": presentation_exchange_record.thread_id + } + + presentation_exchange_record.thread_id = presentation_request_message._thread_id + presentation_exchange_record.state = V10PresentationExchange.STATE_REQUEST_SENT + presentation_exchange_record.presentation_request = indy_proof_request + await presentation_exchange_record.save( + self.context, + reason="Aries#0037v1.0 create presentation request" + ) + + return presentation_exchange_record, presentation_request_message + + async def receive_request( + self, + presentation_exchange_record: V10PresentationExchange + ): + """ + Receive a presentation request. + + Args: + presentation_exchange_record: presentation exchange record with + request to receive + + Returns: + The presentation_exchange_record + + """ + presentation_exchange_record.state = ( + V10PresentationExchange.STATE_REQUEST_RECEIVED + ) + await presentation_exchange_record.save( + self.context, + reason="Aries#0037v1.0 receive presentation request" + ) + + return presentation_exchange_record + + async def create_presentation( + self, + presentation_exchange_record: V10PresentationExchange, + requested_credentials: dict, + comment: str = None + ): + """ + Create a presentation. + + Args: + presentation_exchange_record: Record to update + requested_credentials: Indy formatted requested_credentials + + e.g., + + { + "self_attested_attributes": { + "j233ffbc-bd35-49b1-934f-51e083106f6d": "value" + }, + "requested_attributes": { + "6253ffbb-bd35-49b3-934f-46e083106f6c": { + "cred_id": "5bfa40b7-062b-4ae0-a251-a86c87922c0e", + "revealed": true + } + }, + "requested_predicates": { + "bfc8a97d-60d3-4f21-b998-85eeabe5c8c0": { + "cred_id": "5bfa40b7-062b-4ae0-a251-a86c87922c0e" + } + } + } + comment: optional human-readable comment + + """ + # Get all credential ids for this presentation + credential_ids = [] + + requested_attributes = requested_credentials["requested_attributes"] + for presentation_referent in requested_attributes: + credential_id = requested_attributes[presentation_referent]["cred_id"] + credential_ids.append(credential_id) + + requested_predicates = requested_credentials["requested_predicates"] + for presentation_referent in requested_predicates: + credential_id = requested_predicates[presentation_referent]["cred_id"] + credential_ids.append(credential_id) + + # Get all schema and credential definition ids in use + # TODO: Cache this!!! + schema_ids = [] + credential_definition_ids = [] + holder: BaseHolder = await self.context.inject(BaseHolder) + for credential_id in credential_ids: + credential = await holder.get_credential(credential_id) + schema_id = credential["schema_id"] + credential_definition_id = credential["cred_def_id"] + schema_ids.append(schema_id) + credential_definition_ids.append(credential_definition_id) + + schemas = {} + credential_definitions = {} + + ledger: BaseLedger = await self.context.inject(BaseLedger) + async with ledger: + + # Build schemas for anoncreds + for schema_id in schema_ids: + schema = await ledger.get_schema(schema_id) + schemas[schema_id] = schema + + # Build credential_definitions for anoncreds + for credential_definition_id in credential_definition_ids: + (credential_definition) = await ledger.get_credential_definition( + credential_definition_id + ) + credential_definitions[credential_definition_id] = credential_definition + + holder: BaseHolder = await self.context.inject(BaseHolder) + indy_proof = await holder.create_presentation( + presentation_exchange_record.presentation_request, + requested_credentials, + schemas, + credential_definitions, + ) + + presentation_message = Presentation( + comment=comment, + presentations_attach=[AttachDecorator.from_indy_dict(indy_proof)] + ) + + presentation_message._thread = { + "thid": presentation_exchange_record.thread_id + } + + # save presentation exchange state + presentation_exchange_record.state = ( + V10PresentationExchange.STATE_PRESENTATION_SENT + ) + presentation_exchange_record.presentation = indy_proof + await presentation_exchange_record.save( + self.context, + reason="Aries#0037v1.0 create presentation" + ) + + return presentation_exchange_record, presentation_message + + async def receive_presentation(self, presentation: dict, thread_id: str): + """Receive a presentation.""" + ( + presentation_exchange_record + ) = await V10PresentationExchange.retrieve_by_tag_filter( + self.context, tag_filter={"thread_id": thread_id} + ) + + presentation_exchange_record.presentation = presentation + presentation_exchange_record.state = ( + V10PresentationExchange.STATE_PRESENTATION_RECEIVED + ) + + await presentation_exchange_record.save( + self.context, + reason="Aries#0037v1.0 receive presentation" + ) + + return presentation_exchange_record + + async def verify_presentation( + self, + presentation_exchange_record: V10PresentationExchange + ): + """Verify a presentation.""" + + indy_proof_request = presentation_exchange_record.presentation_request + indy_proof = presentation_exchange_record.presentation + + schema_ids = [] + credential_definition_ids = [] + + identifiers = indy_proof["identifiers"] + for identifier in identifiers: + schema_ids.append(identifier["schema_id"]) + credential_definition_ids.append(identifier["cred_def_id"]) + + schemas = {} + credential_definitions = {} + + ledger: BaseLedger = await self.context.inject(BaseLedger) + async with ledger: + + # Build schemas for anoncreds + for schema_id in schema_ids: + schema = await ledger.get_schema(schema_id) + schemas[schema_id] = schema + + # Build credential_definitions for anoncreds + for credential_definition_id in credential_definition_ids: + (credential_definition) = await ledger.get_credential_definition( + credential_definition_id + ) + credential_definitions[credential_definition_id] = credential_definition + + verifier: BaseVerifier = await self.context.inject(BaseVerifier) + presentation_exchange_record.verified = json.dumps( # tag: needs string value + await verifier.verify_presentation( + indy_proof_request, + indy_proof, + schemas, + credential_definitions + ) + ) + presentation_exchange_record.state = V10PresentationExchange.STATE_VERIFIED + + await presentation_exchange_record.save( + self.context, + reason="Aries#0037v1.0 verify presentation" + ) + + return presentation_exchange_record diff --git a/aries_cloudagent/messaging/present_proof/v1_0/message_types.py b/aries_cloudagent/messaging/present_proof/v1_0/message_types.py new file mode 100644 index 0000000000..e3a16ef328 --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/message_types.py @@ -0,0 +1,19 @@ +"""Message and inner object type identifiers for Connections.""" + +MESSAGE_FAMILY = "did:sov:BzCbsNYhMrjHiqZDTUASHg;spec/present-proof/1.0/" + +# Message types + +PRESENTATION_PROPOSAL = f"{MESSAGE_FAMILY}propose-presentation" +PRESENTATION_REQUEST = f"{MESSAGE_FAMILY}request-presentation" +PRESENTATION = f"{MESSAGE_FAMILY}presentation" + +TOP = "aries_cloudagent.messaging.present_proof.v1_0" +MESSAGE_TYPES = { + PRESENTATION_PROPOSAL: f"{TOP}.messages.presentation_proposal.PresentationProposal", + PRESENTATION_REQUEST: f"{TOP}.messages.presentation_request.PresentationRequest", + PRESENTATION: f"{TOP}.messages.presentation.Presentation" +} + +# Inner object types +PRESENTATION_PREVIEW = f"{MESSAGE_FAMILY}presentation-preview" diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/__init__.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/__init__.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/presentation_preview.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/presentation_preview.py new file mode 100644 index 0000000000..79e8f25d5b --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/presentation_preview.py @@ -0,0 +1,441 @@ +"""A presentation preview inner object.""" + + +from datetime import datetime, timezone +from uuid import uuid4 +from typing import Mapping + +import base64 + +from marshmallow import fields, validate + +from ......messaging.util import str_to_epoch +from .....models.base import BaseModel, BaseModelSchema +from .....valid import INDY_CRED_DEF_ID, INDY_PREDICATE, INDY_ISO8601_DATETIME +from ...message_types import PRESENTATION_PREVIEW +from ..util.indy import canon, Predicate + + +class PresentationAttrPreview(BaseModel): + """Class representing an `"attributes"` attibute within the preview.""" + + DEFAULT_META = {"mime-type": "text/plain"} + + class Meta: + """Attribute preview metadata.""" + + schema_class = "PresentationAttrPreviewSchema" + + def __init__( + self, + *, + value: str = None, + encoding: str = None, + mime_type: str = None, + **kwargs): + """ + Initialize attribute preview object. + + Args: + mime_type: MIME type + encoding: encoding (omit or "base64") + value: attribute value + + """ + super().__init__(**kwargs) + self.value = value + self.encoding = encoding.lower() if encoding else None + self.mime_type = ( + mime_type.lower() + if mime_type and mime_type != PresentationAttrPreview.DEFAULT_META.get( + "mime-type" + ) + else None + ) + + @staticmethod + def list_plain(plain: dict): + """ + Return a list of `PresentationAttrPreview` for plain text from names/values. + + Args: + plain: dict mapping names to values + + Returns: + PresentationAttrPreview on name/values pairs with default MIME type + + """ + return [PresentationAttrPreview(name=k, value=plain[k]) for k in plain] + + def void(self): + """Remove value, encoding, MIME type for use in proof request.""" + + self.value = None + self.encoding = None + self.mime_type = None + + def b64_decoded_value(self) -> str: + """Value, base64-decoded if applicable.""" + + return base64.b64decode(self.value.encode()).decode( + ) if ( + self.value and + self.encoding and + self.encoding.lower() == "base64" + ) else self.value + + def __eq__(self, other): + """Equality comparator.""" + + if all( + getattr(self, attr, PresentationAttrPreview.DEFAULT_META.get(attr)) == + getattr(other, attr, PresentationAttrPreview.DEFAULT_META.get(attr)) + for attr in vars(self) + ): + return True # all attrs exactly match + + if ( + self.mime_type or "text/plain" + ).lower() != (other.mime_type or "text/plain").lower(): + return False # distinct MIME types + + return self.b64_decoded_value() == other.b64_decoded_value() + + +class PresentationAttrPreviewSchema(BaseModelSchema): + """Attribute preview schema.""" + + class Meta: + """Attribute preview schema metadata.""" + + model_class = PresentationAttrPreview + + value = fields.Str( + description="Attribute value", + required=False + ) + mime_type = fields.Str( + description="MIME type (default text/plain)", + required=False, + data_key="mime-type", + example="text/plain" + ) + encoding = fields.Str( + description="Encoding (specify base64 or omit for none)", + required=False, + example="base64", + validate=validate.Equal("base64", error="Must be absent or equal to {other}") + ) + + +class PresentationPreview(BaseModel): + """Class representing presentation preview.""" + + class Meta: + """Presentation preview metadata.""" + + schema_class = "PresentationPreviewSchema" + message_type = PRESENTATION_PREVIEW + + def __init__( + self, + *, + _type: str = None, + attributes: Mapping[str, Mapping[str, PresentationAttrPreview]], + predicates: Mapping[str, Mapping[str, Mapping[str, int]]], + non_revocation_times: Mapping[str, datetime], + **kwargs + ): + """ + Initialize presentation preview object. + + Args: + _type: formalism for Marshmallow model creation: ignored + attributes: nested dict mapping cred def identifiers to attribute names + to attribute previews + predicates: nested dict mapping cred def identifiers to predicates + to predicate previews + non_revocation_times: dict mapping cred def identifiers to non-revocation + timestamps + """ + super().__init__(**kwargs) + self.attributes = attributes + self.predicates = predicates + self.non_revocation_times = non_revocation_times + + @staticmethod + def from_indy_proof_request(indy_proof_request: dict): + """Reverse-engineer presentation preview from indy proof request.""" + + def do_non_revo(cd_id: str, proof_req_non_revo: dict): + """Set non-revocation times per cred def id given from/to specifiers.""" + + nonlocal non_revocation_times + if proof_req_non_revo: + if cd_id not in non_revocation_times: + non_revocation_times[cd_id] = { + "from": datetime.fromtimestamp( + proof_req_non_revo["from"], + tz=timezone.utc + ), + "to": datetime.fromtimestamp( + proof_req_non_revo["to"], + tz=timezone.utc + ) + } + else: + non_revocation_times[cd_id] = { + "from": max( + datetime.fromtimestamp( + proof_req_non_revo["from"], + tz=timezone.utc + ), + non_revocation_times[cd_id]["from"] + ), + "to": min( + datetime.fromtimestamp( + proof_req_non_revo["to"], + tz=timezone.utc + ), + non_revocation_times[cd_id]["to"] + ) + } + + attributes = {} + predicates = {} + non_revocation_times = {} + + for (uuid, attr_spec) in indy_proof_request["requested_attributes"].items(): + cd_id = attr_spec["restrictions"][0]["cred_def_id"] + if cd_id not in attributes: + attributes[cd_id] = {} + attributes[cd_id][attr_spec["name"]] = PresentationAttrPreview() + do_non_revo(cd_id, attr_spec.get("non_revoked")) + + for (uuid, pred_spec) in indy_proof_request["requested_predicates"].items(): + cd_id = pred_spec["restrictions"][0]["cred_def_id"] + if cd_id not in predicates: + predicates[cd_id] = {} + pred_type = pred_spec["p_type"] + if pred_type not in predicates[cd_id]: + predicates[cd_id][pred_type] = {} + predicates[cd_id][pred_type][pred_spec["name"]] = ( + pred_spec["p_value"] + ) + do_non_revo(cd_id, pred_spec.get("non_revoked")) + + return PresentationPreview( + attributes=attributes, + predicates=predicates, + non_revocation_times={ + cd_id: ( + non_revocation_times[cd_id]["to"].isoformat(" ", "seconds") + ) for cd_id in non_revocation_times + } + ) + + def void_attribute_previews(self): + """Clear attribute values, encodings, MIME types from presentation preview.""" + for cd_id in self.attributes: + for attr in self.attributes[cd_id]: + self.attributes[cd_id][attr].void() + + return self + + @property + def _type(self): + """Accessor for message type.""" + + return PresentationPreview.Meta.message_type + + def attr_dict(self, decode: bool = False): + """ + Return dict mapping cred def id to name:value pair per attribute. + + Args: + decode: whether first to decode attributes marked as having encoding + + """ + + def b64(attr_prev: PresentationAttrPreview, b64deco: bool = False) -> str: + """Base64 decode attribute value if applicable.""" + return ( + base64.b64decode(attr_prev.value.encode()).decode() + if ( + attr_prev.value and + attr_prev.encoding and + attr_prev.encoding == "base64" and + b64deco + ) else attr_prev.value + ) + + return { + cd_id: { + attr: b64(self.attributes[cd_id][attr], decode) + for attr in self.attributes[cd_id] + } for cd_id in self.attributes + } + + def attr_metadata(self): + """Return nested dict mapping cred def id to attr to MIME type and encoding.""" + + return { + cd_id: { + attr: { + **{ + "mime-type": aprev.mime_type + for aprev in [self.attributes[cd_id][attr]] if aprev.mime_type + }, + **{ + "encoding": aprev.encoding + for aprev in [self.attributes[cd_id][attr]] if aprev.encoding + } + } for attr in self.attributes[cd_id] + } for cd_id in self.attributes + } + + def indy_proof_request( + self, + name: str = None, + version: str = None, + nonce: str = None + ) -> dict: + """ + Return indy proof request corresponding to presentation preview. + + Args: + name: for proof request + version: version for proof request + nonce: nonce for proof request + + Returns: + Indy proof request dict. + + """ + proof_req = { + "name": name or "proof-request", + "version": version or "1.0", + "nonce": nonce or str(uuid4().int), + "requested_attributes": {}, + "requested_predicates": {} + } + cd_ids = [] # map ordinal to cred def id for use in proof req referents + + for (cd_id, attr_dict) in self.attr_dict().items(): + cd_ids.append(cd_id) + cd_id_index = len(cd_ids) - 1 + for (attr, attr_value) in attr_dict.items(): + proof_req["requested_attributes"][ + f"{cd_id_index}_{canon(attr)}_uuid" + ] = { + "name": attr, + "restrictions": [ + {"cred_def_id": cd_id} + ], + **{ + "non_revoked": { + "from": str_to_epoch(self.non_revocation_times[cd_id]), + "to": str_to_epoch(self.non_revocation_times[cd_id]) + } for _ in [""] if cd_id in self.non_revocation_times + } + } + + # predicates: Mapping[str, Mapping[str, Mapping[str, str]]], + for (cd_id, pred_dict) in self.predicates.items(): + if cd_id not in cd_ids: + cd_ids.append(cd_id) + cd_id_index = cd_ids.index(cd_id) + for (pred_math, pred_attr_dict) in pred_dict.items(): + for (attr, threshold) in pred_attr_dict.items(): + proof_req["requested_predicates"][ + "{}_{}_{}_uuid".format( + cd_id_index, + canon(attr), + Predicate.get(pred_math).value.fortran + ) + ] = { + "name": attr, + "p_type": pred_math, + "p_value": threshold, + "restrictions": [ + {"cred_def_id": cd_id} + ], + **{ + "non_revoked": { + "from": str_to_epoch(self.non_revocation_times[cd_id]), + "to": str_to_epoch(self.non_revocation_times[cd_id]) + } for _ in [""] if cd_id in self.non_revocation_times + } + } + + return proof_req + + def __eq__(self, other): + """Equality comparator.""" + + for part in vars(self): + if getattr(self, part, None) != getattr(other, part, None): + return False + return True + + +class PresentationPreviewSchema(BaseModelSchema): + """Presentation preview schema.""" + + class Meta: + """Presentation preview schema metadata.""" + + model_class = PresentationPreview + + _type = fields.Str( + description="Message type identifier", + required=False, + example=PRESENTATION_PREVIEW, + data_key="@type", + validate=validate.Equal( + PRESENTATION_PREVIEW, + error="Must be absent or equal to {other}" + ) + ) + attributes = fields.Dict( + description=( + "Nested object mapping cred def identifiers to attribute preview specifiers" + ), + required=True, + keys=fields.Str(**INDY_CRED_DEF_ID), # marshmallow/apispec v3.0rc3 ignores + values=fields.Dict( + description="Object mapping attribute names to attribute previews", + keys=fields.Str(example="attr_name"), # marshmallow/apispec v3.0rc3 ignores + values=fields.Nested(PresentationAttrPreviewSchema) + ) + ) + predicates = fields.Dict( + description=( + "Nested object mapping cred def identifiers to predicate preview specifiers" + ), + required=True, + keys=fields.Str(**INDY_CRED_DEF_ID), + values=fields.Dict( + description=( + "Nested Object mapping predicates " + '(currently, only ">=" for 32-bit integers) ' + "to attribute names to threshold values" + ), + keys=fields.Str(**INDY_PREDICATE), # marshmallow/apispec v3.0rc3 ignores + values=fields.Dict( + description="Object mapping attribute names to threshold values", + keys=fields.Str(example="attr_name"), + values=fields.Int() + ) + ) + ) + non_revocation_times = fields.Dict( + description=( + "Object mapping cred def identifiers to ISO-8601 datetimes, each marking a " + "non-revocation timestamp for its corresponding credential in the proof" + ), + required=False, + default={}, + keys=fields.Str(**INDY_CRED_DEF_ID), # marshmallow/apispec v3.0rc3 ignores + values=fields.Str(**INDY_ISO8601_DATETIME) + ) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/__init__.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py new file mode 100644 index 0000000000..501231198f --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py @@ -0,0 +1,337 @@ +from copy import deepcopy +from datetime import datetime, timezone +from unittest import TestCase + +import json + +from .......messaging.util import str_to_datetime, str_to_epoch +from ....message_types import PRESENTATION_PREVIEW +from ..presentation_preview import ( + PresentationAttrPreview, + PresentationPreview, + PresentationPreviewSchema +) + +NOW_8601 = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat(" ", "seconds") +NOW_EPOCH = str_to_epoch(NOW_8601) +CD_ID = "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" +PRES_PREVIEW = PresentationPreview( + attributes={ + CD_ID: { + "player": PresentationAttrPreview(value="Richie Knucklez"), + "screenCapture": PresentationAttrPreview( + mime_type="image/png", + encoding="base64", + value="aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" + ) + } + }, + predicates={ + CD_ID: { + ">=": { + "highScore": 1000000 + } + } + }, + non_revocation_times={ + CD_ID: NOW_8601 + } +) +INDY_PROOF_REQ = json.loads(f"""{{ + "name": "proof-req", + "version": "1.0", + "nonce": "12345", + "requested_attributes": {{ + "0_player_uuid": {{ + "name": "player", + "restrictions": [ + {{ + "cred_def_id": "{CD_ID}" + }} + ], + "non_revoked": {{ + "from": {NOW_EPOCH}, + "to": {NOW_EPOCH} + }} + }}, + "0_screencapture_uuid": {{ + "name": "screenCapture", + "restrictions": [ + {{ + "cred_def_id": "{CD_ID}" + }} + ], + "non_revoked": {{ + "from": {NOW_EPOCH}, + "to": {NOW_EPOCH} + }} + }} + }}, + "requested_predicates": {{ + "0_highscore_GE_uuid": {{ + "name": "highScore", + "p_type": ">=", + "p_value": 1000000, + "restrictions": [ + {{ + "cred_def_id": "{CD_ID}" + }} + ], + "non_revoked": {{ + "from": {NOW_EPOCH}, + "to": {NOW_EPOCH} + }} + }} + }} +}}""") + + +class TestPresentationAttrPreview(TestCase): + """Attribute preview tests""" + + def test_eq(self): + attr_previews_none_plain = [ + PresentationAttrPreview(value="value"), + PresentationAttrPreview(value="value", encoding=None, mime_type=None), + PresentationAttrPreview( + value="value", + encoding=None, + mime_type="text/plain" + ), + PresentationAttrPreview( + value="value", + encoding=None, + mime_type="TEXT/PLAIN" + ) + ] + attr_previews_b64_plain = [ + PresentationAttrPreview(value="dmFsdWU=", encoding="base64"), + PresentationAttrPreview( + value="dmFsdWU=", + encoding="base64", + mime_type=None + ), + PresentationAttrPreview( + value="dmFsdWU=", + encoding="base64", + mime_type="text/plain" + ), + PresentationAttrPreview( + value="dmFsdWU=", + encoding="BASE64", + mime_type="text/plain" + ), + PresentationAttrPreview( + value="dmFsdWU=", + encoding="base64", + mime_type="TEXT/PLAIN" + ) + ] + attr_previews_different = [ + PresentationAttrPreview( + value="dmFsdWU=", + encoding="base64", + mime_type="image/png" + ), + PresentationAttrPreview( + value="distinct value", + mime_type=None + ), + PresentationAttrPreview() + ] + + for lhs in attr_previews_none_plain: + for rhs in attr_previews_b64_plain: + assert lhs == rhs # values decode to same + + for lhs in attr_previews_none_plain: + for rhs in attr_previews_different: + assert lhs != rhs + + for lhs in attr_previews_b64_plain: + for rhs in attr_previews_different: + assert lhs != rhs + + for lidx in range(len(attr_previews_none_plain) - 1): + for ridx in range(lidx + 1, len(attr_previews_none_plain)): + assert attr_previews_none_plain[lidx] == attr_previews_none_plain[ridx] + + for lidx in range(len(attr_previews_b64_plain) - 1): + for ridx in range(lidx + 1, len(attr_previews_b64_plain)): + assert attr_previews_b64_plain[lidx] == attr_previews_b64_plain[ridx] + + for lidx in range(len(attr_previews_different) - 1): + for ridx in range(lidx + 1, len(attr_previews_different)): + assert attr_previews_different[lidx] != attr_previews_different[ridx] + + +class TestPresentationPreview(TestCase): + """Presentation preview tests""" + + def test_init(self): + """Test initializer.""" + assert PRES_PREVIEW.attributes + assert PRES_PREVIEW.predicates + assert PRES_PREVIEW.non_revocation_times + + def test_type(self): + """Test type.""" + assert PRES_PREVIEW._type == PRESENTATION_PREVIEW + + def test_indy_proof_request(self): + """Test to and from indy proof request.""" + + pres_preview = deepcopy(PRES_PREVIEW) + pres_preview.void_attribute_previews() + + assert ( + pres_preview == PresentationPreview.from_indy_proof_request(INDY_PROOF_REQ) + ) + assert pres_preview.indy_proof_request( + **{k: INDY_PROOF_REQ[k] for k in ("name", "version", "nonce")} + ) == INDY_PROOF_REQ + assert PRES_PREVIEW.indy_proof_request( + **{k: INDY_PROOF_REQ[k] for k in ("name", "version", "nonce")} + ) == INDY_PROOF_REQ + + def test_preview(self): + """Test preview for attr-values and attr-metadata utilities.""" + assert PRES_PREVIEW.attr_dict(decode=False) == { + CD_ID: { + "player": "Richie Knucklez", + "screenCapture": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" + } + } + assert PRES_PREVIEW.attr_dict(decode=True) == { + CD_ID: { + "player": "Richie Knucklez", + "screenCapture": "imagine a screen capture" + } + } + assert PRES_PREVIEW.attr_metadata() == { + CD_ID: { + "player": {}, + "screenCapture": { + "mime-type": "image/png", + "encoding": "base64" + } + } + } + assert PRES_PREVIEW.indy_proof_request("proof-req", "1.0", "12345") == { + "name": "proof-req", + "version": "1.0", + "nonce": "12345", + "requested_attributes": { + "0_player_uuid": { + "name": "player", + "restrictions": [ + { + "cred_def_id": CD_ID + } + ], + "non_revoked": { + "from": NOW_EPOCH, + "to": NOW_EPOCH + } + }, + "0_screencapture_uuid": { + "name": "screenCapture", + "restrictions": [ + { + "cred_def_id": CD_ID + } + ], + "non_revoked": { + "from": NOW_EPOCH, + "to": NOW_EPOCH + } + } + }, + "requested_predicates": { + "0_highscore_GE_uuid": { + "name": "highScore", + "p_type": ">=", + "p_value": 1000000, + "restrictions": [ + { + "cred_def_id": CD_ID + } + ], + "non_revoked": { + "from": NOW_EPOCH, + "to": NOW_EPOCH + } + } + } + } + + def test_deserialize(self): + """Test deserialization.""" + dump = { + "@type": PRESENTATION_PREVIEW, + "attributes": { + CD_ID: { + "player": { + "value": "Richie Knucklez" + }, + "screenCapture": { + "value": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl", + "encoding": "base64", + "mime-type": "image/png" + } + } + }, + "predicates": { + CD_ID: { + ">=": { + "highScore": 1000000 + } + } + }, + "non_revocation_times": { + CD_ID: NOW_8601 + } + } + + preview = PresentationPreview.deserialize(dump) + assert type(preview) == PresentationPreview + + def test_serialize(self): + """Test serialization.""" + + preview_dict = PRES_PREVIEW.serialize() + assert preview_dict == { + "@type": PRESENTATION_PREVIEW, + "attributes": { + CD_ID: { + "player": { + "value": "Richie Knucklez" + }, + "screenCapture": { + "value": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl", + "encoding": "base64", + "mime-type": "image/png" + } + } + }, + "predicates": { + CD_ID: { + ">=": { + "highScore": 1000000 + } + } + }, + "non_revocation_times": { + CD_ID: NOW_8601 + } + } + + +class TestPresentationPreviewSchema(TestCase): + """Test presentation preview schema""" + + def test_make_model(self): + """Test making model.""" + data = PRES_PREVIEW.serialize() + model_instance = PresentationPreview.deserialize(data) + assert isinstance(model_instance, PresentationPreview) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/presentation.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/presentation.py new file mode 100644 index 0000000000..16705f3519 --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/presentation.py @@ -0,0 +1,80 @@ +"""A (proof) presentation content message.""" + + +from typing import Sequence + +from marshmallow import fields + +from .....messaging.decorators.attach_decorator import ( + AttachDecorator, + AttachDecoratorSchema +) +from ....agent_message import AgentMessage, AgentMessageSchema +from ..message_types import PRESENTATION + + +HANDLER_CLASS = ( + "aries_cloudagent.messaging.present_proof.v1_0.handlers." + + "presentation_handler.PresentationHandler" +) + + +class Presentation(AgentMessage): + """Class representing a (proof) presentation.""" + + class Meta: + """Presentation metadata.""" + + handler_class = HANDLER_CLASS + schema_class = "PresentationSchema" + message_type = PRESENTATION + + def __init__( + self, + _id: str = None, + *, + comment: str = None, + presentations_attach: Sequence[AttachDecorator] = None, + **kwargs + ): + """ + Initialize presentation object. + + Args: + presentations_attach: attachments + comment: optional comment + + """ + super().__init__(_id=_id, **kwargs) + self.comment = comment + self.presentations_attach = ( + list(presentations_attach) if presentations_attach else [] + ) + + def indy_proof(self, index: int = 0): + """ + Retrieve and decode indy proof from attachment. + + Args: + index: ordinal in attachment list to decode and return + (typically, list has length 1) + + """ + return self.presentations_attach[index].indy_dict + + +class PresentationSchema(AgentMessageSchema): + """(Proof) presentation schema.""" + + class Meta: + """Presentation schema metadata.""" + + model_class = Presentation + + comment = fields.Str(description="Human-readable comment", required=False) + presentations_attach = fields.Nested( + AttachDecoratorSchema, + required=True, + many=True, + data_key="presentations~attach" + ) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/presentation_proposal.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/presentation_proposal.py new file mode 100644 index 0000000000..162a9cf566 --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/presentation_proposal.py @@ -0,0 +1,57 @@ +"""A presentation proposal content message.""" + +from marshmallow import fields + +from ....agent_message import AgentMessage, AgentMessageSchema +from ..message_types import PRESENTATION_PROPOSAL +from .inner.presentation_preview import PresentationPreview, PresentationPreviewSchema + + +HANDLER_CLASS = ( + "aries_cloudagent.messaging.present_proof.v1_0.handlers." + + "presentation_proposal_handler.PresentationProposalHandler" +) + + +class PresentationProposal(AgentMessage): + """Class representing a presentation proposal.""" + + class Meta: + """PresentationProposal metadata.""" + + handler_class = HANDLER_CLASS + schema_class = "PresentationProposalSchema" + message_type = PRESENTATION_PROPOSAL + + def __init__( + self, + _id: str = None, + *, + comment: str = None, + presentation_proposal: PresentationPreview = None, + **kwargs + ): + """ + Initialize presentation proposal object. + + Args: + comment: optional human-readable comment + presentation_proposal: proposed presentation preview + """ + super().__init__(_id, **kwargs) + self.comment = comment + self.presentation_proposal = ( + presentation_proposal if presentation_proposal else PresentationPreview() + ) + + +class PresentationProposalSchema(AgentMessageSchema): + """Presentation proposal schema.""" + + class Meta: + """Presentation proposal schema metadata.""" + + model_class = PresentationProposal + + comment = fields.Str(description="Human-readable comment", required=False) + presentation_proposal = fields.Nested(PresentationPreviewSchema, required=True) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/presentation_request.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/presentation_request.py new file mode 100644 index 0000000000..e96398e8ee --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/presentation_request.py @@ -0,0 +1,80 @@ +"""A presentation request content message.""" + + +from typing import Sequence + +from marshmallow import fields + +from .....messaging.decorators.attach_decorator import ( + AttachDecorator, + AttachDecoratorSchema +) +from ....agent_message import AgentMessage, AgentMessageSchema +from ..message_types import PRESENTATION_REQUEST + + +HANDLER_CLASS = ( + "aries_cloudagent.messaging.present_proof.v1_0.handlers." + + "presentation_request_handler.PresentationRequestHandler" +) + + +class PresentationRequest(AgentMessage): + """Class representing a presentation request.""" + + class Meta: + """PresentationRequest metadata.""" + + handler_class = HANDLER_CLASS + schema_class = "PresentationRequestSchema" + message_type = PRESENTATION_REQUEST + + def __init__( + self, + _id: str = None, + *, + comment: str = None, + request_presentations_attach: Sequence[AttachDecorator] = None, + **kwargs + ): + """ + Initialize presentation request object. + + Args: + request_presentations_attach: proof request attachments + comment: optional comment + + """ + super().__init__(_id=_id, **kwargs) + self.comment = comment + self.request_presentations_attach = ( + list(request_presentations_attach) if request_presentations_attach else [] + ) + + def indy_proof_request(self, index: int = 0): + """ + Retrieve and decode indy proof request from attachment. + + Args: + index: ordinal in attachment list to decode and return + (typically, list has length 1) + + """ + return self.request_presentations_attach[index].indy_dict + + +class PresentationRequestSchema(AgentMessageSchema): + """Presentation request schema.""" + + class Meta: + """Presentation request schema metadata.""" + + model_class = PresentationRequest + + comment = fields.Str(description="Human-readable comment", required=False) + request_presentations_attach = fields.Nested( + AttachDecoratorSchema, + required=True, + many=True, + data_key="request_presentations~attach" + ) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/__init__.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation.py new file mode 100644 index 0000000000..ffdbae36c7 --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation.py @@ -0,0 +1,1726 @@ +from datetime import datetime, timezone +from unittest import TestCase + +import json + +from ......messaging.util import str_to_datetime, str_to_epoch +from ......messaging.decorators.attach_decorator import AttachDecorator +from ...message_types import PRESENTATION_PREVIEW, PRESENTATION +from ..presentation import Presentation, PresentationSchema + + +INDY_PROOF = json.loads("""{ + "proof": { + "proofs": [ + { + "primary_proof": { + "eq_proof": { + "revealed_attrs": { + "player": "51643998292319337989293919354395093705917445045137690661130646028663839100479", + "screencapture": "44349549498354774830429200435932754610833874667251545521048906777181334567815" + }, + "a_prime": "99225796363129499107604366233301127916801972855861917994091548556785477066502130364812678473656139160841991495705941663142249404264191136660967090000331013804872112053828446231809361437830836319053659748356431221803865514426935793809384997872056337751830616632363564742453553492463002290910985263848243042219992778220569301291770422529015543837803240796109915443060533925706334481433988647943330126921627882396088865869804752372160284361135101444121353171684921889674279086653358367368851378746682682017641177839566946615542088910003479771742848013401738739436319413416417782857315505655723403098381845469564775640588", + "e": "28484172708495089688591061270972815967199639781170588390863001534745829714460906432474939589201651469315129053056279820725958192110265136", + "v": "310372334186966867767394517648718214703060542831249429833856393387373123821840122943078768258679350688701210557571314095023780969910990133962277141702540794078947706642651608840502392669887782435197020314627659897216201821476693212776945691916847975724720629133302982522740709659244048850715861867163370042548490158397753134853555833520807793752332629616695158900467048806794540963892785661237500652426649694476839734543710070772542960644069106447786837388953205349573770161012926822761642443603379863014577283739370081232865330663720633954578384601051328768426422459925307605555673543912329275856396630694738618400128412881389666175048976575778590587039239852074579445246476657508798666439893978860122625600280248580653651989659501745788120866210838204610848826992305622269021702688221635993649651671518759866100294588385482901147670474709698187899410921387549396343476268788611619095756118794378610337782521199137176224", + "m": { + "master_secret": "8862971523696585539875886113995946020345090415446970983664333029999473510798222518918685777004680817221644138616821331322893963179678008732473561080506239631376575074759262558623", + "date": "3701779401056653400708681878780576462168055130242160156441633682389568986593680911678649493653787250169881692457012639607423195648009201693522087171287177627155679953691027082306", + "highscore": "15076065627409123507707791591890677721352422176962229116158012124884023896353283613850809563416017162039356935197216493911366484372240599638993754251972383037120760793174059437326" + }, + "m2": "936898843995229611075174877423066852536402039331414286329629096155063110397949209326899164087270236968111471019540493930568502892781443118611948331343540849982215419978654911341" + }, + "ge_proofs": [ + { + "u": { + "0": "9910469266382558421810537687107445527637953525140204243652090909154732881567346670639902692019649848585497930780041894066589111086262231121289584890680947709857922351898933228959", + "3": "13248890365144372967021124637790988823123419165600968402954657790395188046865908780216014168108873473963822724485182321396055154711186623889234974568160016086782335901983921278203", + "2": "12729039529929764954731327277162243472469670773258016331674525566138793186295239771259296208473089652983817249211287815365374343774154094615763169572305994728783319085378462750119", + "1": "7521808223922555179229230989469494924108464850902024304215849946306721494292541804707880060117792628557556919883251581183099791703469635100219991762282695219119375485542725378777" + }, + "r": { + "3": "2474586272027077975486776866285873096434331606893837372003598899080539007560599606386516216782289932402894639261205313757395792446618729476125758006073556965534129613180311177356435264610207048503766208536525437467431668179066975773784256747360733829457728689042564760785075167221871190381905962993342585054474809475874638461649882224522900216073005325070483781773167488104736004488166472769233964211119250710987817940295641154170092028642853085492684423831557787832223441677166939601580910358630679766277360055054583280321123214001754046780045473183143311899432032961118915743585200408079711683568075159956637355186537460890150634531127711285758617739650166494804700747238587328374163718880396805711619195410428497141", + "2": "2630464840206472451352435662547628615461823248193121896366292326754757111056015434024860402045528167154717793472610145065612236007297496233371229987771148480914236050468139151516543123252130359806069866913832582652430060368351667452390252792745981559902564451307173881741056494603759634524628446420629554945618261322035719400890137143004894938649567505283955045583301734843724484105958980144825603458470170246633173176192352102370358808823038609216670566297573459331481693366189829604088382174720921421068848195803685053584587847959340545747151323994860573252761484349482452752365951814578536977631851802458952874933908594142054875532155473403049377997857193944575096457437636198069049894085647451273888200116980726092", + "1": "2852147521010832076474693080123829749829373205563299890275783906381127244731842078098806253403603591226341534169437752738669698923225573040124923814326088208465858997309773274462266090025447286378141544917213418789777276232863321772419735930833747576309139155217894968446024099207333956546610641531588126922714769703447074214896402884035961312686767119156707888839495093047502240940442068243444839642678428392561564279122033304060367830470731800699885137708112213347900071682836322404659581146632750296228233441035302852186755012460856485782729749727931571925009194110383907166489891246153746477910501305713189452876479941940283249570571466801547498554092112942172290619708436101630604721002777991653223187127539407188", + "DELTA": "400740231805932179607546658608482360416676808879808936308239007450069335712770990135423875914299915061808733825416765503161922435087393607455279098108543704733230814698288332881292132566532892722244536550609474863487095816391676106247864333163126795882262678039103218492008333619274792818770308974444039810096709828122153085809072205039719201560334210985909087337968918296450456759914221258287823859138473869581326860149282690703526479416994879663317413415525469689392534867388970645182739614666457086788145724809368914878257774143699515974528212285813531498884015621850779340589427600835454594927635608618313963836648119837777098673007860656489994343544396208432731266075365830717274498351940211946906749568641992530", + "0": "1206673881851533752176657850353675358524597024445357836801291763123272463247544653871603547107824681844497100741157733091042299879547696954660696997520368168483474593036101472335505287047339386308031509611499543209773577503155192535635651933608157610580443175876534879594575874473220014224237470499919793664212944782077926675612730961243351448995290239215801924035454011696132815884654568365382507261689165029962957712345451405751438882798844088168256631131921905245510548989991506332080163507201398283921938862585730222296508424960186566696340473016767188656883762864118588802209468135703456208025238541839477324582906436589408122727413989766360283059475263178640070468528674228956264205722590748705114610224502937924" + }, + "mj": "15076065627409123507707791591890677721352422176962229116158012124884023896353283613850809563416017162039356935197216493911366484372240599638993754251972383037120760793174059437326", + "alpha": "20251550018805200717806858447687954659786798446794929315180340450717009476769405863150379133594211911561358797900043949141708879953965949034296837455168571993614131838308136400934080334484858045221098438795729643169952299593947544536931262318894249728605957882845005603393308631544441292352568325855333088710935761954865297018529190379824999153478968957974616452369432737206876894394433169704416574734350248494633324787866283669843201013454433040804437117653085130836624250598443032846839559239219803003053865279821640383428381442135797658167010830469471880710203270574788431679668220274396595963367127851323648855427656787139315767612273110920619973147968805075620184678295428649237076408491062282135768652643652528789723106929481799840238867321416457406628403722479816549749574895345371486180196838697381621782729034821539642473730374275", + "t": { + "1": "12625197327271658918836077104165526795551240746110291943686378253405709448817799744491714171274845559259436160572533367386665079411321345166308961745117961730852997405633497191717007336383275404469818669156913360917193297281721819431590624901667128875332561566036439988983652391578753211775620012967251966145029999420901103522072647380944582775843791262702644151927742979273959780439648875773579687106661837930590989533046533664173215551687012232903455587682542013722715620746003808218596250895466798585940038977660031964518043170383195848432855521396949050006496669882466103602834555814104353098012178481563132990657", + "3": "82102416726449754901570630901431660447826687124743534767954749884467633032358726226619062293813250820543583618667653110864397826099702636976514863698490371598871942970264169528954417033219855319798151022602756245903134559243361308006137131575819330897670063637536795053765101329851925607560890238602738686737347630399680932950512292412006361269539738453753560364596561872651528860308101942007770489206306048924418921104517753483478955863296623417733412161191192531054326372049247205543273207371278781809399097610512792780914259992762072456575639120070897889219135350947197581287043954055372025101673838669553746551523", + "0": "100578099981822727242488292109669009229478055276500695778799086886344998432604032811665840061704724353178176792298171825200217745276011656576161627798148614876492383276153655146449848780838571509873143828996025628954667317519574656744701630828190045526936155193536814016169445565475181479441229047491855276823646742587245970832496856994388840793376871874193330364608394771574962996229647270622689890201908589893313568444474914909794303851820492781326574727803226373005399197371492982012783800353741451399606551384719595965296619783050926116585174881782168129321205830465290478768408675156580724359333089093105010344487", + "2": "47291536708088381287407033267847414228876334422991232636387475485756328314399598367105968385520172836890544717976118198568671113811836108861048793780118048683411340116566023370245246884524520199561342298868861751758445312599348599287067000725278934840752177807977101054892905295530294108292736307777321970970868898458355273485795649568677223443447768573057466329236959267653001983213430774265365847091875699626385937604178216275273379502023024485339694410370916685404579472512288185963724377525685276628144678139522579811749896221643038522340842472046618109166452353106698715375908659582424315255951960930185079622552", + "DELTA": "55673614276503115042406892194681370272903807098038274960776275804979087176140123726613332530447421097732347173352956738522605590407126570163366084735258393133886870700490345929950624260625461471012084011187108973815830590105522983606251371051538463584013547099942110852482167674597067842508689609606420417081221833855564428834462819662758502495039815615824926366319292041564418283778981490957086445486745581161189382261760754225714728934548296947403634289640526526314947616964122321833465956469712078741533550908164428460947933509296796422321858634999992086190358241952920458802129165732538146634862846975496258789679" + }, + "predicate": { + "attr_name": "highscore", + "p_type": "GE", + "value": 1000000 + } + } + ] + }, + "non_revoc_proof": null + } + ], + "aggregated_proof": { + "c_hash": "81147637626301581116624461636159287563986704950981430016774756525127013830996", + "c_list": [ + [ + 3, + 18, + 5, + 11, + 249, + 192, + 147, + 232, + 208, + 2, + 120, + 15, + 246, + 67, + 152, + 178, + 13, + 223, + 45, + 197, + 49, + 251, + 124, + 129, + 88, + 30, + 22, + 215, + 93, + 198, + 188, + 111, + 134, + 78, + 237, + 244, + 150, + 57, + 134, + 207, + 48, + 252, + 238, + 215, + 44, + 69, + 28, + 38, + 231, + 95, + 66, + 222, + 118, + 30, + 137, + 6, + 78, + 103, + 185, + 218, + 139, + 176, + 149, + 97, + 40, + 224, + 246, + 241, + 87, + 80, + 58, + 169, + 185, + 39, + 121, + 175, + 175, + 181, + 73, + 172, + 152, + 149, + 252, + 2, + 237, + 255, + 147, + 215, + 212, + 0, + 134, + 24, + 198, + 1, + 241, + 191, + 206, + 227, + 200, + 228, + 32, + 22, + 90, + 101, + 237, + 161, + 32, + 157, + 211, + 231, + 28, + 106, + 42, + 227, + 234, + 207, + 116, + 119, + 121, + 173, + 188, + 167, + 195, + 218, + 223, + 194, + 123, + 102, + 140, + 36, + 121, + 231, + 254, + 240, + 155, + 55, + 244, + 236, + 106, + 84, + 62, + 169, + 69, + 56, + 191, + 61, + 29, + 29, + 117, + 196, + 40, + 26, + 210, + 204, + 194, + 164, + 5, + 25, + 138, + 235, + 164, + 176, + 182, + 32, + 100, + 24, + 52, + 71, + 227, + 199, + 45, + 162, + 88, + 66, + 245, + 222, + 51, + 250, + 174, + 222, + 34, + 93, + 63, + 181, + 49, + 45, + 226, + 120, + 183, + 81, + 127, + 222, + 168, + 100, + 99, + 8, + 8, + 248, + 24, + 142, + 118, + 99, + 42, + 157, + 170, + 117, + 103, + 183, + 22, + 253, + 189, + 186, + 234, + 88, + 129, + 202, + 193, + 32, + 237, + 49, + 251, + 49, + 131, + 183, + 2, + 22, + 44, + 207, + 13, + 83, + 98, + 38, + 14, + 160, + 14, + 13, + 146, + 108, + 239, + 43, + 47, + 238, + 251, + 17, + 206, + 164, + 179, + 185, + 103, + 219, + 80, + 159, + 145, + 184, + 239, + 46, + 12 + ], + [ + 3, + 28, + 187, + 101, + 204, + 218, + 140, + 64, + 119, + 109, + 189, + 77, + 133, + 186, + 157, + 230, + 147, + 59, + 219, + 42, + 64, + 16, + 163, + 132, + 197, + 115, + 236, + 3, + 117, + 211, + 98, + 142, + 33, + 166, + 85, + 1, + 88, + 93, + 245, + 55, + 253, + 248, + 59, + 240, + 70, + 169, + 206, + 15, + 157, + 202, + 59, + 254, + 204, + 251, + 3, + 126, + 139, + 138, + 251, + 103, + 229, + 185, + 66, + 105, + 188, + 36, + 47, + 233, + 32, + 148, + 14, + 116, + 14, + 40, + 62, + 209, + 131, + 62, + 108, + 124, + 251, + 157, + 114, + 208, + 94, + 195, + 239, + 168, + 196, + 162, + 19, + 23, + 21, + 215, + 235, + 26, + 12, + 211, + 250, + 184, + 14, + 57, + 116, + 53, + 94, + 179, + 92, + 6, + 45, + 72, + 140, + 173, + 133, + 162, + 150, + 17, + 235, + 31, + 82, + 88, + 14, + 89, + 143, + 166, + 97, + 157, + 250, + 191, + 236, + 95, + 115, + 137, + 102, + 29, + 61, + 179, + 40, + 219, + 182, + 124, + 162, + 134, + 146, + 113, + 137, + 234, + 30, + 130, + 201, + 215, + 22, + 28, + 40, + 108, + 174, + 166, + 191, + 239, + 251, + 166, + 163, + 248, + 245, + 140, + 249, + 199, + 168, + 137, + 50, + 230, + 83, + 204, + 238, + 235, + 156, + 202, + 77, + 1, + 12, + 112, + 242, + 56, + 189, + 100, + 37, + 43, + 139, + 230, + 60, + 235, + 94, + 110, + 13, + 51, + 230, + 136, + 33, + 208, + 191, + 83, + 149, + 167, + 17, + 255, + 252, + 115, + 11, + 177, + 12, + 98, + 208, + 13, + 82, + 83, + 78, + 81, + 44, + 77, + 166, + 235, + 230, + 94, + 52, + 76, + 191, + 176, + 18, + 64, + 223, + 96, + 145, + 51, + 38, + 236, + 143, + 134, + 22, + 244, + 116, + 214, + 26, + 66, + 199, + 249, + 64, + 11, + 164, + 153, + 174, + 107, + 201, + 247, + 134, + 223, + 136, + 2, + 39 + ], + [ + 100, + 2, + 197, + 149, + 94, + 78, + 16, + 15, + 216, + 212, + 33, + 205, + 178, + 90, + 159, + 110, + 12, + 9, + 195, + 172, + 98, + 84, + 106, + 166, + 143, + 8, + 199, + 177, + 41, + 127, + 219, + 144, + 203, + 178, + 101, + 82, + 112, + 39, + 1, + 201, + 198, + 130, + 88, + 22, + 198, + 20, + 169, + 14, + 201, + 230, + 67, + 228, + 169, + 137, + 134, + 157, + 105, + 111, + 4, + 85, + 56, + 183, + 107, + 8, + 1, + 230, + 16, + 54, + 137, + 81, + 99, + 165, + 2, + 191, + 84, + 188, + 68, + 200, + 91, + 223, + 145, + 201, + 36, + 217, + 23, + 124, + 88, + 78, + 186, + 186, + 63, + 25, + 188, + 95, + 138, + 240, + 187, + 154, + 27, + 12, + 228, + 173, + 156, + 225, + 43, + 200, + 163, + 221, + 241, + 105, + 61, + 99, + 182, + 150, + 56, + 141, + 248, + 113, + 54, + 231, + 19, + 51, + 4, + 232, + 15, + 70, + 213, + 186, + 10, + 247, + 219, + 255, + 159, + 30, + 42, + 205, + 228, + 91, + 1, + 158, + 90, + 6, + 112, + 252, + 153, + 234, + 57, + 90, + 107, + 172, + 180, + 150, + 189, + 188, + 201, + 143, + 121, + 38, + 51, + 235, + 122, + 163, + 129, + 205, + 24, + 30, + 59, + 91, + 233, + 1, + 80, + 186, + 199, + 153, + 222, + 201, + 78, + 156, + 74, + 111, + 31, + 105, + 83, + 23, + 167, + 55, + 2, + 38, + 102, + 254, + 51, + 157, + 37, + 83, + 232, + 48, + 29, + 108, + 30, + 13, + 152, + 151, + 27, + 218, + 2, + 59, + 4, + 74, + 22, + 127, + 186, + 54, + 120, + 127, + 203, + 250, + 161, + 6, + 9, + 166, + 122, + 112, + 141, + 64, + 60, + 192, + 95, + 47, + 191, + 8, + 94, + 231, + 5, + 11, + 61, + 239, + 136, + 85, + 56, + 42, + 11, + 224, + 60, + 229, + 139, + 244, + 25, + 26, + 159, + 166, + 79, + 67, + 12, + 111, + 148, + 193 + ], + [ + 1, + 118, + 159, + 2, + 129, + 184, + 137, + 5, + 51, + 164, + 24, + 85, + 155, + 119, + 100, + 109, + 91, + 14, + 209, + 217, + 55, + 243, + 140, + 157, + 24, + 70, + 85, + 43, + 5, + 8, + 112, + 215, + 228, + 90, + 166, + 205, + 46, + 79, + 107, + 162, + 136, + 139, + 7, + 34, + 80, + 253, + 216, + 178, + 107, + 67, + 44, + 184, + 135, + 90, + 140, + 117, + 10, + 237, + 33, + 146, + 73, + 88, + 123, + 61, + 203, + 227, + 138, + 96, + 130, + 148, + 4, + 70, + 34, + 234, + 229, + 13, + 25, + 202, + 122, + 58, + 244, + 228, + 234, + 223, + 237, + 124, + 22, + 222, + 229, + 79, + 223, + 138, + 52, + 50, + 28, + 168, + 4, + 214, + 26, + 111, + 217, + 22, + 205, + 149, + 100, + 36, + 40, + 42, + 248, + 58, + 10, + 35, + 103, + 175, + 77, + 175, + 198, + 195, + 122, + 176, + 250, + 57, + 64, + 233, + 128, + 200, + 162, + 124, + 129, + 200, + 54, + 99, + 99, + 237, + 246, + 107, + 97, + 196, + 62, + 167, + 109, + 187, + 143, + 106, + 43, + 133, + 219, + 70, + 181, + 42, + 107, + 13, + 12, + 146, + 149, + 22, + 234, + 39, + 69, + 126, + 128, + 174, + 121, + 208, + 84, + 98, + 130, + 153, + 17, + 20, + 239, + 13, + 190, + 143, + 247, + 160, + 214, + 157, + 53, + 196, + 181, + 181, + 187, + 175, + 76, + 97, + 142, + 193, + 183, + 80, + 88, + 109, + 73, + 178, + 79, + 222, + 47, + 193, + 232, + 233, + 110, + 215, + 229, + 80, + 49, + 145, + 59, + 202, + 136, + 50, + 49, + 12, + 253, + 21, + 122, + 80, + 183, + 142, + 34, + 141, + 237, + 142, + 23, + 99, + 69, + 231, + 105, + 76, + 248, + 237, + 130, + 200, + 215, + 160, + 59, + 25, + 198, + 105, + 130, + 20, + 96, + 200, + 183, + 159, + 232, + 177, + 244, + 84, + 169, + 245, + 209, + 111, + 53, + 240, + 123, + 11, + 152 + ], + [ + 2, + 138, + 96, + 92, + 255, + 34, + 116, + 173, + 20, + 69, + 199, + 3, + 5, + 92, + 201, + 32, + 201, + 31, + 179, + 150, + 90, + 107, + 31, + 3, + 191, + 223, + 78, + 115, + 65, + 64, + 16, + 87, + 247, + 247, + 21, + 69, + 196, + 57, + 136, + 39, + 234, + 158, + 1, + 163, + 252, + 36, + 57, + 107, + 168, + 117, + 225, + 98, + 29, + 146, + 235, + 106, + 133, + 38, + 101, + 9, + 184, + 149, + 75, + 179, + 75, + 156, + 5, + 109, + 37, + 180, + 150, + 97, + 61, + 70, + 97, + 32, + 135, + 82, + 71, + 4, + 200, + 150, + 253, + 125, + 232, + 119, + 231, + 74, + 221, + 185, + 139, + 56, + 214, + 209, + 46, + 138, + 92, + 102, + 93, + 249, + 240, + 97, + 245, + 177, + 115, + 108, + 189, + 68, + 93, + 85, + 108, + 216, + 40, + 161, + 55, + 32, + 13, + 34, + 12, + 198, + 184, + 69, + 10, + 191, + 38, + 79, + 194, + 167, + 19, + 135, + 195, + 62, + 245, + 248, + 122, + 144, + 132, + 233, + 238, + 78, + 242, + 137, + 129, + 117, + 210, + 244, + 53, + 87, + 73, + 246, + 30, + 223, + 83, + 0, + 84, + 83, + 36, + 211, + 231, + 24, + 60, + 58, + 114, + 223, + 218, + 47, + 32, + 47, + 34, + 227, + 224, + 122, + 50, + 215, + 242, + 198, + 104, + 205, + 192, + 11, + 142, + 139, + 17, + 101, + 236, + 88, + 9, + 119, + 137, + 218, + 215, + 73, + 235, + 183, + 59, + 223, + 42, + 203, + 218, + 76, + 184, + 27, + 70, + 225, + 6, + 151, + 2, + 183, + 106, + 124, + 14, + 219, + 58, + 71, + 100, + 2, + 135, + 124, + 43, + 178, + 12, + 140, + 45, + 136, + 135, + 69, + 195, + 219, + 63, + 249, + 58, + 140, + 198, + 123, + 143, + 203, + 132, + 105, + 55, + 36, + 14, + 107, + 211, + 251, + 173, + 102, + 241, + 193, + 165, + 3, + 168, + 108, + 93, + 127, + 3, + 162, + 227 + ], + [ + 1, + 185, + 5, + 29, + 44, + 82, + 241, + 206, + 149, + 5, + 122, + 252, + 235, + 120, + 16, + 15, + 71, + 16, + 151, + 103, + 254, + 245, + 217, + 73, + 207, + 230, + 48, + 243, + 78, + 241, + 168, + 104, + 15, + 36, + 251, + 86, + 253, + 17, + 224, + 55, + 55, + 167, + 239, + 241, + 16, + 62, + 0, + 100, + 53, + 9, + 36, + 151, + 215, + 143, + 218, + 214, + 72, + 24, + 152, + 42, + 144, + 168, + 100, + 122, + 101, + 248, + 55, + 109, + 225, + 78, + 58, + 108, + 185, + 206, + 44, + 23, + 114, + 116, + 222, + 91, + 168, + 112, + 48, + 141, + 64, + 71, + 142, + 191, + 255, + 83, + 126, + 61, + 160, + 123, + 215, + 116, + 45, + 198, + 122, + 62, + 63, + 107, + 40, + 58, + 56, + 166, + 148, + 204, + 220, + 10, + 67, + 200, + 94, + 140, + 173, + 98, + 26, + 61, + 146, + 74, + 106, + 73, + 162, + 150, + 210, + 96, + 244, + 191, + 80, + 109, + 153, + 157, + 59, + 31, + 151, + 218, + 156, + 244, + 212, + 208, + 160, + 112, + 220, + 134, + 64, + 28, + 164, + 111, + 219, + 198, + 234, + 130, + 54, + 20, + 217, + 56, + 115, + 0, + 28, + 44, + 18, + 3, + 8, + 70, + 248, + 157, + 67, + 198, + 216, + 69, + 232, + 236, + 111, + 145, + 191, + 214, + 186, + 208, + 126, + 133, + 151, + 166, + 251, + 30, + 26, + 163, + 255, + 234, + 241, + 251, + 253, + 132, + 247, + 204, + 95, + 124, + 142, + 76, + 250, + 115, + 91, + 240, + 169, + 203, + 162, + 57, + 41, + 42, + 150, + 242, + 72, + 227, + 223, + 76, + 149, + 87, + 153, + 77, + 193, + 63, + 159, + 32, + 190, + 32, + 126, + 53, + 26, + 99, + 95, + 59, + 205, + 22, + 161, + 9, + 195, + 16, + 48, + 79, + 53, + 235, + 46, + 71, + 0, + 8, + 57, + 55, + 6, + 87, + 1, + 198, + 107, + 255, + 135, + 80, + 239, + 33, + 47 + ] + ] + } + }, + "requested_proof": { + "revealed_attrs": { + "0_player_uuid": { + "sub_proof_index": 0, + "raw": "Richie Knucklez", + "encoded": "51643998292319337989293919354395093705917445045137690661130646028663839100479" + }, + "0_screencapture_uuid": { + "sub_proof_index": 0, + "raw": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl", + "encoded": "44349549498354774830429200435932754610833874667251545521048906777181334567815" + } + }, + "self_attested_attrs": {}, + "unrevealed_attrs": {}, + "predicates": { + "0_highscore_GE_uuid": { + "sub_proof_index": 0 + } + } + }, + "identifiers": [ + { + "schema_id": "WjFgAM9qFept242HWzUSTZ:2:high_score:1.0", + "cred_def_id": "WjFgAM9qFept242HWzUSTZ:3:CL:13:tag", + "rev_reg_id": null, + "timestamp": null + } + ] +}""") + +PRES = Presentation( + comment="Test", + presentations_attach=[ + AttachDecorator.from_indy_dict(INDY_PROOF) + ] +) + +class TestPresentation(TestCase): + """Presentation tests.""" + + def test_init(self): + """Test initializer.""" + assert PRES.presentations_attach[0].indy_dict == INDY_PROOF + assert PRES.indy_proof(0) == INDY_PROOF + + def test_type(self): + """Test type.""" + assert PRES._type == PRESENTATION + + def test_deserialize(self): + """Test deserialization.""" + dump = json.dumps({ + "@type": PRESENTATION, + "comment": "Hello World", + "presentations~attach": [ + AttachDecorator.from_indy_dict(INDY_PROOF).serialize() + ] + }) + + presentation = Presentation.deserialize(dump) + assert type(presentation) == Presentation + + def test_serialize(self): + """Test serialization.""" + pres_dict = PRES.serialize() + pres_dict.pop("@id") + + assert pres_dict == { + "@type": PRESENTATION, + "presentations~attach": [ + AttachDecorator.from_indy_dict(INDY_PROOF).serialize() + ], + "comment": "Test" + } + + +class TestPresentationSchema(TestCase): + """Test presentation schema""" + + def test_make_model(self): + """Test making model.""" + pres_dict = PRES.serialize() + ''' + Looks like: { + "@type": "did:sov:BzCbsNYhMrjHiqZDTUASHg;spec/present-proof/1.0/presentation", + "@id": "f49773e3-bd56-4868-a5f1-456d1e6d1a16", + "comment": "Test", + "presentations~attach": [ + { + "mime-type": "application/json", + "data": { + "base64": "eyJuYW..." + } + } + ] + } + ''' + + model_instance = PRES.deserialize(pres_dict) + assert isinstance(model_instance, Presentation) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation_proposal.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation_proposal.py new file mode 100644 index 0000000000..8e9d0f84c5 --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation_proposal.py @@ -0,0 +1,134 @@ +from ..presentation_proposal import PresentationProposal +from ..inner.presentation_preview import PresentationAttrPreview, PresentationPreview +from ...message_types import PRESENTATION_PREVIEW, PRESENTATION_PROPOSAL + +from unittest import TestCase + + +CD_ID = "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" +PRES_PREVIEW = PresentationPreview( + attributes={ + CD_ID: { + "player": PresentationAttrPreview(value="Richie Knucklez"), + "screenCapture": PresentationAttrPreview( + mime_type="image/png", + encoding="base64", + value="aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" + ) + } + }, + predicates={ + CD_ID: { + ">=": { + "highScore": "1000000" + } + } + }, + non_revocation_times={} +) + + +class TestPresentationProposal(TestCase): + """Presentation proposal tests.""" + + def test_init(self): + """Test initializer.""" + presentation_proposal = PresentationProposal( + comment="Hello World", + presentation_proposal=PRES_PREVIEW + ) + assert presentation_proposal.presentation_proposal == PRES_PREVIEW + + def test_type(self): + """Test type.""" + presentation_proposal = PresentationProposal( + comment="Hello World", + presentation_proposal=PRES_PREVIEW + ) + assert presentation_proposal._type == PRESENTATION_PROPOSAL + + def test_deserialize(self): + """Test deserialization.""" + obj = { + "@type": PRESENTATION_PROPOSAL, + "comment": "Hello World", + "presentation_proposal": { + "@type": PRESENTATION_PREVIEW, + "attributes": { + CD_ID: { + "player": { + "value": "Richie Knucklez" + }, + "screenCapture": { + "value": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl", + "encoding": "base64", + "mime-type": "image/png" + } + } + }, + "predicates": { + CD_ID: { + ">=": { + "highScore": 1000000 + } + } + }, + "non_revocation_times": {} + } + } + + pres_proposal = PresentationProposal.deserialize(obj) + assert type(pres_proposal) == PresentationProposal + + def test_serialize(self): + """Test serialization.""" + + pres_proposal = PresentationProposal( + comment="Hello World", + presentation_proposal=PRES_PREVIEW + ) + + pres_proposal_dict = pres_proposal.serialize() + pres_proposal_dict.pop("@id") + + assert pres_proposal_dict == { + "@type": PRESENTATION_PROPOSAL, + "comment": "Hello World", + "presentation_proposal": { + "@type": PRESENTATION_PREVIEW, + "attributes": { + CD_ID: { + "player": { + "value": "Richie Knucklez", + }, + "screenCapture": { + "value": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl", + "encoding": "base64", + "mime-type": "image/png" + } + } + }, + "predicates": { + CD_ID: { + ">=": { + "highScore": 1000000 + } + } + }, + "non_revocation_times": {} + } + } + +class TestPresentationProposalSchema(TestCase): + """Test presentation cred proposal schema.""" + + presentation_proposal = PresentationProposal( + comment="Hello World", + presentation_proposal=PRES_PREVIEW + ) + + def test_make_model(self): + """Test making model.""" + data = self.presentation_proposal.serialize() + model_instance = PresentationProposal.deserialize(data) + assert isinstance(model_instance, PresentationProposal) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation_request.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation_request.py new file mode 100644 index 0000000000..0d96e02f1b --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation_request.py @@ -0,0 +1,131 @@ +from datetime import datetime, timezone +from unittest import TestCase + +import json + +from ......messaging.util import str_to_datetime, str_to_epoch +from ......messaging.decorators.attach_decorator import AttachDecorator +from ...message_types import PRESENTATION_PREVIEW, PRESENTATION_REQUEST +from ..presentation_request import PresentationRequest, PresentationRequestSchema + + +NOW_8601 = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat(" ", "seconds") +NOW_EPOCH = str_to_epoch(NOW_8601) +CD_ID = "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" +INDY_PROOF_REQ = json.loads(f"""{{ + "name": "proof-req", + "version": "1.0", + "nonce": "12345", + "requested_attributes": {{ + "0_player_uuid": {{ + "name": "player", + "restrictions": [ + {{ + "cred_def_id": "{CD_ID}" + }} + ], + "non_revoked": {{ + "from": {NOW_EPOCH}, + "to": {NOW_EPOCH} + }} + }}, + "0_screencapture_uuid": {{ + "name": "screenCapture", + "restrictions": [ + {{ + "cred_def_id": "{CD_ID}" + }} + ], + "non_revoked": {{ + "from": {NOW_EPOCH}, + "to": {NOW_EPOCH} + }} + }} + }}, + "requested_predicates": {{ + "0_highscore_GE_uuid": {{ + "name": "highScore", + "p_type": ">=", + "p_value": 1000000, + "restrictions": [ + {{ + "cred_def_id": "{CD_ID}" + }} + ], + "non_revoked": {{ + "from": {NOW_EPOCH}, + "to": {NOW_EPOCH} + }} + }} + }} +}}""") +PRES_REQ = PresentationRequest( + comment="Test", + request_presentations_attach=[ + AttachDecorator.from_indy_dict(INDY_PROOF_REQ) + ] +) + +class TestPresentationRequest(TestCase): + """Presentation request tests.""" + + def test_init(self): + """Test initializer.""" + assert PRES_REQ.request_presentations_attach[0].indy_dict == INDY_PROOF_REQ + assert PRES_REQ.indy_proof_request(0) == INDY_PROOF_REQ + + def test_type(self): + """Test type.""" + assert PRES_REQ._type == PRESENTATION_REQUEST + + def test_deserialize(self): + """Test deserialization.""" + dump = json.dumps({ + "@type": PRESENTATION_REQUEST, + "comment": "Hello World", + "request_presentations~attach": [ + AttachDecorator.from_indy_dict(INDY_PROOF_REQ).serialize() + ] + }) + + presentation_request = PresentationRequest.deserialize(dump) + assert type(presentation_request) == PresentationRequest + + def test_serialize(self): + """Test serialization.""" + pres_req_dict = PRES_REQ.serialize() + pres_req_dict.pop("@id") + + assert pres_req_dict == { + "@type": PRESENTATION_REQUEST, + "request_presentations~attach": [ + AttachDecorator.from_indy_dict(INDY_PROOF_REQ).serialize() + ], + "comment": "Test" + } + + +class TestPresentationRequestSchema(TestCase): + """Test presentation request schema""" + + def test_make_model(self): + """Test making model.""" + pres_req_dict = PRES_REQ.serialize() + ''' + Looks like: { + "@type": "did:sov:BzCbsNYhMrjHiqZDTUASHg;spec/present-proof/1.0/request-presentation", + "@id": "f49773e3-bd56-4868-a5f1-456d1e6d1a16", + "comment": "Test", + "request_presentations~attach": [ + { + "mime-type": "application/json", + "data": { + "base64": "eyJuYW..." + } + } + ] + } + ''' + + model_instance = PRES_REQ.deserialize(pres_req_dict) + assert isinstance(model_instance, PresentationRequest) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/util/__init__.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/util/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/util/indy.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/util/indy.py new file mode 100644 index 0000000000..30b8436c2c --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/util/indy.py @@ -0,0 +1,78 @@ +"""Utilities for dealing with indy conventions.""" + +from collections import namedtuple +from enum import Enum +from typing import Any + +Relation = namedtuple("Relation", "fortran wql math yes no") + + +def canon(raw_attr_name: str) -> str: + """ + Canonicalize input attribute name for indy proofs and credential offers. + + Args: + raw_attr_name: raw attribute name + + Returns: + canonicalized attribute name + + """ + if raw_attr_name: # do not dereference None, and "" is already canonical + return raw_attr_name.replace(" ", "").lower() + return raw_attr_name + + +class Predicate(Enum): + """Enum for predicate types that indy-sdk supports.""" + + LT = Relation( + 'LT', + '$lt', + '<', + lambda x, y: Predicate.to_int(x) < Predicate.to_int(y), + lambda x, y: Predicate.to_int(x) >= Predicate.to_int(y)) + LE = Relation( + 'LE', + '$lte', + '<=', + lambda x, y: Predicate.to_int(x) <= Predicate.to_int(y), + lambda x, y: Predicate.to_int(x) > Predicate.to_int(y)) + GE = Relation( + 'GE', + '$gte', + '>=', + lambda x, y: Predicate.to_int(x) >= Predicate.to_int(y), + lambda x, y: Predicate.to_int(x) < Predicate.to_int(y)) + GT = Relation( + 'GT', + '$gt', + '>', + lambda x, y: Predicate.to_int(x) > Predicate.to_int(y), + lambda x, y: Predicate.to_int(x) <= Predicate.to_int(y)) + + @staticmethod + def get(relation: str) -> 'Predicate': + """Return enum instance corresponding to input relation string.""" + + for pred in Predicate: + if relation.upper() in ( + pred.value.fortran, pred.value.wql.upper(), pred.value.math + ): + return pred + return None + + @staticmethod + def to_int(value: Any) -> int: + """ + Cast a value as its equivalent int for indy predicate argument. + + Raise ValueError for any input but int, stringified int, or boolean. + + Args: + value: value to coerce + """ + + if isinstance(value, (bool, int)): + return int(value) + return int(str(value)) # kick out floats diff --git a/aries_cloudagent/messaging/present_proof/v1_0/models/__init__.py b/aries_cloudagent/messaging/present_proof/v1_0/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/present_proof/v1_0/models/presentation_exchange.py b/aries_cloudagent/messaging/present_proof/v1_0/models/presentation_exchange.py new file mode 100644 index 0000000000..afc1494e9b --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/models/presentation_exchange.py @@ -0,0 +1,116 @@ +"""Aries#0037 v1.0 presentation exchange information with non-secrets storage.""" + +from marshmallow import fields + +from ....models.base_record import BaseRecord, BaseRecordSchema + + +class V10PresentationExchange(BaseRecord): + """Represents an Aries#0037 v1.0 presentation exchange.""" + + class Meta: + """V10PresentationExchange metadata.""" + + schema_class = "V10PresentationExchangeSchema" + + RECORD_TYPE = "v10_presentation_exchange" + RECORD_ID_NAME = "presentation_exchange_id" + WEBHOOK_TOPIC = "Aries#0037 v1.0 presentations" + + INITIATOR_SELF = "self" + INITIATOR_EXTERNAL = "external" + + STATE_PROPOSAL_SENT = "proposal_sent" + STATE_PROPOSAL_RECEIVED = "proposal_received" + STATE_REQUEST_SENT = "request_sent" + STATE_REQUEST_RECEIVED = "request_received" + STATE_PRESENTATION_SENT = "presentation_sent" + STATE_PRESENTATION_RECEIVED = "presentation_received" + STATE_VERIFIED = "verified" + + def __init__( + self, + *, + presentation_exchange_id: str = None, + connection_id: str = None, + thread_id: str = None, + initiator: str = None, + state: str = None, + presentation_proposal_dict: dict = None, # serialized pres proposal message + presentation_request: dict = None, # indy proof req + presentation: dict = None, # indy proof + verified: str = None, + auto_present: bool = False, + error_msg: str = None, + **kwargs + ): + """Initialize a new PresentationExchange.""" + super().__init__(presentation_exchange_id, state, **kwargs) + self.connection_id = connection_id + self.thread_id = thread_id + self.initiator = initiator + self.state = state + self.presentation_proposal_dict = presentation_proposal_dict + self.presentation_request = presentation_request # indy proof req + self.presentation = presentation # indy proof + self.verified = verified + self.auto_present = auto_present + self.error_msg = error_msg + + @property + def presentation_exchange_id(self) -> str: + """Accessor for the ID associated with this exchange.""" + return self._id + + @property + def record_value(self) -> dict: + """Accessor for JSON record value generated for this presentation exchange.""" + result = self.tags + for prop in ( + "presentation_proposal_dict", + "presentation_request", + "presentation", + "auto_present", + "error_msg" + ): + val = getattr(self, prop) + if val: + result[prop] = val + return result + + @property + def record_tags(self) -> dict: + """Accessor for the record tags generated for this presentation exchange.""" + result = {} + for prop in ( + "connection_id", + "thread_id", + "initiator", + "state", + "verified" + ): + val = getattr(self, prop) + if val: + result[prop] = val + return result + + +class V10PresentationExchangeSchema(BaseRecordSchema): + """Schema for de/serialization of v1.0 presentation exchange records.""" + + class Meta: + """V10PresentationExchangeSchema metadata.""" + + model_class = V10PresentationExchange + + presentation_exchange_id = fields.Str(required=False) + connection_id = fields.Str(required=False) + thread_id = fields.Str(required=False) + initiator = fields.Str(required=False) + state = fields.Str(required=False) + presentation_proposal_dict = fields.Dict(required=False) + presentation_request = fields.Dict(required=False) + presentation = fields.Dict(required=False) + verified = fields.Str(required=False) + auto_present = fields.Bool(required=False) + error_msg = fields.Str(required=False) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/routes.py b/aries_cloudagent/messaging/present_proof/v1_0/routes.py new file mode 100644 index 0000000000..22650aba6b --- /dev/null +++ b/aries_cloudagent/messaging/present_proof/v1_0/routes.py @@ -0,0 +1,627 @@ +"""Admin routes for presentations.""" + +import json + +from aiohttp import web +from aiohttp_apispec import docs, request_schema, response_schema +from marshmallow import fields, Schema + +from ....holder.base import BaseHolder +from ....storage.error import StorageNotFoundError + +from ...connections.models.connection_record import ConnectionRecord + +from .manager import PresentationManager +from .messages.inner.presentation_preview import ( + PresentationPreview, + PresentationPreviewSchema +) +from .messages.presentation_proposal import PresentationProposal +from .models.presentation_exchange import ( + V10PresentationExchange, + V10PresentationExchangeSchema, +) + + +class V10PresentationExchangeListSchema(Schema): + """Result schema for an Aries#0037 v1.0 presentation exchange query.""" + + results = fields.List( + fields.Nested(V10PresentationExchangeSchema()), + description="Aries#0037 v1.0 presentation exchange records" + ) + + +class V10PresentationProposalRequestSchema(Schema): + """Request schema for sending a presentation proposal admin message.""" + + connection_id = fields.UUID(description="Connection identifier", required=True) + comment = fields.Str( + description="Human-readable comment", + required=False, + default="") + presentation_proposal = fields.Nested(PresentationPreviewSchema, required=True) + auto_present = fields.Boolean( + description=( + "Whether to respond automatically to presentation requests, building " + "and presenting requested proof" + ), + required=False, + default=False + ) + + +class V10PresentationProposalResultSchema(V10PresentationExchangeSchema): + """Result schema for sending a presentation proposal admin message.""" + + +class V10PresentationRequestRequestSchema(Schema): + """Request schema for sending a proof request.""" + + connection_id = fields.UUID(description="Connection identifier", required=True) + name = fields.String(example="proof-request", description="Proof request name") + version = fields.String(example="1.0", description="Proof request version") + comment = fields.String( + description="Human-readable comment", + required=False, + default="" + ) + presentation_proposal = fields.Nested(PresentationPreviewSchema, required=True) + + +class V10PresentationRequestResultSchema(V10PresentationExchangeSchema): + """Result schema for sending a presentation request admin message.""" + + +class IndyRequestedCredsRequestedAttrSchema(Schema): + """Schema for requested attributes within indy requested credentials structure.""" + + cred_id = fields.Str( + example="3fa85f64-5717-4562-b3fc-2c963f66afa6", + description=( + "Wallet credential identifier (typically but not necessarily a UUID)" + ) + ) + revealed = fields.Bool( + description="Whether to reveal attribute in proof", + default=True + ) + + +class IndyRequestedCredsRequestedPredSchema(Schema): + """Schema for requested predicates within indy requested credentials structure.""" + + cred_id = fields.Str( + example="3fa85f64-5717-4562-b3fc-2c963f66afa6", + description=( + "Wallet credential identifier (typically but not necessarily a UUID)" + ) + ) + + +class V10PresentationRequestSchema(Schema): + """Request schema for sending a presentation.""" + + self_attested_attributes = fields.Dict( + description=("Self-attested attributes to build into proof"), + required=True, + keys=fields.Str(example="attr_name"), # marshmallow/apispec v3.0rc3 ignores + values=fields.Str( + example="self_attested_value", + description=( + "Self-attested attribute values to use in requested-credentials " + "structure for proof construction" + ) + ) + ) + requested_attributes = fields.Dict( + description=( + "Nested object mapping proof request attribute referents to " + "requested-attribute specifiers" + ), + required=True, + keys=fields.Str(example="attr_referent"), # marshmallow/apispec v3.0rc3 ignores + values=fields.Nested(IndyRequestedCredsRequestedAttrSchema()) + ) + requested_predicates = fields.Dict( + description=( + "Nested object mapping proof request predicate referents to " + "requested-predicate specifiers" + ), + required=True, + keys=fields.Str(example="pred_referent"), # marshmallow/apispec v3.0rc3 ignores + values=fields.Nested(IndyRequestedCredsRequestedPredSchema()) + ) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + summary="Fetch all present-proof exchange records" +) +@response_schema(V10PresentationExchangeListSchema(), 200) +async def presentation_exchange_list(request: web.BaseRequest): + """ + Request handler for searching presentation exchange records. + + Args: + request: aiohttp request object + + Returns: + The presentation exchange list response + + """ + context = request.app["request_context"] + tag_filter = {} + for param_name in ( + "connection_id", + "thread_id", + "initiator", + "state", + "verified" + ): + if param_name in request.query and request.query[param_name] != "": + tag_filter[param_name] = request.query[param_name] + records = await V10PresentationExchange.query(context, tag_filter) + return web.json_response({"results": [record.serialize() for record in records]}) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + summary="Fetch a single presentation exchange record" +) +@response_schema(V10PresentationExchangeSchema(), 200) +async def presentation_exchange_retrieve(request: web.BaseRequest): + """ + Request handler for fetching a single presentation exchange record. + + Args: + request: aiohttp request object + + Returns: + The presentation exchange record response + + """ + context = request.app["request_context"] + presentation_exchange_id = request.match_info["pres_ex_id"] + try: + record = await V10PresentationExchange.retrieve_by_id( + context, + presentation_exchange_id + ) + except StorageNotFoundError: + raise web.HTTPNotFound() + return web.json_response(record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + summary="Fetch credentials for a presentation request from wallet", + parameters=[ + { + "name": "start", + "in": "query", + "schema": {"type": "string"}, + "required": False, + }, + { + "name": "count", + "in": "query", + "schema": {"type": "string"}, + "required": False, + }, + { + "name": "extra_query", + "in": "query", + "schema": {"type": "string"}, + "required": False, + }, + ] +) +async def presentation_exchange_credentials_list(request: web.BaseRequest): + """ + Request handler for searching applicable credential records. + + Args: + request: aiohttp request object + + Returns: + The credential list response + + """ + context = request.app["request_context"] + + presentation_exchange_id = request.match_info["pres_ex_id"] + presentation_referent = request.match_info["referent"] + + try: + presentation_exchange_record = await V10PresentationExchange.retrieve_by_id( + context, + presentation_exchange_id + ) + except StorageNotFoundError: + raise web.HTTPNotFound() + + start = request.query.get("start") + count = request.query.get("count") + + # url encoded json extra_query + encoded_extra_query = request.query.get("extra_query") or "{}" + extra_query = json.loads(encoded_extra_query) + + # defaults + start = int(start) if isinstance(start, str) else 0 + count = int(count) if isinstance(count, str) else 10 + + holder: BaseHolder = await context.inject(BaseHolder) + credentials = await holder.get_credentials_for_presentation_request_by_referent( + presentation_exchange_record.presentation_request, + (presentation_referent,) if presentation_referent else (), + start, + count, + extra_query + ) + + return web.json_response(credentials) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + summary="Sends a presentation request" +) +@request_schema(V10PresentationProposalRequestSchema()) +@response_schema(V10PresentationProposalResultSchema(), 200) +async def presentation_exchange_send_proposal(request: web.BaseRequest): + """ + Request handler for sending a presentation proposal. + + Args: + request: aiohttp request object + + Returns: + The presentation exchange details + + """ + context = request.app["request_context"] + outbound_handler = request.app["outbound_message_router"] + + body = await request.json() + + connection_id = body.get("connection_id") + try: + connection_record = await ConnectionRecord.retrieve_by_id( + context, + connection_id + ) + except StorageNotFoundError: + raise web.HTTPBadRequest() + + if not connection_record.is_ready: + return web.HTTPForbidden() + + comment = body.get("comment") + # Aries#0037 calls it a proposal in the proposal struct but it's of type preview + presentation_preview = body.get("presentation_proposal") + presentation_proposal_message = PresentationProposal( + comment=comment, + presentation_proposal=PresentationPreview.deserialize(presentation_preview) + ) + auto_present = body.get( + "auto_present", + context.settings.get("debug.auto_respond_presentation_request") + ) + + presentation_manager = PresentationManager(context) + + presentation_exchange_record = ( + await presentation_manager.create_exchange_for_proposal( + connection_id=connection_id, + presentation_proposal_message=presentation_proposal_message, + auto_present=auto_present + ) + ) + await outbound_handler(presentation_proposal_message, connection_id=connection_id) + + return web.json_response(presentation_exchange_record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + summary="Sends a free presentation request not bound to any proposal" +) +@request_schema(V10PresentationRequestRequestSchema()) +@response_schema(V10PresentationRequestResultSchema(), 200) +async def presentation_exchange_send_free_request(request: web.BaseRequest): + """ + Request handler for sending a presentation request free from any proposal. + + Args: + request: aiohttp request object + + Returns: + The presentation exchange details + + """ + context = request.app["request_context"] + outbound_handler = request.app["outbound_message_router"] + + body = await request.json() + + connection_id = body.get("connection_id") + try: + connection_record = await ConnectionRecord.retrieve_by_id( + context, + connection_id + ) + except StorageNotFoundError: + raise web.HTTPBadRequest() + + if not connection_record.is_ready: + return web.HTTPForbidden() + + comment = body.get("comment") + name = body.get("name", "proof-request") + version = body.get("version", "1.0") + presentation_proposal = body.get("presentation_proposal") + presentation_proposal_message = PresentationProposal( + comment=comment, + presentation_proposal=PresentationPreview.deserialize(presentation_proposal) + ) + + presentation_exchange_record = V10PresentationExchange( + connection_id=connection_id, + initiator=V10PresentationExchange.INITIATOR_SELF, + presentation_proposal_dict=presentation_proposal_message.serialize() + ) + + presentation_manager = PresentationManager(context) + + ( + presentation_exchange_record, + presentation_request_message, + ) = await presentation_manager.create_request( + presentation_exchange_record, + name=name, + version=version, + comment=comment + ) + + await outbound_handler(presentation_request_message, connection_id=connection_id) + + return web.json_response(presentation_exchange_record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + summary="Sends a presentation request in reference to a proposal" +) +@request_schema(V10PresentationRequestRequestSchema()) +@response_schema(V10PresentationRequestResultSchema(), 200) +async def presentation_exchange_send_bound_request(request: web.BaseRequest): + """ + Request handler for sending a presentation request free from any proposal. + + Args: + request: aiohttp request object + + Returns: + The presentation exchange details + + """ + context = request.app["request_context"] + outbound_handler = request.app["outbound_message_router"] + + presentation_exchange_id = request.match_info["pres_ex_id"] + presentation_exchange_record = await V10PresentationExchange.retrieve_by_id( + context, + presentation_exchange_id + ) + assert presentation_exchange_record.state == ( + V10PresentationExchange.STATE_PROPOSAL_RECEIVED + ) + body = await request.json() + + connection_id = body.get("connection_id") + try: + connection_record = await ConnectionRecord.retrieve_by_id( + context, + connection_id + ) + except StorageNotFoundError: + raise web.HTTPBadRequest() + + if not connection_record.is_ready: + return web.HTTPForbidden() + + presentation_manager = PresentationManager(context) + + ( + presentation_exchange_record, + presentation_request_message + ) = await presentation_manager.create_request( + presentation_exchange_record + ) + + await outbound_handler(presentation_request_message, connection_id=connection_id) + + return web.json_response(presentation_exchange_record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + summary="Sends a proof presentation" +) +@request_schema(V10PresentationRequestSchema()) +@response_schema(V10PresentationExchangeSchema()) +async def presentation_exchange_send_presentation(request: web.BaseRequest): + """ + Request handler for sending a presentation. + + Args: + request: aiohttp request object + + Returns: + The presentation exchange details + + """ + + context = request.app["request_context"] + outbound_handler = request.app["outbound_message_router"] + presentation_exchange_id = request.match_info["pres_ex_id"] + presentation_exchange_record = await V10PresentationExchange.retrieve_by_id( + context, presentation_exchange_id + ) + + body = await request.json() + + connection_id = presentation_exchange_record.connection_id + try: + connection_record = await ConnectionRecord.retrieve_by_id( + context, + connection_id + ) + except StorageNotFoundError: + raise web.HTTPBadRequest() + + if not connection_record.is_ready: + return web.HTTPForbidden() + + assert ( + presentation_exchange_record.state + ) == V10PresentationExchange.STATE_REQUEST_RECEIVED + + presentation_manager = PresentationManager(context) + + ( + presentation_exchange_record, + presentation_message, + ) = await presentation_manager.create_presentation( + presentation_exchange_record, + { + "self_attested_attributes": body.get("self_attested_attributes"), + "requested_attributes": body.get("requested_attributes"), + "requested_predicates": body.get("requested_predicates") + }, + comment=body.get("comment") + ) + + await outbound_handler(presentation_message, connection_id=connection_id) + return web.json_response(presentation_exchange_record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + summary="Verify a received presentation" +) +@response_schema(V10PresentationExchangeSchema()) +async def presentation_exchange_verify_presentation( + request: web.BaseRequest +): + """ + Request handler for verifying a presentation request. + + Args: + request: aiohttp request object + + Returns: + The presentation exchange details + + """ + context = request.app["request_context"] + presentation_exchange_id = request.match_info["pres_ex_id"] + + presentation_exchange_record = await V10PresentationExchange.retrieve_by_id( + context, + presentation_exchange_id + ) + connection_id = presentation_exchange_record.connection_id + + try: + connection_record = await ConnectionRecord.retrieve_by_id( + context, + connection_id + ) + except StorageNotFoundError: + raise web.HTTPBadRequest() + + if not connection_record.is_ready: + return web.HTTPForbidden() + + assert ( + presentation_exchange_record.state + ) == V10PresentationExchange.STATE_PRESENTATION_RECEIVED + + presentation_manager = PresentationManager(context) + + presentation_exchange_record = await presentation_manager.verify_presentation( + presentation_exchange_record + ) + + return web.json_response(presentation_exchange_record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + summary="Remove an existing presentation exchange record", +) +async def presentation_exchange_remove(request: web.BaseRequest): + """ + Request handler for removing a presentation exchange record. + + Args: + request: aiohttp request object + + """ + context = request.app["request_context"] + presentation_exchange_id = request.match_info["pres_ex_id"] + try: + presentation_exchange_record = await V10PresentationExchange.retrieve_by_id( + context, + presentation_exchange_id + ) + except StorageNotFoundError: + raise web.HTTPNotFound() + + await presentation_exchange_record.delete_record(context) + return web.json_response({}) + + +async def register(app: web.Application): + """Register routes.""" + + app.add_routes( + [ + web.get("/v1.0/present_proof_exchange", presentation_exchange_list), + web.get( + "/v1.0/present_proof_exchange/{pres_ex_id}", + presentation_exchange_retrieve + ), + web.get( + "/v1.0/present_proof_exchange/{pres_ex_id}/credentials/{referent}", + presentation_exchange_credentials_list, + ), + web.post( + "/v1.0/present_proof_exchange/send_proposal", + presentation_exchange_send_proposal, + ), + web.post( + "/v1.0/present_proof_exchange/send_request", + presentation_exchange_send_free_request, + ), + web.post( + "/v1.0/present_proof_exchange/{pres_ex_id}/send_request", + presentation_exchange_send_bound_request, + ), + web.post( + "/v1.0/present_proof_exchange/{pres_ex_id}/send_presentation", + presentation_exchange_send_presentation, + ), + web.post( + "/v1.0/present_proof_exchange/{pres_ex_id}/verify_presentation", + presentation_exchange_verify_presentation, + ), + web.post( + "/v1.0/present_proof_exchange/{pres_ex_id}/remove", + presentation_exchange_remove + ), + ] + ) diff --git a/aries_cloudagent/messaging/tests/test_utils.py b/aries_cloudagent/messaging/tests/test_utils.py index 4f67f5d55e..c2c215e056 100644 --- a/aries_cloudagent/messaging/tests/test_utils.py +++ b/aries_cloudagent/messaging/tests/test_utils.py @@ -1,7 +1,14 @@ from datetime import datetime, timezone from unittest import mock, TestCase -from ..util import str_to_datetime, datetime_to_str, datetime_now, time_now +from ..util import ( + datetime_now, + datetime_to_str, + epoch_to_str, + str_to_datetime, + str_to_epoch, + time_now +) class TestUtils(TestCase): @@ -42,3 +49,10 @@ def test_format(self): } for datetime_val, expected in tests.items(): assert datetime_to_str(datetime_val) == expected + + def test_epoch(self): + dt_now = datetime_now() + epoch_now = int(dt_now.timestamp()) + + assert epoch_now == str_to_epoch(dt_now) and isinstance(epoch_now, int) + assert epoch_to_str(epoch_now) == datetime_to_str(dt_now.replace(microsecond=0)) diff --git a/aries_cloudagent/messaging/tests/test_valid.py b/aries_cloudagent/messaging/tests/test_valid.py new file mode 100644 index 0000000000..c414af87b5 --- /dev/null +++ b/aries_cloudagent/messaging/tests/test_valid.py @@ -0,0 +1,85 @@ +from marshmallow import ValidationError +from unittest import TestCase + +from ..valid import ( + INDY_CRED_DEF_ID, + INDY_SCHEMA_ID, + INDY_PREDICATE, + INDY_ISO8601_DATETIME +) + + +class TestValid(TestCase): + + def test_cred_def_id(self): + non_cred_def_ids = [ + "Q4zqM7aXqm7gDQkUVLng9h:4:CL:18:0", + "Q4zqM7aXqm7gDQkUVLng9h::CL:18:0", + "Q4zqM7aXqm7gDQkUVLng9I:3:CL:18:tag", + "Q4zqM7aXqm7gDQkUVLng9h:3::18:tag", + "Q4zqM7aXqm7gDQkUVLng9h:3:18:tag" + + ] + for non_cred_def_id in non_cred_def_ids: + with self.assertRaises(ValidationError): + INDY_CRED_DEF_ID["validate"](non_cred_def_id) + + INDY_CRED_DEF_ID["validate"]("Q4zqM7aXqm7gDQkUVLng9h:3:CL:18:tag") + + def test_schema_id(self): + non_schema_ids = [ + "Q4zqM7aXqm7gDQkUVLng9h:3:bc-reg:1.0", + "Q4zqM7aXqm7gDQkUVLng9h::bc-reg:1.0", + "Q4zqM7aXqm7gDQkUVLng9h:bc-reg:1.0", + "Q4zqM7aXqm7gDQkUVLng9h:2:1.0", + "Q4zqM7aXqm7gDQkUVLng9h:2::1.0", + "Q4zqM7aXqm7gDQkUVLng9h:2:bc-reg:", + "Q4zqM7aXqm7gDQkUVLng9h:2:bc-reg:1.0a", + "Q4zqM7aXqm7gDQkUVLng9I:2:bc-reg:1.0" # I is not in base58 + ] + for non_schema_id in non_schema_ids: + with self.assertRaises(ValidationError): + INDY_SCHEMA_ID["validate"](non_schema_id) + + INDY_SCHEMA_ID["validate"]("Q4zqM7aXqm7gDQkUVLng9h:2:bc-reg:1.0") + + def test_predicate(self): + non_predicates = [ + ">>", + "", + " >= ", + "<<<=", + "==", + "=", + "!=" + ] + for non_predicate in non_predicates: + with self.assertRaises(ValidationError): + INDY_PREDICATE["validate"](non_predicate) + + INDY_PREDICATE["validate"]("<") + INDY_PREDICATE["validate"]("<=") + INDY_PREDICATE["validate"](">=") + INDY_PREDICATE["validate"](">") + + def test_indy_date(self): + non_datetimes = [ + "nope", + "2020-01-01", + "2020-01-01:00:00:00Z", + "2020.01.01 00:00:00Z", + "2020-01-01T00:00:00", + "2020-01-01T00:00.123456+00:00", + "2020-01-01T00:00:00.123456+0:00" + ] + for non_datetime in non_datetimes: + with self.assertRaises(ValidationError): + INDY_ISO8601_DATETIME["validate"](non_datetime) + + INDY_ISO8601_DATETIME["validate"]("2020-01-01 00:00:00Z") + INDY_ISO8601_DATETIME["validate"]("2020-01-01T00:00:00Z") + INDY_ISO8601_DATETIME["validate"]("2020-01-01 00:00:00+00:00") + INDY_ISO8601_DATETIME["validate"]("2020-01-01 00:00:00-00:00") + INDY_ISO8601_DATETIME["validate"]("2020-01-01 00:00-00:00") + INDY_ISO8601_DATETIME["validate"]("2020-01-01 00:00:00.1-00:00") + INDY_ISO8601_DATETIME["validate"]("2020-01-01 00:00:00.123456-00:00") diff --git a/aries_cloudagent/messaging/util.py b/aries_cloudagent/messaging/util.py index ab4fa2fc5a..913d246c76 100644 --- a/aries_cloudagent/messaging/util.py +++ b/aries_cloudagent/messaging/util.py @@ -28,6 +28,7 @@ def str_to_datetime(dt: Union[str, datetime]) -> datetime: Args: dt: May be a string or datetime to allow automatic conversion + """ if isinstance(dt, str): match = re.match( @@ -67,6 +68,26 @@ def str_to_datetime(dt: Union[str, datetime]) -> datetime: return dt +def str_to_epoch(dt: Union[str, datetime]) -> int: + """Convert an indy-standard datetime string to epoch seconds. + + Args: + dt: May be a string or datetime to allow automatic conversion + + """ + return int(str_to_datetime(dt).timestamp()) + + +def epoch_to_str(epoch: int) -> str: + """Convert epoch seconds to indy-standard datetime string. + + Args: + epoch: epoch seconds + + """ + return datetime_to_str(datetime.fromtimestamp(epoch, tz=timezone.utc)) + + def datetime_now() -> datetime: """Timestamp in UTC.""" return datetime.utcnow().replace(tzinfo=timezone.utc) diff --git a/aries_cloudagent/messaging/valid.py b/aries_cloudagent/messaging/valid.py new file mode 100644 index 0000000000..c5a535740a --- /dev/null +++ b/aries_cloudagent/messaging/valid.py @@ -0,0 +1,85 @@ +"""Validators for schema fields.""" + +from base58 import alphabet +from datetime import datetime +from marshmallow.validate import OneOf, Regexp + +from .util import epoch_to_str + +B58 = alphabet if isinstance(alphabet, str) else alphabet.decode("ascii") + + +class IndyCredDefId(Regexp): + """Validate value against indy credential definition identifier specification.""" + + EXAMPLE = "WgWxqztrNooG92RXvxSTWv:3:CL:20:tag" + + def __init__(self): + """Initializer.""" + + super().__init__( + rf"^[{B58}]{{21,22}}:3:CL:[1-9][0-9]*:.+$", + error="Value {input} is not an indy credential definition identifier." + ) + + +class IndySchemaId(Regexp): + """Validate value against indy schema identifier specification.""" + + EXAMPLE = "WgWxqztrNooG92RXvxSTWv:2:schema_name:1.0" + + def __init__(self): + """Initializer.""" + + super().__init__( + rf"^[{B58}]{{21,22}}:2:.+:[0-9.]+$", + error="Value {input} is not an indy schema identifier." + ) + + +class IndyPredicate(OneOf): + """Validate value against indy predicate.""" + + EXAMPLE = ">=" + + def __init__(self): + """Initializer.""" + + super().__init__( + choices=["<", "<=", ">=", ">"], + error="Value {input} must be one of {choices}." + ) + + +class IndyISO8601DateTime(Regexp): + """Validate value against ISO 8601 datetime format, indy profile.""" + + EXAMPLE = epoch_to_str(int(datetime.now().timestamp())) + + def __init__(self): + """Initializer.""" + + super().__init__( + r"^(\d{4})-(\d\d)-(\d\d)[T ](\d\d):(\d\d)" + r"(?:\:(\d\d(?:\.\d{1,6})?))?([+-]\d\d:?\d\d|Z)$", + error="Value {input} is not a date in valid format." + ) + + +# Instances for marshmallow schema specification +INDY_CRED_DEF_ID = { + "validate": IndyCredDefId(), + "example": IndyCredDefId.EXAMPLE +} +INDY_SCHEMA_ID = { + "validate": IndySchemaId(), + "example": IndySchemaId.EXAMPLE +} +INDY_PREDICATE = { + "validate": IndyPredicate(), + "example": IndyPredicate.EXAMPLE +} +INDY_ISO8601_DATETIME = { + "validate": IndyISO8601DateTime(), + "example": IndyISO8601DateTime.EXAMPLE +} From 372488287840b12aa6a51aa7e5dfa57531768e9e Mon Sep 17 00:00:00 2001 From: sklump Date: Mon, 12 Aug 2019 09:37:50 -0400 Subject: [PATCH 2/4] descriptions and validators for attach decorator Signed-off-by: sklump --- aries_cloudagent/messaging/.util.py.swp | Bin 12288 -> 0 bytes .../messaging/decorators/attach_decorator.py | 82 +++++++++++++----- .../messaging/tests/test_valid.py | 41 ++++++++- aries_cloudagent/messaging/valid.py | 45 ++++++++++ 4 files changed, 147 insertions(+), 21 deletions(-) delete mode 100644 aries_cloudagent/messaging/.util.py.swp diff --git a/aries_cloudagent/messaging/.util.py.swp b/aries_cloudagent/messaging/.util.py.swp deleted file mode 100644 index 4e1e5959093ce76824bc4cf9f3e3f85ac9b49d38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI2O^g&p6vqpVAAtCQM`BVmXu92*W@Z;yGiKc&A%qBGgvEs2VNyF?Gt=19HR-N~ zodwi*P!7ffO*DG-%s~=O^eiUEc=YDM3r0jdcu*5P_^;}(o!w#aKw>o2$&c=-_p0i> ze^pl%*F^-Gsz12T6aR4C`OXVp5-)T;E4oUr8I> z#Uh`_)pj5fk9Bz{tAnBJiO`K-o|;aLt)g2&(g z^Z)<<@DL%_z!mTjcniD^e9!~?!Lwi=*bJ^dNXQr9b09$go&$euA>=1;9sCF`gAnw= z4shcE*unSUL+}B(1QIY0c7ki03HcU$4L$*b?E*Lco)0_E`eq65>R6@0nB9-Fbe$p3Q$}Tmq`>- zk4Y~5K5t3Uie+RMze91>b{wKyEz3F%msqm?fchalbnGbYh^Wt`3ls5phw3JD62*g>+5-Bjyew!jyq!jxi)Jcxdmxb$Rs3g!S<=UkwX9;KvqHoZ- z5dF-fxKhN_)B+&g)k7?GJWBX%vPC>jg0fUu)Rh*~nf#%uXRXHr$?U!#CX&Z9a=8_E zL;57eylnME62*AlTcZ=08!b4+nV4ydl=_(Zxg+$;oEjvzt9w?6j@4Mn<@xGlqnS>v z+^uM%@ToVZ*7~T>3XfV7QCW4XY)J=0x6e@Pq*aDc+I281-m?nH`&akA-;P8&qel&T zhUuB^tTm*s8JS4=-GBBkgYT;TFoY% zgE6hQYPFhTRG!RphIRrWq7{`#-BvWJT^ZiNRG}gu9>lyHgjE6~yjtO@Y(>9iF-9{K zGP9H|$z@c=8`Cmu%#S%e!GeSzh$1Lv9n&ir^#qS~{JLYxmj=8oxu;*bZ1WhMxhhxfTc$dy zxhF?wUu4;{jfIB2;4OIS$zAZy)hl~1IJ1s3YtK(qo66FdRsWtp>Fl&tS3iZS=XPIk zs=A5%Hh-Kp(}J{V$0|flQ&H=Os~>G`!8rLbRtBu;*v=58T2Gxc3$;BK)$izmVH)6skgao%`aZ%b(j6z>(*<(Coyk5x$e2b zEu^t6YB`0kHBqZIYBj5(R?c+RtsT`ogcU5iI5 zo1Y`rRTo2{ggt!p;K2hgLdHOJ)pzP{m&?O= Date: Fri, 30 Aug 2019 10:14:35 -0400 Subject: [PATCH 3/4] roll up recent developments with aries#0036, #0037, demo/workshop Signed-off-by: sklump --- aries_cloudagent/admin/server.py | 7 +- aries_cloudagent/holder/indy.py | 78 +- aries_cloudagent/holder/tests/test_indy.py | 28 +- aries_cloudagent/ledger/indy.py | 17 +- aries_cloudagent/ledger/tests/test_indy.py | 49 + .../connections/models/diddoc/util.py | 2 +- .../messaging/decorators/attach_decorator.py | 3 +- .../v1_0/handlers/credential_issue_handler.py | 14 +- .../handlers/credential_stored_handler.py | 37 + .../issue_credential/v1_0/manager.py | 146 ++- .../issue_credential/v1_0/message_types.py | 4 +- .../v1_0/messages/credential_stored.py | 35 + .../v1_0/messages/inner/credential_preview.py | 123 +- .../inner/tests/test_credential_preview.py | 99 +- .../messages/tests/test_credential_issue.py | 17 +- .../messages/tests/test_credential_offer.py | 8 +- .../tests/test_credential_proposal.py | 21 +- .../messages/tests/test_credential_request.py | 4 +- .../messages/tests/test_credential_stored.py | 60 + .../v1_0/models/credential_exchange.py | 9 +- .../messaging/issue_credential/v1_0/routes.py | 361 ++++-- .../v1_0/tests}/__init__.py | 0 .../v1_0/tests/test_routes.py | 1035 +++++++++++++++++ .../handlers/presentation_proposal_handler.py | 2 +- .../handlers/presentation_request_handler.py | 74 +- .../messaging/present_proof/v1_0/manager.py | 81 +- .../messages/inner/presentation_preview.py | 528 ++++----- .../inner/tests/test_presentation_preview.py | 483 ++++---- .../tests/test_presentation_proposal.py | 92 +- .../v1_0/models/presentation_exchange.py | 2 +- .../messaging/present_proof/v1_0/routes.py | 273 +++-- .../present_proof/v1_0/util/__init__.py | 0 .../v1_0/{messages => }/util/indy.py | 67 +- aries_cloudagent/messaging/util.py | 16 + aries_cloudagent/messaging/valid.py | 70 +- demo/AcmeDemoWorkshop.md | 170 ++- demo/AriesOpenAPIDemo-DMV.md | 101 +- demo/AriesOpenAPIDemo.md | 126 +- demo/runners/acme.py | 21 +- demo/runners/alice.py | 25 +- demo/runners/faber.py | 82 +- demo/runners/support/agent.py | 9 +- scripts/run_tests_indy | 2 +- 43 files changed, 3083 insertions(+), 1298 deletions(-) create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/handlers/credential_stored_handler.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_stored.py create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_stored.py rename aries_cloudagent/messaging/{present_proof/v1_0/messages/util => issue_credential/v1_0/tests}/__init__.py (100%) create mode 100644 aries_cloudagent/messaging/issue_credential/v1_0/tests/test_routes.py create mode 100644 aries_cloudagent/messaging/present_proof/v1_0/util/__init__.py rename aries_cloudagent/messaging/present_proof/v1_0/{messages => }/util/indy.py (51%) diff --git a/aries_cloudagent/admin/server.py b/aries_cloudagent/admin/server.py index c823e57446..543a2d68bf 100644 --- a/aries_cloudagent/admin/server.py +++ b/aries_cloudagent/admin/server.py @@ -420,7 +420,12 @@ async def _perform_send_webhook( full_webhook_url, json=payload ) as response: if response.status < 200 or response.status > 299: - raise Exception("Unexpected response status") + # raise Exception(f"Unexpected response status {response.status}") + raise Exception( + f"Unexpected: target {target_url}\n" + f"full {full_webhook_url}\n" + f"response {response}" + ) async def complete_webhooks(self): """Wait for all pending webhooks to be dispatched, used in testing.""" diff --git a/aries_cloudagent/holder/indy.py b/aries_cloudagent/holder/indy.py index 8b5ff2de64..3ceba34002 100644 --- a/aries_cloudagent/holder/indy.py +++ b/aries_cloudagent/holder/indy.py @@ -4,7 +4,7 @@ import logging from collections import OrderedDict -from typing import Sequence +from typing import Sequence, Union import indy.anoncreds from indy.error import ErrorCode, IndyError @@ -21,7 +21,7 @@ class IndyHolder(BaseHolder): """Indy holder class.""" - RECORD_TYPE_METADATA = "attribute-metadata" + RECORD_TYPE_MIME_TYPES = "attribute-mime-types" def __init__(self, wallet): """ @@ -76,7 +76,7 @@ async def store_credential( credential_definition, credential_data, credential_request_metadata, - credential_attr_metadata=None + credential_attr_mime_types=None ): """ Store a credential in the wallet. @@ -86,8 +86,8 @@ async def store_credential( credential_data: Credential data generated by the issuer credential_request_metadata: credential request metadata generated by the issuer - credential_attr_metadata: dict mapping attribute name to (optional) - encoding and MIME type to store as non-secret record, if specified + credential_attr_mime_types: dict mapping attribute names to (optional) + MIME types to store as non-secret record, if specified """ credential_id = await indy.anoncreds.prover_store_credential( @@ -99,28 +99,21 @@ async def store_credential( None, # We don't support revocation yet ) - if credential_attr_metadata: - metadata_tags = {} - for attr in credential_data["values"]: - meta = credential_attr_metadata.get(attr) - tag = { - key: meta.get( - key - ) for key in ('encoding', 'mime-type') if meta.get(key) - } - - if tag: # only include non-trivial tag per attr - metadata_tags[attr] = json.dumps(tag) - - if metadata_tags: - meta_record = StorageRecord( - type=IndyHolder.RECORD_TYPE_METADATA, + if credential_attr_mime_types: + mime_types = { + attr: credential_attr_mime_types.get(attr) + for attr in credential_data["values"] + if attr in credential_attr_mime_types + } + if mime_types: + record = StorageRecord( + type=IndyHolder.RECORD_TYPE_MIME_TYPES, value=credential_id, - tags=metadata_tags, - id=f"{IndyHolder.RECORD_TYPE_METADATA}::{credential_id}" + tags=mime_types, + id=f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{credential_id}" ) indy_stor = IndyStorage(self.wallet) - await indy_stor.add_record(meta_record) + await indy_stor.add_record(record) return credential_id @@ -252,13 +245,13 @@ async def delete_credential(self, credential_id: str): """ try: indy_stor = IndyStorage(self.wallet) - meta_record = await indy_stor.get_record( - IndyHolder.RECORD_TYPE_METADATA, - f"{IndyHolder.RECORD_TYPE_METADATA}::{credential_id}" + mime_types_record = await indy_stor.get_record( + IndyHolder.RECORD_TYPE_MIME_TYPES, + f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{credential_id}" ) - await indy_stor.delete_record(meta_record) + await indy_stor.delete_record(mime_types_record) except StorageNotFoundError: - pass # metadata record not present: carry on + pass # MIME types record not present: carry on try: await indy.anoncreds.prover_delete_credential( @@ -272,30 +265,31 @@ async def delete_credential(self, credential_id: str): else: raise - async def get_metadata(self, credential_id: str, attr: str = None) -> dict: + async def get_mime_type( + self, + credential_id: str, + attr: str = None + ) -> Union[dict, str]: """ - Get MIME type and encoding per attribute (or for all attributes). + Get MIME type per attribute (or for all attributes). Args: credential_id: credential id attr: attribute of interest or omit for all - Returns: metadata dict + Returns: Attribute MIME type or dict mapping attribute names to MIME types + attr_meta_json = all_meta.tags.get(attr) """ try: - all_meta = await IndyStorage(self.wallet).get_record( - IndyHolder.RECORD_TYPE_METADATA, - f"{IndyHolder.RECORD_TYPE_METADATA}::{credential_id}" + mime_types_record = await IndyStorage(self.wallet).get_record( + IndyHolder.RECORD_TYPE_MIME_TYPES, + f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{credential_id}" ) except StorageError: - return None # no metadata: not an error - - if attr: - attr_meta_json = all_meta.tags.get(attr) - return json.loads(attr_meta_json) if attr_meta_json else None + return None # no MIME types: not an error - return {attr: json.loads(all_meta.tags[attr]) for attr in all_meta.tags} + return mime_types_record.tags.get(attr) if attr else mime_types_record.tags async def create_presentation( self, @@ -322,7 +316,7 @@ async def create_presentation( self.wallet.master_secret_id, json.dumps(schemas), json.dumps(credential_definitions), - json.dumps({}), # We don't support revocation currently. + json.dumps({}) # We don't support revocation currently. ) presentation = json.loads(presentation_json) diff --git a/aries_cloudagent/holder/tests/test_indy.py b/aries_cloudagent/holder/tests/test_indy.py index 8d731e0a59..b70f74334a 100644 --- a/aries_cloudagent/holder/tests/test_indy.py +++ b/aries_cloudagent/holder/tests/test_indy.py @@ -66,16 +66,14 @@ async def test_store_credential(self, mock_store_cred): assert cred_id == "cred_id" @async_mock.patch("indy.non_secrets.get_wallet_record") - async def test_get_credential_attrs_metadata(self, mock_nonsec_get_wallet_record): + async def test_get_credential_attrs_mime_types(self, mock_nonsec_get_wallet_record): cred_id = "credential_id" dummy_tags = {"a": "1", "b": "2"} dummy_rec = { - "type": IndyHolder.RECORD_TYPE_METADATA, + "type": IndyHolder.RECORD_TYPE_MIME_TYPES, "id": cred_id, "value": "value", - "tags": { - attr: json.dumps(dummy_tags[attr]) for attr in dummy_tags - } + "tags": dummy_tags } mock_nonsec_get_wallet_record.return_value = json.dumps(dummy_rec) @@ -83,12 +81,12 @@ async def test_get_credential_attrs_metadata(self, mock_nonsec_get_wallet_record holder = IndyHolder(mock_wallet) - metadata = await holder.get_metadata(cred_id) + mime_types = await holder.get_mime_type(cred_id) mock_nonsec_get_wallet_record.assert_called_once_with( mock_wallet.handle, dummy_rec["type"], - f"{IndyHolder.RECORD_TYPE_METADATA}::{dummy_rec['id']}", + f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{dummy_rec['id']}", json.dumps( { "retrieveType": True, @@ -98,19 +96,17 @@ async def test_get_credential_attrs_metadata(self, mock_nonsec_get_wallet_record ) ) - assert metadata == dummy_tags + assert mime_types == dummy_tags @async_mock.patch("indy.non_secrets.get_wallet_record") - async def test_get_credential_attr_metadata(self, mock_nonsec_get_wallet_record): + async def test_get_credential_attr_mime_type(self, mock_nonsec_get_wallet_record): cred_id = "credential_id" dummy_tags = {"a": "1", "b": "2"} dummy_rec = { - "type": IndyHolder.RECORD_TYPE_METADATA, + "type": IndyHolder.RECORD_TYPE_MIME_TYPES, "id": cred_id, "value": "value", - "tags": { - attr: json.dumps(dummy_tags[attr]) for attr in dummy_tags - } + "tags": dummy_tags } mock_nonsec_get_wallet_record.return_value = json.dumps(dummy_rec) @@ -118,12 +114,12 @@ async def test_get_credential_attr_metadata(self, mock_nonsec_get_wallet_record) holder = IndyHolder(mock_wallet) - a_metadata = await holder.get_metadata(cred_id, "a") + a_mime_type = await holder.get_mime_type(cred_id, "a") mock_nonsec_get_wallet_record.assert_called_once_with( mock_wallet.handle, dummy_rec["type"], - f"{IndyHolder.RECORD_TYPE_METADATA}::{dummy_rec['id']}", + f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{dummy_rec['id']}", json.dumps( { "retrieveType": True, @@ -133,7 +129,7 @@ async def test_get_credential_attr_metadata(self, mock_nonsec_get_wallet_record) ) ) - assert a_metadata == dummy_tags["a"] + assert a_mime_type == dummy_tags["a"] @async_mock.patch("indy.anoncreds.prover_search_credentials") @async_mock.patch("indy.anoncreds.prover_fetch_credentials") diff --git a/aries_cloudagent/ledger/indy.py b/aries_cloudagent/ledger/indy.py index a62f0b92e1..45a82588d9 100644 --- a/aries_cloudagent/ledger/indy.py +++ b/aries_cloudagent/ledger/indy.py @@ -428,9 +428,10 @@ async def send_credential_definition(self, schema_id: str, tag: str = "default") except IndyError as error: if error.error_code == ErrorCode.AnoncredsCredDefAlreadyExistsError: try: - cred_def_id = re.search(r"\w*:\d*:CL:\d*:\w*", error.message).group( - 0 - ) + cred_def_id = re.search( + r"\w*:3:CL:(([1-9][0-9]*)|(.{21,22}:2:.+:[0-9.]+)):\w*", + error.message + ).group(0) return cred_def_id # The regex search failed so let the error bubble up except AttributeError: @@ -508,8 +509,12 @@ async def credential_definition_id2schema_id(self, credential_definition_id): from which to identify a schema """ - # scrape sequence number from cd_id - seq_no = int(credential_definition_id.split(":")[3]) + # scrape schema id or sequence number from cred def id + tokens = credential_definition_id.split(":") + if len(tokens) == 8: # node protocol >= 1.4: cred def id has 5 or 8 tokens + return ":".join(tokens[3:7]) # schema id spans 0-based positions 3-6 + + seq_no = int(tokens[3]) # get txn by sequence number, retrieve schema identifier components request_json = await indy.ledger.build_get_txn_request( @@ -519,7 +524,7 @@ async def credential_definition_id2schema_id(self, credential_definition_id): ) response = json.loads(await self._submit(request_json)) - # transaction data format assumes node protocol 1.4 (circa 2018-07) or higher + # transaction data format assumes node protocol >= 1.4 (circa 2018-07) data_txn = (response["result"].get("data", {}) or {}).get("txn", {}) if data_txn.get("type", None) == "101": # marks indy-sdk schema txn type (origin_did, name, version) = ( diff --git a/aries_cloudagent/ledger/tests/test_indy.py b/aries_cloudagent/ledger/tests/test_indy.py index 9ae3cf1412..0bf679e850 100644 --- a/aries_cloudagent/ledger/tests/test_indy.py +++ b/aries_cloudagent/ledger/tests/test_indy.py @@ -407,6 +407,55 @@ async def test_get_credential_definition( assert response == json.loads(mock_parse_get_cred_def_req.return_value[1]) + @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open") + @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_close") + @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._submit") + @async_mock.patch("indy.ledger.build_get_txn_request") + async def test_credential_definition_id2schema_id( + self, + mock_build_get_txn_req, + mock_submit, + mock_close, + mock_open, + ): + mock_wallet = async_mock.MagicMock() + mock_wallet.WALLET_TYPE = "indy" + + mock_build_get_txn_req.return_value = json.dumps("dummy") + mock_submit.return_value = json.dumps({ + "result": { + "data": { + "txn": { + "type": "101", + "metadata": { + "from": f"{TestIndyLedger.test_did}" + }, + "data": { + "data": { + "name": "favourite_drink", + "version": "1.0" + } + } + } + } + } + }) + + ledger = IndyLedger("name", mock_wallet) + + async with ledger: + s_id_short = await ledger.credential_definition_id2schema_id( + f"{TestIndyLedger.test_did}:3:CL:9999:tag" + ) + mock_build_get_txn_req.assert_called_once_with(None, None, seq_no=9999) + mock_submit.assert_called_once_with(mock_build_get_txn_req.return_value) + + assert s_id_short == f"{TestIndyLedger.test_did}:2:favourite_drink:1.0" + s_id_long = await ledger.credential_definition_id2schema_id( + f"{TestIndyLedger.test_did}:3:CL:{s_id_short}:tag" + ) + assert s_id_long == s_id_short + def test_error_handler(self): with self.assertRaises(LedgerTransactionError): with IndyErrorHandler("message", LedgerTransactionError): diff --git a/aries_cloudagent/messaging/connections/models/diddoc/util.py b/aries_cloudagent/messaging/connections/models/diddoc/util.py index 5c860bd6b1..7d838eff64 100644 --- a/aries_cloudagent/messaging/connections/models/diddoc/util.py +++ b/aries_cloudagent/messaging/connections/models/diddoc/util.py @@ -99,7 +99,7 @@ def canon_ref(did: str, ref: str, delimiter: str = None): def ok_did(token: str) -> bool: """ - Whether input token looks like a valid distributed identifier. + Whether input token looks like a valid decentralized identifier. Args: token: candidate string diff --git a/aries_cloudagent/messaging/decorators/attach_decorator.py b/aries_cloudagent/messaging/decorators/attach_decorator.py index 71d2573c38..e17a04d706 100644 --- a/aries_cloudagent/messaging/decorators/attach_decorator.py +++ b/aries_cloudagent/messaging/decorators/attach_decorator.py @@ -8,7 +8,6 @@ import base64 import json -from datetime import datetime from typing import Union from marshmallow import fields @@ -141,7 +140,7 @@ def __init__( description: str = None, filename: str = None, mime_type: str = None, - lastmod_time: datetime = None, + lastmod_time: str = None, byte_count: int = None, data: AttachDecoratorData, **kwargs diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/handlers/credential_issue_handler.py b/aries_cloudagent/messaging/issue_credential/v1_0/handlers/credential_issue_handler.py index 9e7e5a0966..53dbec0fe8 100644 --- a/aries_cloudagent/messaging/issue_credential/v1_0/handlers/credential_issue_handler.py +++ b/aries_cloudagent/messaging/issue_credential/v1_0/handlers/credential_issue_handler.py @@ -32,4 +32,16 @@ async def handle(self, context: RequestContext, responder: BaseResponder): credential_manager = CredentialManager(context) - await credential_manager.store_credential(context.message) + credential_exchange_record = await credential_manager.receive_credential( + context.message + ) + + # Automatically move to next state if flag is set + if context.settings.get("debug.auto_store_credential"): + ( + credential_exchange_record, + credential_stored_message, + ) = await credential_manager.store_credential(credential_exchange_record) + + # Notify issuer that credential was stored + await responder.send_reply(credential_stored_message) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/handlers/credential_stored_handler.py b/aries_cloudagent/messaging/issue_credential/v1_0/handlers/credential_stored_handler.py new file mode 100644 index 0000000000..3b1609d379 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/handlers/credential_stored_handler.py @@ -0,0 +1,37 @@ +"""Credential stored message handler.""" + +from ....base_handler import ( + BaseHandler, + BaseResponder, + HandlerException, + RequestContext +) + +from ..manager import CredentialManager +from ..messages.credential_stored import CredentialStored + + +class CredentialStoredHandler(BaseHandler): + """Message handler class for credential offers.""" + + async def handle(self, context: RequestContext, responder: BaseResponder): + """ + Message handler logic for credential stored messages. + + Args: + context: request context + responder: responder callback + + """ + self._logger.debug(f"CredentialStoredHandler called with context {context}") + + assert isinstance(context.message, CredentialStored) + + self._logger.info(f"Received credential stored message: {context.message}") + + if not context.connection_ready: + raise HandlerException("No connection established for credential store") + + credential_manager = CredentialManager(context) + + await credential_manager.credential_stored(context.message) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/manager.py b/aries_cloudagent/messaging/issue_credential/v1_0/manager.py index 05c602f551..d1fe534b39 100644 --- a/aries_cloudagent/messaging/issue_credential/v1_0/manager.py +++ b/aries_cloudagent/messaging/issue_credential/v1_0/manager.py @@ -19,6 +19,7 @@ from .messages.credential_offer import CredentialOffer from .messages.credential_proposal import CredentialProposal from .messages.credential_request import CredentialRequest +from .messages.credential_stored import CredentialStored from .messages.inner.credential_preview import CredentialPreview from .models.credential_exchange import V10CredentialExchange @@ -69,7 +70,7 @@ async def prepare_send( self, credential_definition_id: str, connection_id: str, - credential_values: dict + credential_proposal: CredentialProposal ) -> V10CredentialExchange: """ Set up a new credential exchange for an automated send. @@ -77,7 +78,8 @@ async def prepare_send( Args: credential_definition_id: Credential definition id for offer connection_id: Connection to create offer for - credential_values: The credential values to use if auto_issue is enabled + credential_proposal: The credential proposal with preview on + attribute values to use if auto_issue is enabled Returns: A new `V10CredentialExchange` record @@ -106,7 +108,8 @@ async def prepare_send( lookup_start = time.perf_counter() while True: source_credential_exchange = await V10CredentialExchange.retrieve_by_id( - self._context, source_credential_exchange_id + self._context, + source_credential_exchange_id ) if source_credential_exchange.credential_request: break @@ -118,7 +121,7 @@ async def prepare_send( if source_credential_exchange: # Since we have the source exchange cache, we can re-use the schema_id, - # credential_offer, and credential_request to save a roundtrip + # credential_offer, and credential_request to save a round trip credential_exchange = V10CredentialExchange( auto_issue=True, connection_id=connection_id, @@ -126,12 +129,9 @@ async def prepare_send( state=V10CredentialExchange.STATE_REQUEST_RECEIVED, credential_definition_id=credential_definition_id, schema_id=source_credential_exchange.schema_id, - credential_proposal_dict=( - source_credential_exchange.credential_proposal_dict - ), + credential_proposal_dict=credential_proposal.serialize(), credential_offer=source_credential_exchange.credential_offer, credential_request=source_credential_exchange.credential_request, - credential_values=credential_values, # We use the source credential exchange's thread id as the parent # thread id. This thread is a branch of that parent so that the other # agent can use the parent thread id to look up its corresponding @@ -151,8 +151,18 @@ async def prepare_send( # also instructing the agent to automatically issue the credential # once it receives the credential request - credential_exchange = await self.create_offer( - credential_definition_id, connection_id, True, credential_values + credential_exchange = V10CredentialExchange( + auto_issue=True, + connection_id=connection_id, + initiator=V10CredentialExchange.INITIATOR_SELF, + credential_definition_id=credential_definition_id, + credential_proposal_dict=credential_proposal.serialize() + ) + (credential_exchange, _) = await self.create_offer( + credential_exchange_record=credential_exchange, + comment=( + "Aries#0036v1.0 create automated credential exchange " + ) ) # Mark this credential exchange as the current cached one for this cred def @@ -165,18 +175,27 @@ async def perform_send( credential_exchange: V10CredentialExchange, outbound_handler ): - """Send the first message in a credential exchange.""" + """Send first message from credential exchange, issuer to holder.""" if credential_exchange.credential_request: (credential_exchange, credential_message) = await self.issue_credential( credential_exchange ) await outbound_handler( - credential_message, connection_id=credential_exchange.connection_id + credential_message, + connection_id=credential_exchange.connection_id ) else: - credential_exchange, credential_offer_message = await self.offer_credential( - credential_exchange + ( + credential_exchange, + credential_offer_message + ) = await self.create_offer( + credential_exchange, + comment=( + "Automated offer creation on cred def id " + f"{credential_exchange.credential_definition_id}, " + f"parent thread {credential_exchange.parent_thread_id}" + ) ) await outbound_handler( credential_offer_message, @@ -345,7 +364,7 @@ async def create_offer( reason="Aries#0036v1.0 create credential offer" ) - return credential_exchange_record, credential_offer_message + return (credential_exchange_record, credential_offer_message) async def receive_offer( self, @@ -448,7 +467,10 @@ async def receive_request( credential_exchange_record = await V10CredentialExchange.retrieve_by_tag_filter( self.context, - tag_filter={"thread_id": credential_request_message._thread_id}, + tag_filter={ + "thread_id": credential_request_message._thread_id + # initiator may be issuer (via request) or holder (via proposal) + } ) credential_exchange_record.credential_request = credential_request credential_exchange_record.state = V10CredentialExchange.STATE_REQUEST_RECEIVED @@ -475,7 +497,7 @@ async def issue_credential( credential_values: dict of credential attribute {name: value} pairs Returns: - Tuple: (Updated credential exchange record, credential message obj) + Tuple: (Updated credential exchange record, credential message) """ @@ -525,25 +547,33 @@ async def issue_credential( return credential_exchange_record, credential_message - async def store_credential( + async def receive_credential( self, credential_message: CredentialIssue ): """ - Store a credential in the wallet. + Receive a credential from an issuer. + + Hold in storage potentially to be processed by controller before storing. Args: credential_message: credential to store + Returns: + Credential exchange record + """ assert len(credential_message.credentials_attach or []) == 1 - credential = credential_message.indy_credential(0) + raw_credential = credential_message.indy_credential(0) try: credential_exchange_record = ( await V10CredentialExchange.retrieve_by_tag_filter( self.context, - tag_filter={"thread_id": credential_message._thread_id} + tag_filter={ + "thread_id": credential_message._thread_id + # initiator may be issuer (via request) or holder (via proposal) + } ) ) except StorageNotFoundError: @@ -558,7 +588,10 @@ async def store_credential( credential_exchange_record = ( await V10CredentialExchange.retrieve_by_tag_filter( self.context, - tag_filter={"thread_id": credential_message._thread.pthid} + tag_filter={ + "thread_id": credential_message._thread.pthid + # initiator may be issuer (via request) or holder (via proposal) + } ) ) @@ -567,30 +600,89 @@ async def store_credential( credential_exchange_record.credential_id = None credential_exchange_record.credential = None + credential_exchange_record.raw_credential = raw_credential + credential_exchange_record.state = ( + V10CredentialExchange.STATE_CREDENTIAL_RECEIVED + ) + + await credential_exchange_record.save( + self.context, + reason="Aries#0036v1.0 receive credential" + ) + return credential_exchange_record + + async def store_credential(self, credential_exchange_record: V10CredentialExchange): + """ + Store a credential in the wallet. + + Args: + credential_message: credential to store + + Returns: + Tuple: (Updated credential exchange record, credential-stored message) + + """ + raw_credential = credential_exchange_record.raw_credential + ledger: BaseLedger = await self.context.inject(BaseLedger) async with ledger: credential_definition = await ledger.get_credential_definition( - credential["cred_def_id"] + raw_credential["cred_def_id"] ) holder: BaseHolder = await self.context.inject(BaseHolder) credential_id = await holder.store_credential( credential_definition, - credential, + raw_credential, credential_exchange_record.credential_request_metadata, CredentialPreview.deserialize( credential_exchange_record.credential_proposal_dict[ "credential_proposal" ] - ).metadata() + ).mime_types() ) - wallet_credential = await holder.get_credential(credential_id) + credential = await holder.get_credential(credential_id) credential_exchange_record.state = V10CredentialExchange.STATE_STORED credential_exchange_record.credential_id = credential_id - credential_exchange_record.credential = wallet_credential + credential_exchange_record.credential = credential await credential_exchange_record.save( self.context, reason="Aries#0036v1.0 store credential" ) + + credential_stored_message = CredentialStored() + credential_stored_message.assign_thread_id( + credential_exchange_record.thread_id, + credential_exchange_record.parent_thread_id + ) + + return (credential_exchange_record, credential_stored_message) + + async def credential_stored(self, credential_stored_message: CredentialStored): + """ + Receive confirmation that holder stored credential. + + Args: + credential_message: credential to store + + Returns: + credential exchange record + + """ + credential_exchange_record = await V10CredentialExchange.retrieve_by_tag_filter( + self.context, + tag_filter={ + "thread_id": credential_stored_message._thread_id + # initiator may be issuer (via request) or holder (via proposal) + } + ) + + credential_exchange_record.state = V10CredentialExchange.STATE_STORED + await credential_exchange_record.save( + self.context, + reason="Aries#0036v1.0 credential stored" + ) + + return credential_exchange_record diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/message_types.py b/aries_cloudagent/messaging/issue_credential/v1_0/message_types.py index 4fc623859d..f6b0e91f58 100644 --- a/aries_cloudagent/messaging/issue_credential/v1_0/message_types.py +++ b/aries_cloudagent/messaging/issue_credential/v1_0/message_types.py @@ -8,13 +8,15 @@ CREDENTIAL_OFFER = f"{MESSAGE_FAMILY}offer-credential" CREDENTIAL_REQUEST = f"{MESSAGE_FAMILY}request-credential" CREDENTIAL_ISSUE = f"{MESSAGE_FAMILY}issue-credential" +CREDENTIAL_STORED = f"{MESSAGE_FAMILY}/credential-stored" TOP = "aries_cloudagent.messaging.issue_credential.v1_0" MESSAGE_TYPES = { CREDENTIAL_PROPOSAL: f"{TOP}.messages.credential_proposal.CredentialProposal", CREDENTIAL_OFFER: f"{TOP}.messages.credential_offer.CredentialOffer", CREDENTIAL_REQUEST: f"{TOP}.messages.credential_request.CredentialRequest", - CREDENTIAL_ISSUE: f"{TOP}.messages.credential_issue.CredentialIssue" + CREDENTIAL_ISSUE: f"{TOP}.messages.credential_issue.CredentialIssue", + CREDENTIAL_STORED: f"{TOP}.messages.credential_stored.CredentialStored" } # Inner object types diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_stored.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_stored.py new file mode 100644 index 0000000000..59973aa332 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/credential_stored.py @@ -0,0 +1,35 @@ +"""A credential stored message.""" + +# from marshmallow import fields + +from ....agent_message import AgentMessage, AgentMessageSchema +from ..message_types import CREDENTIAL_STORED + +HANDLER_CLASS = ( + "aries_cloudagent.messaging.issue_credential.v1_0.handlers." + "credential_stored_handler.CredentialStoredHandler" +) + + +class CredentialStored(AgentMessage): + """Class representing a credential stored message.""" + + class Meta: + """Credential metadata.""" + + handler_class = HANDLER_CLASS + schema_class = "CredentialStoredSchema" + message_type = CREDENTIAL_STORED + + def __init__(self, **kwargs): + """Initialize credential object.""" + super(CredentialStored, self).__init__(**kwargs) + + +class CredentialStoredSchema(AgentMessageSchema): + """Credential stored schema.""" + + class Meta: + """Schema metadata.""" + + model_class = CredentialStored diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/credential_preview.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/credential_preview.py index e5e67daf4d..26459d66b9 100644 --- a/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/credential_preview.py +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/credential_preview.py @@ -8,119 +8,96 @@ from marshmallow import fields, validate from .....models.base import BaseModel, BaseModelSchema +from .....util import canon from ...message_types import CREDENTIAL_PREVIEW -class CredentialAttrPreview(BaseModel): +class CredAttrSpec(BaseModel): """Class representing a preview of an attibute.""" - DEFAULT_META = {"mime-type": "text/plain"} - class Meta: """Attribute preview metadata.""" - schema_class = "CredentialAttrPreviewSchema" + schema_class = "CredAttrSpecSchema" def __init__( - self, - *, - name: str, - value: str, - encoding: str = None, - mime_type: str = None, - **kwargs): + self, + *, + name: str, + value: str, + mime_type: str = None, + **kwargs + ): """ Initialize attribute preview object. Args: name: attribute name - value: attribute value - encoding: encoding (omit or "base64") - mime_type: MIME type + value: attribute value; caller must base64-encode for attributes with + non-empty MIME type + mime_type: MIME type (default null) """ super().__init__(**kwargs) - self.name = name + + self.name = canon(name) self.value = value - self.encoding = encoding.lower() if encoding else None - self.mime_type = ( - mime_type.lower() - if mime_type and mime_type != CredentialAttrPreview.DEFAULT_META.get( - "mime-type" - ) - else None - ) + self.mime_type = mime_type.lower() if mime_type else None @staticmethod def list_plain(plain: dict): """ - Return a list of `CredentialAttrPreview` for plain text from names/values. + Return a list of `CredAttrSpec` without MIME types from names/values. Args: plain: dict mapping names to values Returns: - CredentialAttrPreview on name/values pairs with default MIME type + List of CredAttrSpecs with no MIME types """ - return [CredentialAttrPreview(name=k, value=plain[k]) for k in plain] + return [CredAttrSpec(name=k, value=plain[k]) for k in plain] def b64_decoded_value(self) -> str: """Value, base64-decoded if applicable.""" return base64.b64decode(self.value.encode()).decode( - ) if ( - self.value and - self.encoding and - self.encoding.lower() == "base64" - ) else self.value + ) if self.value and self.mime_type else self.value def __eq__(self, other): """Equality comparator.""" - if all( - getattr(self, attr, CredentialAttrPreview.DEFAULT_META.get(attr)) == - getattr(other, attr, CredentialAttrPreview.DEFAULT_META.get(attr)) - for attr in vars(self) - ): - return True # all attrs exactly match - if self.name != other.name: return False # distinct attribute names - if ( - self.mime_type or "text/plain" - ).lower() != (other.mime_type or "text/plain").lower(): + if (self.mime_type != other.mime_type): return False # distinct MIME types return self.b64_decoded_value() == other.b64_decoded_value() -class CredentialAttrPreviewSchema(BaseModelSchema): +class CredAttrSpecSchema(BaseModelSchema): """Attribute preview schema.""" class Meta: """Attribute preview schema metadata.""" - model_class = CredentialAttrPreview + model_class = CredAttrSpec - name = fields.Str(description="Attribute name", required=True, example="attr_name") + name = fields.Str( + description="Attribute name", + required=True, + example="favourite_drink") mime_type = fields.Str( - description="MIME type", + description='MIME type: omit for (null) default', required=False, data_key="mime-type", - example="text/plain" - ) - encoding = fields.Str( - description="Encoding (specify base64 or omit for none)", - required=False, - example="base64", - validate=validate.Equal("base64", error="Must be absent or equal to {other}") + example="image/jpeg" ) value = fields.Str( - description="Attribute value", + description="Attribute value: base64-encode if MIME type is present", required=True, - example="attr_value" + example="martini" ) @@ -134,11 +111,12 @@ class Meta: message_type = CREDENTIAL_PREVIEW def __init__( - self, - *, - _type: str = None, - attributes: Sequence[CredentialAttrPreview] = None, - **kwargs): + self, + *, + _type: str = None, + attributes: Sequence[CredAttrSpec] = None, + **kwargs + ): """ Initialize credential preview object. @@ -147,13 +125,11 @@ def __init__( attributes (list): list of attribute preview dicts; e.g., [ { "name": "attribute_name", - "mime-type": "text/plain", "value": "value" }, { "name": "icon", "mime-type": "image/png", - "encoding": "base64", "value": "cG90YXRv" } ] @@ -172,26 +148,25 @@ def attr_dict(self, decode: bool = False): Return name:value pair per attribute. Args: - decode: whether first to decode attributes marked as having encoding + decode: whether first to decode attributes with MIME type """ + return { attr.name: base64.b64decode(attr.value.encode()).decode() - if ( - attr.encoding - and attr.encoding.lower() == "base64" - and decode - ) else attr.value + if attr.mime_type and decode else attr.value for attr in self.attributes } - def metadata(self): - """Return per-attribute mapping from name to MIME type and encoding.""" + def mime_types(self): + """ + Return per-attribute mapping from name to MIME type and encoding. + + Return empty dict if no attribute has MIME type. + + """ return { - attr.name: { - **{"mime-type": attr.mime_type for attr in [attr] if attr.mime_type}, - **{"encoding": attr.encoding for attr in [attr] if attr.encoding} - } for attr in self.attributes + attr.name: attr.mime_type for attr in self.attributes if attr.mime_type } @@ -214,7 +189,7 @@ class Meta: ) ) attributes = fields.Nested( - CredentialAttrPreviewSchema, + CredAttrSpecSchema, many=True, required=True, data_key="attributes" diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/tests/test_credential_preview.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/tests/test_credential_preview.py index 4316fccb33..ec70c82bad 100644 --- a/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/tests/test_credential_preview.py +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/inner/tests/test_credential_preview.py @@ -2,7 +2,7 @@ from ....message_types import CREDENTIAL_PREVIEW from ..credential_preview import ( - CredentialAttrPreview, + CredAttrSpec, CredentialPreview, CredentialPreviewSchema ) @@ -10,121 +10,58 @@ CRED_PREVIEW = CredentialPreview( attributes=( - CredentialAttrPreview.list_plain({'test': '123', 'hello': 'world'}) + + CredAttrSpec.list_plain({'test': '123', 'hello': 'world'}) + [ - CredentialAttrPreview( + CredAttrSpec( name='icon', value='cG90YXRv', - encoding='base64', - mime_type='image/png' + mime_type='image/PNG' ) ] ) ) -class TestCredentialAttrPreview(TestCase): +class TestCredAttrSpec(TestCase): """Attribute preview tests""" def test_eq(self): attr_previews_none_plain = [ - CredentialAttrPreview( + CredAttrSpec( name="item", value="value" ), - CredentialAttrPreview( + CredAttrSpec( name="item", value="value", - encoding=None, mime_type=None - ), - CredentialAttrPreview( - name="item", - value="value", - encoding=None, - mime_type="text/plain" - ), - CredentialAttrPreview( - name="item", - value="value", - encoding=None, - mime_type="TEXT/PLAIN" - ) - ] - attr_previews_b64_plain = [ - CredentialAttrPreview( - name="item", - value="dmFsdWU=", - encoding="base64" - ), - CredentialAttrPreview( - name="item", - value="dmFsdWU=", - encoding="base64", - mime_type=None - ), - CredentialAttrPreview( - name="item", - value="dmFsdWU=", - encoding="base64", - mime_type="text/plain" - ), - CredentialAttrPreview( - name="item", - value="dmFsdWU=", - encoding="BASE64", - mime_type="text/plain" - ), - CredentialAttrPreview( - name="item", - value="dmFsdWU=", - encoding="base64", - mime_type="TEXT/PLAIN" ) ] attr_previews_different = [ - CredentialAttrPreview( + CredAttrSpec( name="item", value="dmFsdWU=", - encoding="base64", mime_type="image/png" ), - CredentialAttrPreview( + CredAttrSpec( name="item", - value="distinct value", - mime_type=None + value="distinct value" ), - CredentialAttrPreview( + CredAttrSpec( name="distinct_name", value="distinct value", mime_type=None - ), - CredentialAttrPreview( - name="item", - value="xyzzy" ) ] - for lhs in attr_previews_none_plain: - for rhs in attr_previews_b64_plain: - assert lhs == rhs # values decode to same - for lhs in attr_previews_none_plain: for rhs in attr_previews_different: assert lhs != rhs - for lhs in attr_previews_b64_plain: - for rhs in attr_previews_different: - assert lhs != rhs - for lidx in range(len(attr_previews_none_plain) - 1): for ridx in range(lidx + 1, len(attr_previews_none_plain)): assert attr_previews_none_plain[lidx] == attr_previews_none_plain[ridx] - for lidx in range(len(attr_previews_b64_plain) - 1): - for ridx in range(lidx + 1, len(attr_previews_b64_plain)): - assert attr_previews_b64_plain[lidx] == attr_previews_b64_plain[ridx] - for lidx in range(len(attr_previews_different) - 1): for ridx in range(lidx + 1, len(attr_previews_different)): assert attr_previews_different[lidx] != attr_previews_different[ridx] @@ -153,15 +90,8 @@ def test_preview(self): 'hello': 'world', 'icon': 'potato' } - assert CRED_PREVIEW.metadata() == { - 'test': { - }, - 'hello': { - }, - 'icon': { - 'mime-type': 'image/png', - 'encoding': 'base64' - } + assert CRED_PREVIEW.mime_types() == { + 'icon': 'image/png' # canonicalize to lower case } def test_deserialize(self): @@ -171,13 +101,11 @@ def test_deserialize(self): 'attributes': [ { 'name': 'name', - 'mime-type': 'text/plain', 'value': 'Alexander Delarge' }, { 'name': 'pic', 'mime-type': 'image/png', - 'encoding': 'base64', 'value': 'Abcd0123...' } ] @@ -204,7 +132,6 @@ def test_serialize(self): { "name": "icon", "mime-type": "image/png", - "encoding": "base64", "value": "cG90YXRv" } ] diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_issue.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_issue.py index 8f3e277721..7e02b911b6 100644 --- a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_issue.py +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_issue.py @@ -100,13 +100,13 @@ def test_type(self): @mock.patch( "aries_cloudagent.messaging.issue_credential.v1_0.messages." - + "credential_issue.CredentialIssueSchema.load" + "credential_issue.CredentialIssueSchema.load" ) def test_deserialize(self, mock_credential_issue_schema_load): """ Test deserialize """ - obj = self.indy_cred + obj = self.cred_issue credential_issue = CredentialIssue.deserialize(obj) mock_credential_issue_schema_load.assert_called_once_with(obj) @@ -115,25 +115,22 @@ def test_deserialize(self, mock_credential_issue_schema_load): @mock.patch( "aries_cloudagent.messaging.issue_credential.v1_0.messages." - + "credential_issue.CredentialIssueSchema.dump" + "credential_issue.CredentialIssueSchema.dump" ) def test_serialize(self, mock_credential_issue_schema_dump): """ Test serialization. """ - credential_issue = CredentialIssue( - comment="Test", - credentials_attach=[AttachDecorator.from_indy_dict(self.indy_cred)] - ) + obj = self.cred_issue - credential_issue_dict = credential_issue.serialize() - mock_credential_issue_schema_dump.assert_called_once_with(credential_issue) + credential_issue_dict = obj.serialize() + mock_credential_issue_schema_dump.assert_called_once_with(obj) assert credential_issue_dict is mock_credential_issue_schema_dump.return_value class TestCredentialIssueSchema(TestCase): - """Test credential cred request schema""" + """Test credential cred issue schema""" credential_issue = CredentialIssue( comment="Test", diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_offer.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_offer.py index 33a766e18d..eebc7730f7 100644 --- a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_offer.py +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_offer.py @@ -1,7 +1,7 @@ from .....decorators.attach_decorator import AttachDecorator from ...message_types import CREDENTIAL_OFFER from ..credential_offer import CredentialOffer -from ..inner.credential_preview import CredentialAttrPreview, CredentialPreview +from ..inner.credential_preview import CredAttrSpec, CredentialPreview from unittest import mock, TestCase @@ -33,7 +33,7 @@ class TestCredentialOffer(TestCase): } } preview = CredentialPreview( - attributes=CredentialAttrPreview.list_plain( + attributes=CredAttrSpec.list_plain( {'member': 'James Bond', 'favourite': 'martini'} ) ) @@ -66,7 +66,7 @@ def test_type(self): @mock.patch( "aries_cloudagent.messaging.issue_credential.v1_0.messages." - + "credential_offer.CredentialOfferSchema.load" + "credential_offer.CredentialOfferSchema.load" ) def test_deserialize(self, mock_credential_offer_schema_load): """ @@ -81,7 +81,7 @@ def test_deserialize(self, mock_credential_offer_schema_load): @mock.patch( "aries_cloudagent.messaging.issue_credential.v1_0.messages." - + "credential_offer.CredentialOfferSchema.dump" + "credential_offer.CredentialOfferSchema.dump" ) def test_serialize(self, mock_credential_offer_schema_dump): """ diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_proposal.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_proposal.py index f40cc40138..9de4345cb9 100644 --- a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_proposal.py +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_proposal.py @@ -1,5 +1,5 @@ from ..credential_proposal import CredentialProposal -from ..inner.credential_preview import CredentialAttrPreview, CredentialPreview +from ..inner.credential_preview import CredAttrSpec, CredentialPreview from ...message_types import CREDENTIAL_PREVIEW, CREDENTIAL_PROPOSAL from unittest import TestCase @@ -7,12 +7,11 @@ CRED_PREVIEW = CredentialPreview( attributes=( - CredentialAttrPreview.list_plain({"test": "123", "hello": "world"}) + + CredAttrSpec.list_plain({"test": "123", "hello": "world"}) + [ - CredentialAttrPreview( + CredAttrSpec( name="icon", value="cG90YXRv", - encoding="base64", mime_type="image/png" ) ] @@ -28,7 +27,7 @@ def test_init(self): credential_proposal = CredentialProposal( comment="Hello World", credential_proposal=CRED_PREVIEW, - schema_id="GMm4vMw8LLrLJjp81kRRLp:2:tails_load:1560364003.0", + schema_id="GMm4vMw8LLrLJjp81kRRLp:2:ahoy:1560364003.0", cred_def_id="GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" ) assert credential_proposal.credential_proposal == CRED_PREVIEW @@ -38,7 +37,7 @@ def test_type(self): credential_proposal = CredentialProposal( comment="Hello World", credential_proposal=CRED_PREVIEW, - schema_id="GMm4vMw8LLrLJjp81kRRLp:2:tails_load:1560364003.0", + schema_id="GMm4vMw8LLrLJjp81kRRLp:2:ahoy:1560364003.0", cred_def_id="GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" ) @@ -58,12 +57,11 @@ def test_deserialize(self): { "name": "pic", "mime-type": "image/png", - "encoding": "base64", "value": "Abcd0123..." } ] }, - "schema_id": "GMm4vMw8LLrLJjp81kRRLp:2:tails_load:1560364003.0", + "schema_id": "GMm4vMw8LLrLJjp81kRRLp:2:ahoy:1560364003.0", "cred_def_id": "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" } @@ -76,7 +74,7 @@ def test_serialize(self): cred_proposal = CredentialProposal( comment="Hello World", credential_proposal=CRED_PREVIEW, - schema_id="GMm4vMw8LLrLJjp81kRRLp:2:tails_load:1560364003.0", + schema_id="GMm4vMw8LLrLJjp81kRRLp:2:ahoy:1560364003.0", cred_def_id="GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" ) @@ -100,12 +98,11 @@ def test_serialize(self): { "name": "icon", "mime-type": "image/png", - "encoding": "base64", "value": "cG90YXRv" } ] }, - "schema_id": "GMm4vMw8LLrLJjp81kRRLp:2:tails_load:1560364003.0", + "schema_id": "GMm4vMw8LLrLJjp81kRRLp:2:ahoy:1560364003.0", "cred_def_id": "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" } @@ -116,7 +113,7 @@ class TestCredentialProposalSchema(TestCase): credential_proposal = CredentialProposal( comment="Hello World", credential_proposal=CRED_PREVIEW, - schema_id="GMm4vMw8LLrLJjp81kRRLp:2:tails_load:1560364003.0", + schema_id="GMm4vMw8LLrLJjp81kRRLp:2:ahoy:1560364003.0", cred_def_id="GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" ) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_request.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_request.py index 33fdafd962..3ae35fb0a1 100644 --- a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_request.py +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_request.py @@ -55,7 +55,7 @@ def test_type(self): @mock.patch( "aries_cloudagent.messaging.issue_credential.v1_0.messages." - + "credential_request.CredentialRequestSchema.load" + "credential_request.CredentialRequestSchema.load" ) def test_deserialize(self, mock_credential_request_schema_load): """ @@ -70,7 +70,7 @@ def test_deserialize(self, mock_credential_request_schema_load): @mock.patch( "aries_cloudagent.messaging.issue_credential.v1_0.messages." - + "credential_request.CredentialRequestSchema.dump" + "credential_request.CredentialRequestSchema.dump" ) def test_serialize(self, mock_credential_request_schema_dump): """ diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_stored.py b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_stored.py new file mode 100644 index 0000000000..f8ad715669 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/messages/tests/test_credential_stored.py @@ -0,0 +1,60 @@ +from ...message_types import CREDENTIAL_STORED +from ..credential_stored import CredentialStored + +from unittest import mock, TestCase + + +class TestCredentialStored(TestCase): + """Credential stored tests""" + + def test_init(self): + """Test initializer""" + credential_stored = CredentialStored() + + def test_type(self): + """Test type""" + credential_stored = CredentialStored() + + assert credential_stored._type == CREDENTIAL_STORED + + @mock.patch( + "aries_cloudagent.messaging.issue_credential.v1_0.messages." + "credential_stored.CredentialStoredSchema.load" + ) + def test_deserialize(self, mock_credential_stored_schema_load): + """ + Test deserialize + """ + obj = CredentialStored() + + credential_stored = CredentialStored.deserialize(obj) + mock_credential_stored_schema_load.assert_called_once_with(obj) + + assert credential_stored is mock_credential_stored_schema_load.return_value + + @mock.patch( + "aries_cloudagent.messaging.issue_credential.v1_0.messages." + "credential_stored.CredentialStoredSchema.dump" + ) + def test_serialize(self, mock_credential_stored_schema_dump): + """ + Test serialization. + """ + obj = CredentialStored() + + credential_stored_dict = obj.serialize() + mock_credential_stored_schema_dump.assert_called_once_with(obj) + + assert credential_stored_dict is mock_credential_stored_schema_dump.return_value + + +class TestCredentialStoredSchema(TestCase): + """Test credential cred stored schema""" + + credential_stored = CredentialStored() + + def test_make_model(self): + """Test making model.""" + data = self.credential_stored.serialize() + model_instance = CredentialStored.deserialize(data) + assert isinstance(model_instance, CredentialStored) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/models/credential_exchange.py b/aries_cloudagent/messaging/issue_credential/v1_0/models/credential_exchange.py index 65c0d3ca27..e05624ec3f 100644 --- a/aries_cloudagent/messaging/issue_credential/v1_0/models/credential_exchange.py +++ b/aries_cloudagent/messaging/issue_credential/v1_0/models/credential_exchange.py @@ -15,7 +15,7 @@ class Meta: RECORD_TYPE = "v10_credential_exchange" RECORD_ID_NAME = "credential_exchange_id" - WEBHOOK_TOPIC = "Aries#0036 v1.0 credentials" + WEBHOOK_TOPIC = "aries36_v10_credentials" INITIATOR_SELF = "self" INITIATOR_EXTERNAL = "external" @@ -27,6 +27,7 @@ class Meta: STATE_REQUEST_SENT = "request_sent" STATE_REQUEST_RECEIVED = "request_received" STATE_ISSUED = "issued" + STATE_CREDENTIAL_RECEIVED = "credential_received" STATE_STORED = "stored" def __init__( @@ -45,7 +46,8 @@ def __init__( credential_request: dict = None, # indy credential request credential_request_metadata: dict = None, credential_id: str = None, - credential: dict = None, # indy credential + raw_credential: dict = None, # indy credential as received + credential: dict = None, # indy credential as stored auto_offer: bool = False, auto_issue: bool = False, error_msg: str = None, @@ -66,6 +68,7 @@ def __init__( self.credential_request = credential_request self.credential_request_metadata = credential_request_metadata self.credential_id = credential_id + self.raw_credential = raw_credential self.credential = credential self.auto_offer = auto_offer self.auto_issue = auto_issue @@ -88,6 +91,7 @@ def record_value(self) -> dict: "error_msg", "auto_offer", "auto_issue", + "raw_credential", "credential", "parent_thread_id" ): @@ -136,6 +140,7 @@ class Meta: credential_request = fields.Dict(required=False) credential_request_metadata = fields.Dict(required=False) credential_id = fields.Str(required=False) + raw_credential = fields.Dict(required=False) credential = fields.Dict(required=False) auto_offer = fields.Bool(required=False) auto_issue = fields.Bool(required=False) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/routes.py b/aries_cloudagent/messaging/issue_credential/v1_0/routes.py index c91aa9aa49..ba9f74503f 100644 --- a/aries_cloudagent/messaging/issue_credential/v1_0/routes.py +++ b/aries_cloudagent/messaging/issue_credential/v1_0/routes.py @@ -1,11 +1,14 @@ """Connection handling admin routes.""" +import asyncio + from aiohttp import web from aiohttp_apispec import docs, request_schema, response_schema from marshmallow import fields, Schema from ....holder.base import BaseHolder from ....storage.error import StorageNotFoundError +from ....messaging.problem_report.message import ProblemReport from ...connections.models.connection_record import ConnectionRecord from ...valid import INDY_CRED_DEF_ID @@ -13,7 +16,7 @@ from .manager import CredentialManager from .messages.credential_proposal import CredentialProposal from .messages.inner.credential_preview import ( - CredentialAttrPreview, + CredAttrSpec, CredentialPreview, CredentialPreviewSchema ) @@ -23,8 +26,21 @@ ) +class V10AttributeMimeTypesResultSchema(Schema): + """Result schema for credential attribute MIME types by credential definition.""" + + +class V10CredentialExchangeListResultSchema(Schema): + """Result schema for Aries#0036 v1.0 credential exchange query.""" + + results = fields.List( + fields.Nested(V10CredentialExchangeSchema), + description="Aries#0036 v1.0 credential exchange records" + ) + + class V10CredentialProposalRequestSchema(Schema): - """Request schema for sending a credential proposal admin message.""" + """Request schema for sending credential proposal admin message.""" connection_id = fields.UUID(description="Connection identifier", required=True) credential_definition_id = fields.Str( @@ -39,12 +55,8 @@ class V10CredentialProposalRequestSchema(Schema): credential_proposal = fields.Nested(CredentialPreviewSchema, required=True) -class V10CredentialProposalResultSchema(V10CredentialExchangeSchema): - """Result schema for sending a credential proposal admin message.""" - - class V10CredentialOfferRequestSchema(Schema): - """Request schema for sending a credential offer admin message.""" + """Request schema for sending credential offer admin message.""" connection_id = fields.UUID(description="Connection identifier", required=True) credential_definition_id = fields.Str( @@ -67,78 +79,50 @@ class V10CredentialOfferRequestSchema(Schema): credential_preview = fields.Nested(CredentialPreviewSchema, required=True) -class V10CredentialOfferResultSchema(V10CredentialExchangeSchema): - """Result schema for sending a credential offer admin message.""" - - -class V10CredentialRequestResultSchema(Schema): - """Result schema for sending a credential request admin message.""" - - credential_id = fields.Str() - - class V10CredentialIssueRequestSchema(Schema): - """Request schema for sending a credential issue admin message.""" + """Request schema for sending credential issue admin message.""" - comments = fields.Str( + comment = fields.Str( description="Human-readable comment", required=False ) credential_preview = fields.Nested(CredentialPreviewSchema, required=True) -class V10CredentialIssueResultSchema(Schema): - """Result schema for sending a credential issue admin message.""" - - credential_exchange = fields.Nested(V10CredentialExchangeSchema) - - -class V10CredentialExchangeListSchema(Schema): - """Result schema for an Aries#0036 v1.0 credential exchange query.""" +class V10CredentialProblemReportRequestSchema(Schema): + """Request schema for sending problem report.""" - results = fields.List( - fields.Nested(V10CredentialExchangeSchema), - description="Aries#0036 v1.0 credential exchange records" - ) - - -class V10AttributeMetadataResultSchema(Schema): - """Result schema for credential attribute metadatas by credential definition.""" - - # properties undefined + explain_ltxt = fields.Str(required=True) @docs( - tags=["*EXPERIMENTAL* Aries#0036 v1.0 credentials"], - summary="Get attribute metadata from wallet" + tags=["*EXPERIMENTAL* aries#0036 v1.0 credentials"], + summary="Get attribute MIME types from wallet" ) -@response_schema(V10AttributeMetadataResultSchema(), 200) -async def attribute_metadata_get(request: web.BaseRequest): +@response_schema(V10AttributeMimeTypesResultSchema(), 200) +async def attribute_mime_types_get(request: web.BaseRequest): """ - Request handler for getting credential attribute metadata. + Request handler for getting credential attribute MIME types. Args: request: aiohttp request object Returns: - The metadata response + The MIME types response """ context = request.app["request_context"] - credential_id = request.match_info["credential_id"] - holder: BaseHolder = await context.inject(BaseHolder) - metadata = await holder.get_metadata(credential_id) - return web.json_response(metadata) + return web.json_response(await holder.get_mime_type(credential_id)) @docs( - tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + tags=["*EXPERIMENTAL* aries#0036 v1.0 issue-credential exchange"], summary="Fetch all credential exchange records" ) -@response_schema(V10CredentialExchangeListSchema(), 200) +@response_schema(V10CredentialExchangeListResultSchema(), 200) async def credential_exchange_list(request: web.BaseRequest): """ Request handler for searching connection records. @@ -166,19 +150,19 @@ async def credential_exchange_list(request: web.BaseRequest): @docs( - tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + tags=["*EXPERIMENTAL* aries#0036 v1.0 issue-credential exchange"], summary="Fetch a single credential exchange record" ) @response_schema(V10CredentialExchangeSchema(), 200) async def credential_exchange_retrieve(request: web.BaseRequest): """ - Request handler for fetching a single connection record. + Request handler for fetching single connection record. Args: request: aiohttp request object Returns: - The credential exchange record response + The credential exchange record """ context = request.app["request_context"] @@ -189,28 +173,100 @@ async def credential_exchange_retrieve(request: web.BaseRequest): credential_exchange_id ) except StorageNotFoundError: - return web.HTTPNotFound() + raise web.HTTPNotFound() return web.json_response(record.serialize()) @docs( - tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + tags=["*EXPERIMENTAL* aries#0036 v1.0 issue-credential exchange"], + summary="Send credential, automating entire flow" +) +@request_schema(V10CredentialProposalRequestSchema()) +@response_schema(V10CredentialExchangeSchema(), 200) +async def credential_exchange_send(request: web.BaseRequest): + """ + Request handler for sending credential from issuer to holder from attr values. + + If both issuer and holder are configured for automatic responses, the operation + ultimately results in credential issue; otherwise, the result waits on the first + response not automated; the credential exchange record retains state regardless. + + Args: + request: aiohttp request object + + Returns: + The credential exchange record + + """ + context = request.app["request_context"] + outbound_handler = request.app["outbound_message_router"] + + body = await request.json() + + connection_id = body.get("connection_id") + credential_definition_id = body.get("credential_definition_id") + comment = body.get("comment") + credential_proposal = CredentialProposal( + comment=comment, + credential_proposal=CredentialPreview( + attributes=[ + CredAttrSpec( + name=attr_preview['name'], + mime_type=attr_preview.get('mime-type', None), + value=attr_preview['value'] + ) for attr_preview in body.get("credential_proposal")['attributes'] + ] + ), + cred_def_id=credential_definition_id + ) + + if not credential_proposal: + raise web.HTTPBadRequest( + reason="credential_proposal must be provided with attribute values." + ) + + credential_manager = CredentialManager(context) + + try: + connection_record = await ConnectionRecord.retrieve_by_id( + context, + connection_id + ) + except StorageNotFoundError: + raise web.HTTPBadRequest() + + if not connection_record.is_ready: + raise web.HTTPForbidden() + + credential_exchange_record = await credential_manager.prepare_send( + credential_definition_id, + connection_id, + credential_proposal=credential_proposal + ) + asyncio.ensure_future( + credential_manager.perform_send(credential_exchange_record, outbound_handler) + ) + + return web.json_response(credential_exchange_record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* aries#0036 v1.0 issue-credential exchange"], summary="Send issuer a credential proposal" ) @request_schema(V10CredentialProposalRequestSchema()) -@response_schema(V10CredentialProposalResultSchema(), 200) +@response_schema(V10CredentialExchangeSchema(), 200) async def credential_exchange_send_proposal(request: web.BaseRequest): """ - Request handler for sending a credential proposal. + Request handler for sending credential proposal. Args: request: aiohttp request object Returns: - The credential proposal details + The credential exchange record """ - context = request.app["request_context"] outbound_handler = request.app["outbound_message_router"] @@ -221,10 +277,9 @@ async def credential_exchange_send_proposal(request: web.BaseRequest): comment = body.get("comment") credential_preview = CredentialPreview( attributes=[ - CredentialAttrPreview( + CredAttrSpec( name=attr_preview['name'], mime_type=attr_preview.get('mime-type', None), - encoding=attr_preview.get('encoding', None), value=attr_preview['value'] ) for attr_preview in body.get("credential_proposal")['attributes'] ] @@ -246,7 +301,7 @@ async def credential_exchange_send_proposal(request: web.BaseRequest): raise web.HTTPBadRequest() if not connection_record.is_ready: - return web.HTTPForbidden() + raise web.HTTPForbidden() credential_exchange_record = await credential_manager.create_proposal( connection_id, @@ -266,14 +321,14 @@ async def credential_exchange_send_proposal(request: web.BaseRequest): @docs( - tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + tags=["*EXPERIMENTAL* aries#0036 v1.0 issue-credential exchange"], summary="Send holder a credential offer, free from reference to any proposal" ) @request_schema(V10CredentialOfferRequestSchema()) -@response_schema(V10CredentialOfferResultSchema(), 200) +@response_schema(V10CredentialExchangeSchema(), 200) async def credential_exchange_send_free_offer(request: web.BaseRequest): """ - Request handler for sending a free credential offer. + Request handler for sending free credential offer. An issuer initiates a such a credential offer, which is free any holder-initiated corresponding proposal. @@ -282,7 +337,7 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): request: aiohttp request object Returns: - The credential offer details + The credential exchange record """ @@ -300,10 +355,9 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): comment = body.get("comment", None) credential_preview = CredentialPreview( attributes=[ - CredentialAttrPreview( + CredAttrSpec( name=attr_preview['name'], value=attr_preview['value'], - encoding=attr_preview.get('encoding', None), mime_type=attr_preview.get('mime_type', None) ) for attr_preview in body.get("credential_preview")["attributes"] ] @@ -331,14 +385,15 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): raise web.HTTPBadRequest() if not connection_record.is_ready: - return web.HTTPForbidden() + raise web.HTTPForbidden() credential_exchange_record = V10CredentialExchange( connection_id=connection_id, initiator=V10CredentialExchange.INITIATOR_SELF, credential_definition_id=credential_definition_id, credential_proposal_dict=credential_proposal.serialize(), - auto_issue=auto_issue) + auto_issue=auto_issue + ) ( credential_exchange_record, @@ -354,19 +409,22 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): @docs( - tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + tags=["*EXPERIMENTAL* aries#0036 v1.0 issue-credential exchange"], summary="Send holder a credential offer in reference to a proposal" ) -@response_schema(V10CredentialOfferResultSchema(), 200) +@response_schema(V10CredentialExchangeSchema(), 200) async def credential_exchange_send_bound_offer(request: web.BaseRequest): """ - Request handler for sending a credential offer to proposal. + Request handler for sending bound credential offer. + + A holder initiates this sequence with a credential proposal; this message + responds with an offer bound to the proposal. Args: request: aiohttp request object Returns: - The credential exchange details with credential offer + The credential exchange record """ @@ -392,7 +450,7 @@ async def credential_exchange_send_bound_offer(request: web.BaseRequest): raise web.HTTPBadRequest() if not connection_record.is_ready: - return web.HTTPForbidden() + raise web.HTTPForbidden() credential_manager = CredentialManager(context) @@ -410,22 +468,21 @@ async def credential_exchange_send_bound_offer(request: web.BaseRequest): @docs( - tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + tags=["*EXPERIMENTAL* aries#0036 v1.0 issue-credential exchange"], summary="Send a credential request" ) -@response_schema(V10CredentialRequestResultSchema(), 200) +@response_schema(V10CredentialExchangeSchema(), 200) async def credential_exchange_send_request(request: web.BaseRequest): """ - Request handler for sending a credential request. + Request handler for sending credential request. Args: request: aiohttp request object Returns: - The credential request details + The credential exchange record """ - context = request.app["request_context"] outbound_handler = request.app["outbound_message_router"] @@ -451,7 +508,7 @@ async def credential_exchange_send_request(request: web.BaseRequest): raise web.HTTPBadRequest() if not connection_record.is_ready: - return web.HTTPForbidden() + raise web.HTTPForbidden() ( credential_exchange_record, @@ -466,20 +523,20 @@ async def credential_exchange_send_request(request: web.BaseRequest): @docs( - tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + tags=["*EXPERIMENTAL* aries#0036 v1.0 issue-credential exchange"], summary="Send a credential" ) @request_schema(V10CredentialIssueRequestSchema()) -@response_schema(V10CredentialIssueResultSchema(), 200) +@response_schema(V10CredentialExchangeSchema(), 200) async def credential_exchange_issue(request: web.BaseRequest): """ - Request handler for sending a credential. + Request handler for sending credential. Args: request: aiohttp request object Returns: - The credential details. + The credential exchange record """ context = request.app["request_context"] @@ -491,7 +548,8 @@ async def credential_exchange_issue(request: web.BaseRequest): credential_exchange_id = request.match_info["cred_ex_id"] cred_exch_record = await V10CredentialExchange.retrieve_by_id( - context, credential_exchange_id + context, + credential_exchange_id ) connection_id = cred_exch_record.connection_id @@ -508,7 +566,7 @@ async def credential_exchange_issue(request: web.BaseRequest): raise web.HTTPBadRequest() if not connection_record.is_ready: - return web.HTTPForbidden() + raise web.HTTPForbidden() ( cred_exch_record, @@ -524,7 +582,98 @@ async def credential_exchange_issue(request: web.BaseRequest): @docs( - tags=["*EXPERIMENTAL* Aries#0036 v1.0 issue-credential exchange"], + tags=["*EXPERIMENTAL* aries#0036 v1.0 issue-credential exchange"], + summary="Stored a received credential" +) +@response_schema(V10CredentialExchangeSchema(), 200) +async def credential_exchange_store(request: web.BaseRequest): + """ + Request handler for storing credential. + + Args: + request: aiohttp request object + + Returns: + The credential exchange record + + """ + context = request.app["request_context"] + outbound_handler = request.app["outbound_message_router"] + + credential_exchange_id = request.match_info["cred_ex_id"] + credential_exchange_record = await V10CredentialExchange.retrieve_by_id( + context, + credential_exchange_id + ) + connection_id = credential_exchange_record.connection_id + + assert ( + credential_exchange_record.state == ( + V10CredentialExchange.STATE_CREDENTIAL_RECEIVED + ) + ) + + credential_manager = CredentialManager(context) + + try: + connection_record = await ConnectionRecord.retrieve_by_id( + context, + connection_id + ) + except StorageNotFoundError: + raise web.HTTPBadRequest() + + if not connection_record.is_ready: + raise web.HTTPForbidden() + + ( + credential_exchange_record, + credential_stored_message, + ) = await credential_manager.store_credential(credential_exchange_record) + + await outbound_handler(credential_stored_message, connection_id=connection_id) + return web.json_response(credential_exchange_record.serialize()) + + +@docs( + tags=["*EXPERIMENTAL* aries#0036 v1.0 issue-credential exchange"], + summary="Send a problem report for credential exchange", +) +@request_schema(V10CredentialProblemReportRequestSchema()) +async def credential_exchange_problem_report(request: web.BaseRequest): + """ + Request handler for sending problem report. + + Args: + request: aiohttp request object + + """ + context = request.app["request_context"] + outbound_handler = request.app["outbound_message_router"] + + credential_exchange_id = request.match_info["cred_ex_id"] + body = await request.json() + + try: + credential_exchange_record = await V10CredentialExchange.retrieve_by_id( + context, + credential_exchange_id + ) + except StorageNotFoundError: + raise web.HTTPNotFound() + + error_result = ProblemReport(explain_ltxt=body["explain_ltxt"]) + error_result.assign_thread_id(credential_exchange_record.thread_id) + + await outbound_handler( + error_result, + connection_id=credential_exchange_record.connection_id + ) + return web.json_response({}) + + +@docs( + tags=["*EXPERIMENTAL* aries#0036 v1.0 issue-credential exchange"], summary="Remove an existing credential exchange record", ) async def credential_exchange_remove(request: web.BaseRequest): @@ -533,6 +682,7 @@ async def credential_exchange_remove(request: web.BaseRequest): Args: request: aiohttp request object + """ context = request.app["request_context"] credential_exchange_id = request.match_info["cred_ex_id"] @@ -543,7 +693,7 @@ async def credential_exchange_remove(request: web.BaseRequest): credential_exchange_id ) except StorageNotFoundError: - return web.HTTPNotFound() + raise web.HTTPNotFound() await credential_exchange_record.delete_record(context) return web.json_response({}) @@ -554,36 +704,51 @@ async def register(app: web.Application): app.add_routes( [ web.get( - "/v1.0/credential_metadata/{credential_id}", - attribute_metadata_get + "/aries0036/v1.0/mime_types/{credential_id}", + attribute_mime_types_get ), - web.get("/v1.0/issue_credential_exchange", credential_exchange_list), web.get( - "/v1.0/issue_credential_exchange/{cred_ex_id}", + "/aries0036/v1.0/credential_exchange", + credential_exchange_list + ), + web.get( + "/aries0036/v1.0/issue_credential/{cred_ex_id}", credential_exchange_retrieve ), web.post( - "/v1.0/issue_credential_exchange/send_proposal", + "/aries0036/v1.0/issue_credential/send", + credential_exchange_send + ), + web.post( + "/aries0036/v1.0/issue_credential/send_proposal", credential_exchange_send_proposal ), web.post( - "/v1.0/issue_credential_exchange/send_offer", + "/aries0036/v1.0/issue_credential/send_offer", credential_exchange_send_free_offer ), web.post( - "/v1.0/issue_credential_exchange/{cred_ex_id}/send_offer", + "/aries0036/v1.0/issue_credential/{cred_ex_id}/send_offer", credential_exchange_send_bound_offer ), web.post( - "/v1.0/issue_credential_exchange/{cred_ex_id}/send_request", + "/aries0036/v1.0/issue_credential/{cred_ex_id}/send_request", credential_exchange_send_request ), web.post( - "/v1.0/issue_credential_exchange/{cred_ex_id}/issue", + "/aries0036/v1.0/issue_credential/{cred_ex_id}/issue", credential_exchange_issue ), web.post( - "/v1.0/issue_credential_exchange/{cred_ex_id}/remove", + "/aries0036/v1.0/issue_credential/{cred_ex_id}/store", + credential_exchange_store + ), + web.post( + "/aries0036/v1.0/issue_credential/{cred_ex_id}/problem_report", + credential_exchange_problem_report + ), + web.post( + "/aries0036/v1.0/issue_credential/{cred_ex_id}/remove", credential_exchange_remove ) ] diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/util/__init__.py b/aries_cloudagent/messaging/issue_credential/v1_0/tests/__init__.py similarity index 100% rename from aries_cloudagent/messaging/present_proof/v1_0/messages/util/__init__.py rename to aries_cloudagent/messaging/issue_credential/v1_0/tests/__init__.py diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/tests/test_routes.py b/aries_cloudagent/messaging/issue_credential/v1_0/tests/test_routes.py new file mode 100644 index 0000000000..a1f646ede6 --- /dev/null +++ b/aries_cloudagent/messaging/issue_credential/v1_0/tests/test_routes.py @@ -0,0 +1,1035 @@ +from asynctest import TestCase as AsyncTestCase +from asynctest import mock as async_mock + +from aiohttp import web as aio_web +from .. import routes as test_module + +from .....storage.error import StorageNotFoundError + + +class TestCredentialRoutes(AsyncTestCase): + async def test_credential_exchange_send(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_credential_manager: + test_module.web.json_response = async_mock.CoroutineMock() + + mock_credential_manager.return_value.create_offer = ( + async_mock.CoroutineMock() + ) + + mock_cred_ex_record = async_mock.MagicMock() + + mock_credential_manager.return_value.create_offer.return_value = ( + mock_cred_ex_record, + async_mock.MagicMock(), + ) + + await test_module.credential_exchange_send(mock) + + test_module.web.json_response.assert_called_once_with( + mock_credential_manager.return_value.prepare_send.return_value.serialize.return_value + ) + + async def test_credential_exchange_send_no_conn_record(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager: + + test_module.web.json_response = async_mock.CoroutineMock() + + # Emulate storage not found (bad connection id) + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock( + side_effect=StorageNotFoundError + ) + + mock_connection_manager.return_value.create_offer = ( + async_mock.CoroutineMock() + ) + mock_connection_manager.return_value.create_offer.return_value = ( + async_mock.MagicMock(), + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPBadRequest): + await test_module.credential_exchange_send(mock) + + async def test_credential_exchange_send_not_ready(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager: + + test_module.web.json_response = async_mock.CoroutineMock() + + # Emulate connection not ready + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock() + mock_connection_record.retrieve_by_id.return_value.is_ready = False + + mock_connection_manager.return_value.create_offer = ( + async_mock.CoroutineMock() + ) + mock_connection_manager.return_value.create_offer.return_value = ( + async_mock.MagicMock(), + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPForbidden): + await test_module.credential_exchange_send(mock) + + async def test_credential_exchange_send_proposal(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "CredentialProposal", + autospec=True + ) as mock_cred_proposal: + + test_module.web.json_response = async_mock.CoroutineMock() + + mock_connection_manager.return_value.create_proposal = ( + async_mock.CoroutineMock() + ) + + mock_cred_ex_record = async_mock.MagicMock() + + mock_connection_manager.return_value.create_proposal.return_value = ( + mock_cred_ex_record + ) + + mock_cred_proposal.return_value.deserialize.return_value = ( + async_mock.MagicMock() + ) + + await test_module.credential_exchange_send_proposal(mock) + + test_module.web.json_response.assert_called_once_with( + mock_cred_ex_record.serialize.return_value + ) + + async def test_credential_exchange_send_proposal_no_conn_record(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager: + + test_module.web.json_response = async_mock.CoroutineMock() + + # Emulate storage not found (bad connection id) + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock( + side_effect=StorageNotFoundError + ) + + mock_connection_manager.return_value.create_proposal = ( + async_mock.CoroutineMock() + ) + mock_connection_manager.return_value.create_proposal.return_value = ( + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPBadRequest): + await test_module.credential_exchange_send_proposal(mock) + + async def test_credential_exchange_send_proposal_not_ready(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager: + + test_module.web.json_response = async_mock.CoroutineMock() + + # Emulate connection not ready + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock() + mock_connection_record.retrieve_by_id.return_value.is_ready = False + + mock_connection_manager.return_value.create_proposal = ( + async_mock.CoroutineMock() + ) + mock_connection_manager.return_value.create_proposal.return_value = ( + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPForbidden): + await test_module.credential_exchange_send_proposal(mock) + + async def test_credential_exchange_send_free_offer(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + mock.json.return_value["auto_issue"] = True + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": async_mock.patch.object( + aio_web, + "BaseRequest", + autospec=True + ) + } + mock.app["request_context"].settings = {} + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager: + + test_module.web.json_response = async_mock.CoroutineMock() + + mock_connection_manager.return_value.create_offer = ( + async_mock.CoroutineMock() + ) + + mock_cred_ex_record = async_mock.MagicMock() + + mock_connection_manager.return_value.create_offer.return_value = ( + mock_cred_ex_record, + async_mock.MagicMock() + ) + + await test_module.credential_exchange_send_free_offer(mock) + + test_module.web.json_response.assert_called_once_with( + mock_cred_ex_record.serialize.return_value + ) + + async def test_credential_exchange_send_free_offer_no_conn_record(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + mock.json.return_value["auto_issue"] = True + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": async_mock.patch.object( + aio_web, + "BaseRequest", + autospec=True + ) + } + mock.app["request_context"].settings = {} + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager: + + test_module.web.json_response = async_mock.CoroutineMock() + + mock_connection_manager.return_value.create_offer = ( + async_mock.CoroutineMock() + ) + + mock_cred_ex_record = async_mock.MagicMock() + + mock_connection_manager.return_value.create_offer.return_value = ( + mock_cred_ex_record, + async_mock.MagicMock() + ) + + await test_module.credential_exchange_send_free_offer(mock) + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager: + + test_module.web.json_response = async_mock.CoroutineMock() + + # Emulate storage not found (bad connection id) + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock( + side_effect=StorageNotFoundError + ) + + mock_connection_manager.return_value.create_offer = ( + async_mock.CoroutineMock() + ) + mock_connection_manager.return_value.create_offer.return_value = ( + async_mock.MagicMock(), + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPBadRequest): + await test_module.credential_exchange_send_free_offer(mock) + + async def test_credential_exchange_send_free_offer_not_ready(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + mock.json.return_value["auto_issue"] = True + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": async_mock.patch.object( + aio_web, + "BaseRequest", + autospec=True + ) + } + mock.app["request_context"].settings = {} + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager: + + test_module.web.json_response = async_mock.CoroutineMock() + + # Emulate connection not ready + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock() + mock_connection_record.retrieve_by_id.return_value.is_ready = False + + mock_connection_manager.return_value.create_offer = ( + async_mock.CoroutineMock() + ) + mock_connection_manager.return_value.create_offer.return_value = ( + async_mock.MagicMock(), + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPForbidden): + await test_module.credential_exchange_send_free_offer(mock) + + async def test_credential_exchange_send_bound_offer(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex: + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + mock_cred_ex.retrieve_by_id.return_value.state = ( + mock_cred_ex.STATE_PROPOSAL_RECEIVED + ) + + test_module.web.json_response = async_mock.CoroutineMock() + + mock_connection_manager.return_value.create_offer = ( + async_mock.CoroutineMock() + ) + + mock_cred_ex_record = async_mock.MagicMock() + + mock_connection_manager.return_value.create_offer.return_value = ( + mock_cred_ex_record, + async_mock.MagicMock(), + ) + + await test_module.credential_exchange_send_bound_offer(mock) + + test_module.web.json_response.assert_called_once_with( + mock_cred_ex_record.serialize.return_value + ) + + async def test_credential_exchange_send_bound_offer_no_conn_record(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex: + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + mock_cred_ex.retrieve_by_id.return_value.state = ( + mock_cred_ex.STATE_PROPOSAL_RECEIVED + ) + + test_module.web.json_response = async_mock.CoroutineMock() + + # Emulate storage not found (bad connection id) + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock( + side_effect=StorageNotFoundError + ) + + mock_connection_manager.return_value.create_offer = ( + async_mock.CoroutineMock() + ) + mock_connection_manager.return_value.create_offer.return_value = ( + async_mock.MagicMock(), + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPBadRequest): + await test_module.credential_exchange_send_bound_offer(mock) + + async def test_credential_exchange_send_bound_offer_not_ready(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex: + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + mock_cred_ex.retrieve_by_id.return_value.state = ( + mock_cred_ex.STATE_PROPOSAL_RECEIVED + ) + + test_module.web.json_response = async_mock.CoroutineMock() + + # Emulate connection not ready + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock() + mock_connection_record.retrieve_by_id.return_value.is_ready = False + + mock_connection_manager.return_value.create_offer = ( + async_mock.CoroutineMock() + ) + mock_connection_manager.return_value.create_offer.return_value = ( + async_mock.MagicMock(), + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPForbidden): + await test_module.credential_exchange_send_bound_offer(mock) + + async def test_credential_exchange_send_request(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex: + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + mock_cred_ex.retrieve_by_id.return_value.state = ( + mock_cred_ex.STATE_OFFER_RECEIVED + ) + + test_module.web.json_response = async_mock.CoroutineMock() + + mock_cred_ex_record = async_mock.MagicMock() + + mock_connection_manager.return_value.create_request.return_value = ( + mock_cred_ex_record, + async_mock.MagicMock(), + ) + + await test_module.credential_exchange_send_request(mock) + + test_module.web.json_response.assert_called_once_with( + mock_cred_ex_record.serialize.return_value + ) + + async def test_credential_exchange_send_request_no_conn_record(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex: + + test_module.web.json_response = async_mock.CoroutineMock() + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + mock_cred_ex.retrieve_by_id.return_value.state = ( + mock_cred_ex.STATE_OFFER_RECEIVED + ) + + # Emulate storage not found (bad connection id) + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock( + side_effect=StorageNotFoundError + ) + + mock_connection_manager.return_value.create_offer = ( + async_mock.CoroutineMock() + ) + mock_connection_manager.return_value.create_offer.return_value = ( + async_mock.MagicMock(), + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPBadRequest): + await test_module.credential_exchange_send_request(mock) + + async def test_credential_exchange_send_request_not_ready(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex: + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + mock_cred_ex.retrieve_by_id.return_value.state = ( + mock_cred_ex.STATE_OFFER_RECEIVED + ) + + test_module.web.json_response = async_mock.CoroutineMock() + + # Emulate connection not ready + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock() + mock_connection_record.retrieve_by_id.return_value.is_ready = False + + mock_connection_manager.return_value.create_offer = ( + async_mock.CoroutineMock() + ) + mock_connection_manager.return_value.create_offer.return_value = ( + async_mock.MagicMock(), + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPForbidden): + await test_module.credential_exchange_send_request(mock) + + async def test_credential_exchange_issue(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context" + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex, async_mock.patch.object( + test_module, + "CredentialPreview", + autospec=True + ) as mock_cred_preview: + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + mock_cred_ex.retrieve_by_id.return_value.state = ( + mock_cred_ex.STATE_REQUEST_RECEIVED + ) + + test_module.web.json_response = async_mock.CoroutineMock() + + mock_cred_ex_record = async_mock.MagicMock() + + mock_connection_manager.return_value.issue_credential.return_value = ( + mock_cred_ex_record, + async_mock.MagicMock() + ) + + mock_cred_preview.return_value.deserialize.return_value = ( + async_mock.MagicMock() + ) + mock_cred_preview.return_value.attr_dict.return_value = ( + async_mock.MagicMock() + ) + + await test_module.credential_exchange_issue(mock) + + test_module.web.json_response.assert_called_once_with( + mock_cred_ex_record.serialize.return_value + ) + + async def test_credential_exchange_issue_no_conn_record(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex, async_mock.patch.object( + test_module, + "CredentialPreview", + autospec=True + ) as mock_cred_preview: + + test_module.web.json_response = async_mock.CoroutineMock() + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + mock_cred_ex.retrieve_by_id.return_value.state = ( + mock_cred_ex.STATE_REQUEST_RECEIVED + ) + + # Emulate storage not found (bad connection id) + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock( + side_effect=StorageNotFoundError + ) + + mock_connection_manager.return_value.issue_credential = ( + async_mock.CoroutineMock() + ) + mock_connection_manager.return_value.issue_credential.return_value = ( + async_mock.MagicMock(), + async_mock.MagicMock() + ) + + mock_cred_preview.return_value.deserialize.return_value = ( + async_mock.MagicMock() + ) + mock_cred_preview.return_value.attr_dict.return_value = ( + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPBadRequest): + await test_module.credential_exchange_issue(mock) + + async def test_credential_exchange_issue_not_ready(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex, async_mock.patch.object( + test_module, + "CredentialPreview", + autospec=True + ) as mock_cred_preview: + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + mock_cred_ex.retrieve_by_id.return_value.state = ( + mock_cred_ex.STATE_REQUEST_RECEIVED + ) + + test_module.web.json_response = async_mock.CoroutineMock() + + # Emulate connection not ready + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock() + mock_connection_record.retrieve_by_id.return_value.is_ready = False + + mock_connection_manager.return_value.issue_credential = ( + async_mock.CoroutineMock() + ) + mock_connection_manager.return_value.issue_credential.return_value = ( + async_mock.MagicMock(), + async_mock.MagicMock() + ) + + mock_cred_preview.return_value.deserialize.return_value = ( + async_mock.MagicMock() + ) + mock_cred_preview.return_value.attr_dict.return_value = ( + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPForbidden): + await test_module.credential_exchange_issue(mock) + + async def test_credential_exchange_store(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex: + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + mock_cred_ex.retrieve_by_id.return_value.state = ( + mock_cred_ex.STATE_CREDENTIAL_RECEIVED + ) + + test_module.web.json_response = async_mock.CoroutineMock() + + mock_cred_ex_record = async_mock.MagicMock() + + mock_connection_manager.return_value.store_credential.return_value = ( + mock_cred_ex_record, + async_mock.MagicMock() + ) + + await test_module.credential_exchange_store(mock) + + test_module.web.json_response.assert_called_once_with( + mock_cred_ex_record.serialize.return_value + ) + + async def test_credential_exchange_store_no_conn_record(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex: + + test_module.web.json_response = async_mock.CoroutineMock() + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + mock_cred_ex.retrieve_by_id.return_value.state = ( + mock_cred_ex.STATE_CREDENTIAL_RECEIVED + ) + + # Emulate storage not found (bad connection id) + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock( + side_effect=StorageNotFoundError + ) + + mock_connection_manager.return_value.store_credential.return_value = ( + mock_cred_ex, + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPBadRequest): + await test_module.credential_exchange_store(mock) + + async def test_credential_exchange_store_not_ready(self): + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock() + + mock.app = { + "outbound_message_router": async_mock.CoroutineMock(), + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex: + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + mock_cred_ex.retrieve_by_id.return_value.state = ( + mock_cred_ex.STATE_CREDENTIAL_RECEIVED + ) + + test_module.web.json_response = async_mock.CoroutineMock() + + # Emulate connection not ready + mock_connection_record.retrieve_by_id = async_mock.CoroutineMock() + mock_connection_record.retrieve_by_id.return_value.is_ready = False + + mock_connection_manager.return_value.store_credential.return_value = ( + mock_cred_ex, + async_mock.MagicMock() + ) + + with self.assertRaises(test_module.web.HTTPForbidden): + await test_module.credential_exchange_store(mock) + + async def test_credential_exchange_problem_report(self): + mock_request = async_mock.MagicMock() + mock_request.json = async_mock.CoroutineMock() + + mock_outbound = async_mock.CoroutineMock() + + mock_request.app = { + "outbound_message_router": mock_outbound, + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex, async_mock.patch.object( + test_module, + "ProblemReport", + autospec=True + ) as mock_prob_report: + + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock() + + test_module.web.json_response = async_mock.CoroutineMock() + + await test_module.credential_exchange_problem_report(mock_request) + + test_module.web.json_response.assert_called_once_with({}) + mock_outbound.assert_called_once_with( + mock_prob_report.return_value, + connection_id=mock_cred_ex.retrieve_by_id.return_value.connection_id, + ) + + async def test_credential_exchange_problem_report_no_cred_record(self): + + mock_request = async_mock.MagicMock() + mock_request.json = async_mock.CoroutineMock() + + mock_outbound = async_mock.CoroutineMock() + + mock_request.app = { + "outbound_message_router": mock_outbound, + "request_context": "context", + } + + with async_mock.patch.object( + test_module, + "ConnectionRecord", + autospec=True + ) as mock_connection_record, async_mock.patch.object( + test_module, + "CredentialManager", + autospec=True + ) as mock_connection_manager, async_mock.patch.object( + test_module, + "V10CredentialExchange", + autospec=True + ) as mock_cred_ex, async_mock.patch.object( + test_module, + "ProblemReport", + autospec=True + ) as mock_prob_report: + + # Emulate storage not found (bad connection id) + mock_cred_ex.retrieve_by_id = async_mock.CoroutineMock( + side_effect=StorageNotFoundError + ) + + with self.assertRaises(test_module.web.HTTPNotFound): + await test_module.credential_exchange_problem_report(mock_request) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_proposal_handler.py b/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_proposal_handler.py index 1e98ea5b24..c8b55ecc8f 100644 --- a/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_proposal_handler.py +++ b/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_proposal_handler.py @@ -48,7 +48,7 @@ async def handle(self, context: RequestContext, responder: BaseResponder): ( presentation_exchange_record, presentation_request_message, - ) = await presentation_manager.create_request( + ) = await presentation_manager.create_bound_request( presentation_exchange_record=presentation_exchange_record, comment=context.message.comment ) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_request_handler.py b/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_request_handler.py index 9493a3d246..40cb0d654b 100644 --- a/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_request_handler.py +++ b/aries_cloudagent/messaging/present_proof/v1_0/handlers/presentation_request_handler.py @@ -12,10 +12,9 @@ from .....storage.error import StorageNotFoundError from ..manager import PresentationManager -from ..messages.inner.presentation_preview import PresentationPreview from ..messages.presentation_request import PresentationRequest -from ..messages.presentation_proposal import PresentationProposal from ..models.presentation_exchange import V10PresentationExchange +from ..util.indy import indy_proof_request2indy_requested_creds class PresentationRequestHandler(BaseHandler): @@ -44,14 +43,8 @@ async def handle(self, context: RequestContext, responder: BaseResponder): presentation_manager = PresentationManager(context) indy_proof_request = context.message.indy_proof_request(0) - presentation_proposal_dict = PresentationProposal( - comment=context.message.comment, - presentation_proposal=PresentationPreview.from_indy_proof_request( - indy_proof_request - ) - ).serialize() - # Get credential exchange record (holder sent proposal first) + # Get credential exchange record (holder initiated via proposal) # or create it (verifier sent request first) try: presentation_exchange_record = ( @@ -61,16 +54,12 @@ async def handle(self, context: RequestContext, responder: BaseResponder): "thread_id": context.message._thread_id } ) - ) - presentation_exchange_record.presentation_proposal_dict = ( - presentation_proposal_dict - ) + ) # holder initiated via proposal except StorageNotFoundError: # verifier sent this request free of any proposal presentation_exchange_record = V10PresentationExchange( connection_id=context.connection_record.connection_id, thread_id=context.message._thread_id, initiator=V10PresentationExchange.INITIATOR_EXTERNAL, - presentation_proposal_dict=presentation_proposal_dict, presentation_request=indy_proof_request, auto_present=context.settings.get( "debug.auto_respond_presentation_request" @@ -78,55 +67,28 @@ async def handle(self, context: RequestContext, responder: BaseResponder): ) presentation_exchange_record.presentation_request = indy_proof_request - presentation_exchange_record = await presentation_manager.receive_request( presentation_exchange_record ) # If auto_present is enabled, respond immediately with presentation if presentation_exchange_record.auto_present: - hold: BaseHolder = await context.inject(BaseHolder) - req_creds = { - "self_attested_attributes": {}, - "requested_attributes": {}, - "requested_predicates": {} - } - - for category in ("requested_attributes", "requested_predicates"): - for referent in indy_proof_request[category]: - credentials = ( - await hold.get_credentials_for_presentation_request_by_referent( - indy_proof_request, - (referent,), - 0, - 2, - {} - ) + try: + req_creds = await indy_proof_request2indy_requested_creds( + indy_proof_request, + await context.inject(BaseHolder) + ) + except ValueError as err: + self._logger.warning(f"{err}") + return + + (presentation_exchange_record, presentation_message) = ( + await presentation_manager.create_presentation( + presentation_exchange_record=presentation_exchange_record, + requested_credentials=req_creds, + comment="auto-presented for proof request nonce={}".format( + indy_proof_request["nonce"] ) - if len(credentials) != 1: - self._logger.warning( - f"Could not automatically construct presentation for " - + f"presentation request {indy_proof_request['name']}" - + f":{indy_proof_request['version']} because referent " - + f"{referent} did not produce exactly one credential " - + f"result. The wallet returned {len(credentials)} " - + f"matching credentials." - ) - return - - req_creds[category][referent] = { - "cred_id": credentials[0]["cred_info"]["referent"], - "revealed": True - } - - ( - presentation_exchange_record, - presentation_message - ) = await presentation_manager.create_presentation( - presentation_exchange_record=presentation_exchange_record, - requested_credentials=req_creds, - comment="auto-presented for proof request nonce={}".format( - indy_proof_request["nonce"] ) ) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/manager.py b/aries_cloudagent/messaging/present_proof/v1_0/manager.py index 5e9c961501..7cdc10bf9b 100644 --- a/aries_cloudagent/messaging/present_proof/v1_0/manager.py +++ b/aries_cloudagent/messaging/present_proof/v1_0/manager.py @@ -109,9 +109,8 @@ async def receive_proposal( return presentation_exchange_record - async def create_request( + async def create_bound_request( self, - # TODO recall that in routes, to create free request, first populate proposal presentation_exchange_record: V10PresentationExchange, name: str = None, version: str = None, @@ -119,7 +118,7 @@ async def create_request( comment: str = None ): """ - Create a presentation request. + Create a presentation request bound to a proposal. Args: presentation_exchange_record: Presentation exchange record for which @@ -131,8 +130,10 @@ async def create_request( """ indy_proof_request = ( - PresentationProposal.deserialize( - presentation_exchange_record.presentation_proposal_dict + await ( + PresentationProposal.deserialize( + presentation_exchange_record.presentation_proposal_dict + ) ).presentation_proposal.indy_proof_request( name=name, version=version, @@ -155,11 +156,42 @@ async def create_request( presentation_exchange_record.presentation_request = indy_proof_request await presentation_exchange_record.save( self.context, - reason="Aries#0037v1.0 create presentation request" + reason="Aries#0037v1.0 create (bound) presentation request" ) return presentation_exchange_record, presentation_request_message + async def create_exchange_for_request( + self, + connection_id: str, + presentation_request_message: PresentationRequest + ): + """ + Create a presentation exchange record for input presentation request. + + Args: + connection_id: connection identifier + presentation_request_message: presentation request to use in creating + exchange record, extracting indy proof request and thread id + + Returns: + Presentation exchange record + + """ + presentation_exchange_record = V10PresentationExchange( + connection_id=connection_id, + thread_id=presentation_request_message._thread_id, + initiator=V10PresentationExchange.INITIATOR_SELF, + state=V10PresentationExchange.STATE_REQUEST_SENT, + presentation_request=presentation_request_message.indy_proof_request() + ) + await presentation_exchange_record.save( + self.context, + reason="Aries#0037v1.0 create (free) presentation request" + ) + + return presentation_exchange_record + async def receive_request( self, presentation_exchange_record: V10PresentationExchange @@ -200,22 +232,25 @@ async def create_presentation( e.g., - { - "self_attested_attributes": { - "j233ffbc-bd35-49b1-934f-51e083106f6d": "value" - }, - "requested_attributes": { - "6253ffbb-bd35-49b3-934f-46e083106f6c": { - "cred_id": "5bfa40b7-062b-4ae0-a251-a86c87922c0e", - "revealed": true - } - }, - "requested_predicates": { - "bfc8a97d-60d3-4f21-b998-85eeabe5c8c0": { - "cred_id": "5bfa40b7-062b-4ae0-a251-a86c87922c0e" + :: + + { + "self_attested_attributes": { + "j233ffbc-bd35-49b1-934f-51e083106f6d": "value" + }, + "requested_attributes": { + "6253ffbb-bd35-49b3-934f-46e083106f6c": { + "cred_id": "5bfa40b7-062b-4ae0-a251-a86c87922c0e", + "revealed": true + } + }, + "requested_predicates": { + "bfc8a97d-60d3-4f21-b998-85eeabe5c8c0": { + "cred_id": "5bfa40b7-062b-4ae0-a251-a86c87922c0e" + } } } - } + comment: optional human-readable comment """ @@ -296,7 +331,11 @@ async def receive_presentation(self, presentation: dict, thread_id: str): ( presentation_exchange_record ) = await V10PresentationExchange.retrieve_by_tag_filter( - self.context, tag_filter={"thread_id": thread_id} + self.context, + tag_filter={ + "thread_id": thread_id + # initiator may be issuer (via request) or holder (via proposal) + } ) presentation_exchange_record.presentation = presentation diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/presentation_preview.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/presentation_preview.py index 79e8f25d5b..6ef1cd18fa 100644 --- a/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/presentation_preview.py +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/presentation_preview.py @@ -1,130 +1,236 @@ """A presentation preview inner object.""" -from datetime import datetime, timezone +from enum import Enum from uuid import uuid4 -from typing import Mapping +from time import time +from typing import Mapping, Sequence import base64 from marshmallow import fields, validate -from ......messaging.util import str_to_epoch +from ......ledger.indy import IndyLedger from .....models.base import BaseModel, BaseModelSchema -from .....valid import INDY_CRED_DEF_ID, INDY_PREDICATE, INDY_ISO8601_DATETIME +from .....util import canon +from .....valid import INDY_CRED_DEF_ID, INDY_PREDICATE from ...message_types import PRESENTATION_PREVIEW -from ..util.indy import canon, Predicate +from ...util.indy import Predicate -class PresentationAttrPreview(BaseModel): - """Class representing an `"attributes"` attibute within the preview.""" +class PresPredSpec(BaseModel): + """Class representing a predicate specification within a presentation preview.""" - DEFAULT_META = {"mime-type": "text/plain"} + class Meta: + """Pred spec metadata.""" + + schema_class = "PresPredSpecSchema" + + def __init__( + self, + name: str, + *, + cred_def_id: str, + predicate: str, + threshold: int, + **kwargs + ): + """ + Initialize preview object. + + Args: + name: attribute name + cred_def_id: credential definition identifier + predicate: predicate type (e.g., ">=") + threshold: threshold value + + """ + super().__init__(**kwargs) + self.name = canon(name) + self.cred_def_id = cred_def_id + self.predicate = predicate + self.threshold = threshold + + def __eq__(self, other): + """Equality comparator.""" + + for part in vars(self): + if getattr(self, part, None) != getattr(other, part, None): + return False + return True + + +class PresPredSpecSchema(BaseModelSchema): + """Predicate specifiation schema.""" class Meta: - """Attribute preview metadata.""" + """Predicate specifiation schema metadata.""" - schema_class = "PresentationAttrPreviewSchema" + model_class = PresPredSpec + + name = fields.Str( + description="Attribute name", + required=True, + example="high_score" + ) + cred_def_id = fields.Str( + description="Credential definition identifier", + required=True, + **INDY_CRED_DEF_ID + ) + predicate = fields.Str( + description="Predicate (currently, indy supports >=)", + required=True, + **INDY_PREDICATE + ) + threshold = fields.Int( + description="Threshold value", + required=True + ) + + +class PresAttrSpec(BaseModel): + """Class representing an attibute specification within a presentation preview.""" + + class Meta: + """Attr spec metadata.""" + + schema_class = "PresAttrSpecSchema" + + class Posture(Enum): + """Attribute posture: self-attested, revealed claim or unrevealed claim.""" + + SELF_ATTESTED = 0 + REVEALED_CLAIM = 1 + UNREVEALED_CLAIM = 2 def __init__( - self, - *, - value: str = None, - encoding: str = None, - mime_type: str = None, - **kwargs): + self, + name: str, + *, + cred_def_id: str = None, + mime_type: str = None, + value: str = None, + **kwargs + ): """ - Initialize attribute preview object. + Initialize attribute specification object. Args: - mime_type: MIME type + name: attribute name + cred_def_id: credential definition identifier + (None for self-attested attribute) encoding: encoding (omit or "base64") - value: attribute value + mime_type: MIME type + value: attribute value as credential stores it + (None for unrevealed attribute) """ super().__init__(**kwargs) - self.value = value - self.encoding = encoding.lower() if encoding else None + self.name = canon(name) + self.cred_def_id = cred_def_id self.mime_type = ( mime_type.lower() - if mime_type and mime_type != PresentationAttrPreview.DEFAULT_META.get( - "mime-type" - ) - else None + if mime_type else None ) + self.value = value @staticmethod - def list_plain(plain: dict): + def list_plain(plain: dict, cred_def_id: str): """ - Return a list of `PresentationAttrPreview` for plain text from names/values. + Return a list of `PresAttrSpec` on input cred def id. Args: plain: dict mapping names to values + Returns: - PresentationAttrPreview on name/values pairs with default MIME type + List of PresAttrSpec on input cred def id with no MIME types """ - return [PresentationAttrPreview(name=k, value=plain[k]) for k in plain] + return [ + PresAttrSpec( + name=k, + cred_def_id=cred_def_id, + value=plain[k] + ) for k in plain + ] + + @property + def posture(self) -> "PresAttrSpec.Posture": + """Attribute posture: self-attested, revealed claim, or unrevealed claim.""" - def void(self): - """Remove value, encoding, MIME type for use in proof request.""" + if self.cred_def_id: + if self.value: + return PresAttrSpec.Posture.REVEALED_CLAIM + return PresAttrSpec.Posture.UNREVEALED_CLAIM + if self.value: + return PresAttrSpec.Posture.SELF_ATTESTED - self.value = None - self.encoding = None - self.mime_type = None + return None def b64_decoded_value(self) -> str: """Value, base64-decoded if applicable.""" return base64.b64decode(self.value.encode()).decode( - ) if ( + ) if self.value and self.mime_type else self.value + + def satisfies(self, pred_spec: PresPredSpec): + """Whether current specified attribute satisfied input specified predicate.""" + + return bool( self.value and - self.encoding and - self.encoding.lower() == "base64" - ) else self.value + not self.mime_type and + self.name == pred_spec.name and + self.cred_def_id == pred_spec.cred_def_id and + Predicate.get(pred_spec.predicate).value.yes( + self.value, + pred_spec.threshold + ) + ) def __eq__(self, other): """Equality comparator.""" - if all( - getattr(self, attr, PresentationAttrPreview.DEFAULT_META.get(attr)) == - getattr(other, attr, PresentationAttrPreview.DEFAULT_META.get(attr)) - for attr in vars(self) - ): - return True # all attrs exactly match + if self.name != other.name: + return False # distinct attribute names (canonicalized on init) - if ( - self.mime_type or "text/plain" - ).lower() != (other.mime_type or "text/plain").lower(): + if self.cred_def_id != other.cred_def_id: + return False # distinct attribute cred def ids + + if self.mime_type != other.mime_type: return False # distinct MIME types return self.b64_decoded_value() == other.b64_decoded_value() -class PresentationAttrPreviewSchema(BaseModelSchema): - """Attribute preview schema.""" +class PresAttrSpecSchema(BaseModelSchema): + """Attribute specifiation schema.""" class Meta: - """Attribute preview schema metadata.""" + """Attribute specifiation schema metadata.""" - model_class = PresentationAttrPreview + model_class = PresAttrSpec - value = fields.Str( - description="Attribute value", - required=False + name = fields.Str( + description="Attribute name", + required=True, + example="favourite_drink" + ) + cred_def_id = fields.Str( + required=False, + **INDY_CRED_DEF_ID ) mime_type = fields.Str( - description="MIME type (default text/plain)", + description="MIME type (default null)", required=False, data_key="mime-type", - example="text/plain" + example="image/jpeg" ) - encoding = fields.Str( - description="Encoding (specify base64 or omit for none)", + value = fields.Str( + description="Attribute value", required=False, - example="base64", - validate=validate.Equal("base64", error="Must be absent or equal to {other}") + example="martini" ) @@ -141,9 +247,8 @@ def __init__( self, *, _type: str = None, - attributes: Mapping[str, Mapping[str, PresentationAttrPreview]], - predicates: Mapping[str, Mapping[str, Mapping[str, int]]], - non_revocation_times: Mapping[str, datetime], + attributes: Sequence[PresAttrSpec] = None, + predicates: Sequence[PresPredSpec] = None, **kwargs ): """ @@ -151,96 +256,13 @@ def __init__( Args: _type: formalism for Marshmallow model creation: ignored - attributes: nested dict mapping cred def identifiers to attribute names - to attribute previews - predicates: nested dict mapping cred def identifiers to predicates - to predicate previews - non_revocation_times: dict mapping cred def identifiers to non-revocation - timestamps + attributes: list of attribute specifications + predicates: list of predicate specifications + """ super().__init__(**kwargs) - self.attributes = attributes - self.predicates = predicates - self.non_revocation_times = non_revocation_times - - @staticmethod - def from_indy_proof_request(indy_proof_request: dict): - """Reverse-engineer presentation preview from indy proof request.""" - - def do_non_revo(cd_id: str, proof_req_non_revo: dict): - """Set non-revocation times per cred def id given from/to specifiers.""" - - nonlocal non_revocation_times - if proof_req_non_revo: - if cd_id not in non_revocation_times: - non_revocation_times[cd_id] = { - "from": datetime.fromtimestamp( - proof_req_non_revo["from"], - tz=timezone.utc - ), - "to": datetime.fromtimestamp( - proof_req_non_revo["to"], - tz=timezone.utc - ) - } - else: - non_revocation_times[cd_id] = { - "from": max( - datetime.fromtimestamp( - proof_req_non_revo["from"], - tz=timezone.utc - ), - non_revocation_times[cd_id]["from"] - ), - "to": min( - datetime.fromtimestamp( - proof_req_non_revo["to"], - tz=timezone.utc - ), - non_revocation_times[cd_id]["to"] - ) - } - - attributes = {} - predicates = {} - non_revocation_times = {} - - for (uuid, attr_spec) in indy_proof_request["requested_attributes"].items(): - cd_id = attr_spec["restrictions"][0]["cred_def_id"] - if cd_id not in attributes: - attributes[cd_id] = {} - attributes[cd_id][attr_spec["name"]] = PresentationAttrPreview() - do_non_revo(cd_id, attr_spec.get("non_revoked")) - - for (uuid, pred_spec) in indy_proof_request["requested_predicates"].items(): - cd_id = pred_spec["restrictions"][0]["cred_def_id"] - if cd_id not in predicates: - predicates[cd_id] = {} - pred_type = pred_spec["p_type"] - if pred_type not in predicates[cd_id]: - predicates[cd_id][pred_type] = {} - predicates[cd_id][pred_type][pred_spec["name"]] = ( - pred_spec["p_value"] - ) - do_non_revo(cd_id, pred_spec.get("non_revoked")) - - return PresentationPreview( - attributes=attributes, - predicates=predicates, - non_revocation_times={ - cd_id: ( - non_revocation_times[cd_id]["to"].isoformat(" ", "seconds") - ) for cd_id in non_revocation_times - } - ) - - def void_attribute_previews(self): - """Clear attribute values, encodings, MIME types from presentation preview.""" - for cd_id in self.attributes: - for attr in self.attributes[cd_id]: - self.attributes[cd_id][attr].void() - - return self + self.attributes = list(attributes) if attributes else [] + self.predicates = list(predicates) if predicates else [] @property def _type(self): @@ -248,70 +270,52 @@ def _type(self): return PresentationPreview.Meta.message_type - def attr_dict(self, decode: bool = False): - """ - Return dict mapping cred def id to name:value pair per attribute. - - Args: - decode: whether first to decode attributes marked as having encoding - - """ - - def b64(attr_prev: PresentationAttrPreview, b64deco: bool = False) -> str: - """Base64 decode attribute value if applicable.""" - return ( - base64.b64decode(attr_prev.value.encode()).decode() - if ( - attr_prev.value and - attr_prev.encoding and - attr_prev.encoding == "base64" and - b64deco - ) else attr_prev.value - ) - - return { - cd_id: { - attr: b64(self.attributes[cd_id][attr], decode) - for attr in self.attributes[cd_id] - } for cd_id in self.attributes - } - - def attr_metadata(self): - """Return nested dict mapping cred def id to attr to MIME type and encoding.""" - - return { - cd_id: { - attr: { - **{ - "mime-type": aprev.mime_type - for aprev in [self.attributes[cd_id][attr]] if aprev.mime_type - }, - **{ - "encoding": aprev.encoding - for aprev in [self.attributes[cd_id][attr]] if aprev.encoding - } - } for attr in self.attributes[cd_id] - } for cd_id in self.attributes - } - - def indy_proof_request( + async def indy_proof_request( self, name: str = None, version: str = None, - nonce: str = None + nonce: str = None, + ledger: IndyLedger = None, + timestamps: Mapping[str, int] = None ) -> dict: """ Return indy proof request corresponding to presentation preview. + Typically the verifier turns the proof preview into a proof request. + Args: name: for proof request version: version for proof request nonce: nonce for proof request + ledger: ledger with credential definitions, to check for revocation support + timestamps: dict mapping cred def ids to non-revocation + timestamps to use (default current time where applicable) Returns: Indy proof request dict. """ + def non_revo(cred_def_id: str): + """Non-revocation timestamp to use for input cred def id.""" + + nonlocal epoch_now + nonlocal timestamps + + return (timestamps or {}).get(cred_def_id, epoch_now) + + def ord_cred_def_id(cred_def_id: str): + """Ordinal for cred def id to use in suggestive proof req referent.""" + + nonlocal cred_def_ids + + if cred_def_id in cred_def_ids: + return cred_def_ids.index(cred_def_id) + cred_def_ids.append(cred_def_id) + return len(cred_def_ids) - 1 + + epoch_now = int(time()) # TODO: take cred_def_id->timestamp here, default now + cred_def_ids = [] + proof_req = { "name": name or "proof-request", "version": version or "1.0", @@ -319,54 +323,62 @@ def indy_proof_request( "requested_attributes": {}, "requested_predicates": {} } - cd_ids = [] # map ordinal to cred def id for use in proof req referents - for (cd_id, attr_dict) in self.attr_dict().items(): - cd_ids.append(cd_id) - cd_id_index = len(cd_ids) - 1 - for (attr, attr_value) in attr_dict.items(): + for attr_spec in self.attributes: + cd_id = attr_spec.cred_def_id + revo_support = bool(ledger and await ledger.get_credential_definition( + cd_id + )["value"]["revocation"]) + + timestamp = non_revo(attr_spec.cred_def_id) + if attr_spec.posture != PresAttrSpec.Posture.SELF_ATTESTED: proof_req["requested_attributes"][ - f"{cd_id_index}_{canon(attr)}_uuid" + "{}_{}_uuid".format( + ord_cred_def_id(cd_id), + canon(attr_spec.name) + ) ] = { - "name": attr, + "name": canon(attr_spec.name), "restrictions": [ {"cred_def_id": cd_id} ], **{ "non_revoked": { - "from": str_to_epoch(self.non_revocation_times[cd_id]), - "to": str_to_epoch(self.non_revocation_times[cd_id]) - } for _ in [""] if cd_id in self.non_revocation_times + "from": timestamp, + "to": timestamp + } for _ in [""] if revo_support } } - # predicates: Mapping[str, Mapping[str, Mapping[str, str]]], - for (cd_id, pred_dict) in self.predicates.items(): - if cd_id not in cd_ids: - cd_ids.append(cd_id) - cd_id_index = cd_ids.index(cd_id) - for (pred_math, pred_attr_dict) in pred_dict.items(): - for (attr, threshold) in pred_attr_dict.items(): - proof_req["requested_predicates"][ - "{}_{}_{}_uuid".format( - cd_id_index, - canon(attr), - Predicate.get(pred_math).value.fortran - ) - ] = { - "name": attr, - "p_type": pred_math, - "p_value": threshold, - "restrictions": [ - {"cred_def_id": cd_id} - ], - **{ - "non_revoked": { - "from": str_to_epoch(self.non_revocation_times[cd_id]), - "to": str_to_epoch(self.non_revocation_times[cd_id]) - } for _ in [""] if cd_id in self.non_revocation_times - } + for pred_spec in self.predicates: + cd_id = pred_spec.cred_def_id + revo_support = bool(ledger and await ledger.get_credential_definition( + cd_id + )["value"]["revocation"]) + + timestamp = non_revo(attr_spec.cred_def_id) + proof_req["requested_predicates"][ + "{}_{}_{}_uuid".format( + ord_cred_def_id(cd_id), + canon(pred_spec.name), + Predicate.get(pred_spec.predicate).value.fortran + ) + ] = { + "name": canon(pred_spec.name), + "p_type": pred_spec.predicate, + "p_value": pred_spec.threshold, + "restrictions": [ + { + "cred_def_id": cd_id } + ], + **{ + "non_revoked": { + "from": timestamp, + "to": timestamp + } for _ in [""] if revo_support + } + } return proof_req @@ -397,45 +409,15 @@ class Meta: error="Must be absent or equal to {other}" ) ) - attributes = fields.Dict( - description=( - "Nested object mapping cred def identifiers to attribute preview specifiers" - ), + attributes = fields.Nested( + PresAttrSpecSchema, + description="List of attribute specifications", required=True, - keys=fields.Str(**INDY_CRED_DEF_ID), # marshmallow/apispec v3.0rc3 ignores - values=fields.Dict( - description="Object mapping attribute names to attribute previews", - keys=fields.Str(example="attr_name"), # marshmallow/apispec v3.0rc3 ignores - values=fields.Nested(PresentationAttrPreviewSchema) - ) + many=True ) - predicates = fields.Dict( - description=( - "Nested object mapping cred def identifiers to predicate preview specifiers" - ), + predicates = fields.Nested( + PresPredSpecSchema, + description="List of predicate specifications", required=True, - keys=fields.Str(**INDY_CRED_DEF_ID), - values=fields.Dict( - description=( - "Nested Object mapping predicates " - '(currently, only ">=" for 32-bit integers) ' - "to attribute names to threshold values" - ), - keys=fields.Str(**INDY_PREDICATE), # marshmallow/apispec v3.0rc3 ignores - values=fields.Dict( - description="Object mapping attribute names to threshold values", - keys=fields.Str(example="attr_name"), - values=fields.Int() - ) - ) - ) - non_revocation_times = fields.Dict( - description=( - "Object mapping cred def identifiers to ISO-8601 datetimes, each marking a " - "non-revocation timestamp for its corresponding credential in the proof" - ), - required=False, - default={}, - keys=fields.Str(**INDY_CRED_DEF_ID), # marshmallow/apispec v3.0rc3 ignores - values=fields.Str(**INDY_ISO8601_DATETIME) + many=True ) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py index 501231198f..62b6f80f07 100644 --- a/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py @@ -4,38 +4,49 @@ import json +from asynctest import TestCase as AsyncTestCase +from asynctest import mock as async_mock + +import pytest + +from .......holder.indy import IndyHolder from .......messaging.util import str_to_datetime, str_to_epoch +from ......util import canon from ....message_types import PRESENTATION_PREVIEW +from ....util.indy import Predicate from ..presentation_preview import ( - PresentationAttrPreview, + PresAttrSpec, + PresPredSpec, PresentationPreview, - PresentationPreviewSchema ) + NOW_8601 = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat(" ", "seconds") NOW_EPOCH = str_to_epoch(NOW_8601) -CD_ID = "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" +S_ID = "NcYxiDXkpYi6ov5FcYDi1e:2:vidya:1.0" +CD_ID = f"NcYxiDXkpYi6ov5FcYDi1e:3:CL:{S_ID}:tag1" PRES_PREVIEW = PresentationPreview( - attributes={ - CD_ID: { - "player": PresentationAttrPreview(value="Richie Knucklez"), - "screenCapture": PresentationAttrPreview( - mime_type="image/png", - encoding="base64", - value="aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" - ) - } - }, - predicates={ - CD_ID: { - ">=": { - "highScore": 1000000 - } - } - }, - non_revocation_times={ - CD_ID: NOW_8601 - } + attributes=[ + PresAttrSpec( + name="player", + cred_def_id=CD_ID, + value="Richie Knucklez" + ), + PresAttrSpec( + name="screenCapture", + cred_def_id=CD_ID, + mime_type="image/png", + value="aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" + ) + ], + predicates=[ + PresPredSpec( + name="highScore", + cred_def_id=CD_ID, + predicate=">=", + threshold=1000000 + ) + ] ) INDY_PROOF_REQ = json.loads(f"""{{ "name": "proof-req", @@ -48,11 +59,7 @@ {{ "cred_def_id": "{CD_ID}" }} - ], - "non_revoked": {{ - "from": {NOW_EPOCH}, - "to": {NOW_EPOCH} - }} + ] }}, "0_screencapture_uuid": {{ "name": "screenCapture", @@ -60,11 +67,7 @@ {{ "cred_def_id": "{CD_ID}" }} - ], - "non_revoked": {{ - "from": {NOW_EPOCH}, - "to": {NOW_EPOCH} - }} + ] }} }}, "requested_predicates": {{ @@ -76,95 +79,211 @@ {{ "cred_def_id": "{CD_ID}" }} - ], - "non_revoked": {{ - "from": {NOW_EPOCH}, - "to": {NOW_EPOCH} - }} + ] }} }} }}""") -class TestPresentationAttrPreview(TestCase): - """Attribute preview tests""" +class TestPresAttrSpec(TestCase): + """Presentation-preview attribute specification tests""" - def test_eq(self): - attr_previews_none_plain = [ - PresentationAttrPreview(value="value"), - PresentationAttrPreview(value="value", encoding=None, mime_type=None), - PresentationAttrPreview( - value="value", - encoding=None, - mime_type="text/plain" + def test_posture(self): + self_attested = PresAttrSpec( + name="ident", + cred_def_id=None, + value="655321" + ) + assert self_attested.posture == PresAttrSpec.Posture.SELF_ATTESTED + + revealed = PresAttrSpec( + name="ident", + cred_def_id=CD_ID, + value="655321" + ) + assert revealed.posture == PresAttrSpec.Posture.REVEALED_CLAIM + + unrevealed = PresAttrSpec( + name="ident", + cred_def_id=CD_ID + ) + assert unrevealed.posture == PresAttrSpec.Posture.UNREVEALED_CLAIM + + def test_list_plain(self): + by_list = PresAttrSpec.list_plain( + plain={ + "ident": "655321", + " Given Name ": "Alexander DeLarge" + }, + cred_def_id=CD_ID + ) + explicit = [ + PresAttrSpec( + name="ident", + cred_def_id=CD_ID, + value="655321" ), - PresentationAttrPreview( - value="value", - encoding=None, - mime_type="TEXT/PLAIN" + PresAttrSpec( + name="givenname", + cred_def_id=CD_ID, + value="Alexander DeLarge" ) ] - attr_previews_b64_plain = [ - PresentationAttrPreview(value="dmFsdWU=", encoding="base64"), - PresentationAttrPreview( - value="dmFsdWU=", - encoding="base64", - mime_type=None - ), - PresentationAttrPreview( - value="dmFsdWU=", - encoding="base64", - mime_type="text/plain" + + # order could be askew + for listp in by_list: + assert any(xp == listp for xp in explicit) + assert len(explicit) == len(by_list) + + def test_eq(self): + attr_specs_none_plain = [ + PresAttrSpec( + name="name", + value="value" ), - PresentationAttrPreview( - value="dmFsdWU=", - encoding="BASE64", - mime_type="text/plain" + PresAttrSpec( + name="name", + value="value", + mime_type=None ), - PresentationAttrPreview( - value="dmFsdWU=", - encoding="base64", - mime_type="TEXT/PLAIN" + PresAttrSpec( + name=" NAME ", + value="value" ) ] - attr_previews_different = [ - PresentationAttrPreview( + attr_specs_different = [ + PresAttrSpec( + name="name", value="dmFsdWU=", - encoding="base64", mime_type="image/png" ), - PresentationAttrPreview( + PresAttrSpec( + name="name", + value="value", + cred_def_id="cred_def_id" + ), + PresAttrSpec( + name="name", value="distinct value", mime_type=None ), - PresentationAttrPreview() + PresAttrSpec( + name="distinct name", + value="value", + mime_type=None + ), + PresAttrSpec( + name="name", + value="dmFsdWU=", + mime_type=None + ), + PresAttrSpec(name="name") ] - for lhs in attr_previews_none_plain: - for rhs in attr_previews_b64_plain: - assert lhs == rhs # values decode to same - - for lhs in attr_previews_none_plain: - for rhs in attr_previews_different: + for lhs in attr_specs_none_plain: + for rhs in attr_specs_different: assert lhs != rhs - for lhs in attr_previews_b64_plain: - for rhs in attr_previews_different: - assert lhs != rhs + for lidx in range(len(attr_specs_none_plain) - 1): + for ridx in range(lidx + 1, len(attr_specs_none_plain)): + assert attr_specs_none_plain[lidx] == attr_specs_none_plain[ridx] - for lidx in range(len(attr_previews_none_plain) - 1): - for ridx in range(lidx + 1, len(attr_previews_none_plain)): - assert attr_previews_none_plain[lidx] == attr_previews_none_plain[ridx] + for lidx in range(len(attr_specs_different) - 1): + for ridx in range(lidx + 1, len(attr_specs_different)): + assert attr_specs_different[lidx] != attr_specs_different[ridx] - for lidx in range(len(attr_previews_b64_plain) - 1): - for ridx in range(lidx + 1, len(attr_previews_b64_plain)): - assert attr_previews_b64_plain[lidx] == attr_previews_b64_plain[ridx] + def test_deserialize(self): + """Test deserialization.""" + dump = json.dumps({ + "name": "PLAYER", + "cred_def_id": CD_ID, + "value": "Richie Knucklez" + }) - for lidx in range(len(attr_previews_different) - 1): - for ridx in range(lidx + 1, len(attr_previews_different)): - assert attr_previews_different[lidx] != attr_previews_different[ridx] + attr_spec = PresAttrSpec.deserialize(dump) + assert type(attr_spec) == PresAttrSpec + assert attr_spec.name == "player" + def test_serialize(self): + """Test serialization.""" + + attr_spec_dict = PRES_PREVIEW.attributes[0].serialize() + assert attr_spec_dict == { + "name": "player", + "cred_def_id": CD_ID, + "value": "Richie Knucklez" + } + + +class TestPresPredSpec(TestCase): + """Presentation predicate specification tests""" + + def test_deserialize(self): + """Test deserialization.""" + dump = json.dumps({ + "name": "HIGH SCORE", + "cred_def_id": CD_ID, + "predicate": ">=", + "threshold": 1000000 + }) + pred_spec = PresPredSpec.deserialize(dump) + assert type(pred_spec) == PresPredSpec + assert pred_spec.name == "highscore" + + def test_serialize(self): + """Test serialization.""" + + pred_spec_dict = PRES_PREVIEW.predicates[0].serialize() + assert pred_spec_dict == { + "name": "highscore", + "cred_def_id": CD_ID, + "predicate": ">=", + "threshold": 1000000 + } + + +@pytest.mark.indy +class TestPresentationPreviewAsync(AsyncTestCase): + """Presentation preview tests""" + + @pytest.mark.asyncio + async def test_to_indy_proof_request(self): + """Test presentation preview to indy proof request.""" + + CANON_INDY_PROOF_REQ = deepcopy(INDY_PROOF_REQ) + for spec in CANON_INDY_PROOF_REQ["requested_attributes"].values(): + spec["name"] = canon(spec["name"]) + for spec in CANON_INDY_PROOF_REQ["requested_predicates"].values(): + spec["name"] = canon(spec["name"]) + + pres_preview = deepcopy(PRES_PREVIEW) + + indy_proof_req = await pres_preview.indy_proof_request( + **{k: INDY_PROOF_REQ[k] for k in ("name", "version", "nonce")} + ) + + assert indy_proof_req == CANON_INDY_PROOF_REQ + + @pytest.mark.asyncio + async def test_satisfaction(self): + """Test presentation preview predicate satisfaction.""" + + pred_spec = PresPredSpec( + name="highScore", + cred_def_id=CD_ID, + predicate=Predicate.GE.value.math, + threshold=1000000 + ) + attr_spec = PresAttrSpec( + name="HIGHSCORE", + cred_def_id=CD_ID, + value=1234567 + ) + assert attr_spec.satisfies(pred_spec) + + +@pytest.mark.indy class TestPresentationPreview(TestCase): """Presentation preview tests""" @@ -172,125 +291,36 @@ def test_init(self): """Test initializer.""" assert PRES_PREVIEW.attributes assert PRES_PREVIEW.predicates - assert PRES_PREVIEW.non_revocation_times def test_type(self): """Test type.""" assert PRES_PREVIEW._type == PRESENTATION_PREVIEW - def test_indy_proof_request(self): - """Test to and from indy proof request.""" - - pres_preview = deepcopy(PRES_PREVIEW) - pres_preview.void_attribute_previews() - - assert ( - pres_preview == PresentationPreview.from_indy_proof_request(INDY_PROOF_REQ) - ) - assert pres_preview.indy_proof_request( - **{k: INDY_PROOF_REQ[k] for k in ("name", "version", "nonce")} - ) == INDY_PROOF_REQ - assert PRES_PREVIEW.indy_proof_request( - **{k: INDY_PROOF_REQ[k] for k in ("name", "version", "nonce")} - ) == INDY_PROOF_REQ - - def test_preview(self): - """Test preview for attr-values and attr-metadata utilities.""" - assert PRES_PREVIEW.attr_dict(decode=False) == { - CD_ID: { - "player": "Richie Knucklez", - "screenCapture": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" - } - } - assert PRES_PREVIEW.attr_dict(decode=True) == { - CD_ID: { - "player": "Richie Knucklez", - "screenCapture": "imagine a screen capture" - } - } - assert PRES_PREVIEW.attr_metadata() == { - CD_ID: { - "player": {}, - "screenCapture": { - "mime-type": "image/png", - "encoding": "base64" - } - } - } - assert PRES_PREVIEW.indy_proof_request("proof-req", "1.0", "12345") == { - "name": "proof-req", - "version": "1.0", - "nonce": "12345", - "requested_attributes": { - "0_player_uuid": { - "name": "player", - "restrictions": [ - { - "cred_def_id": CD_ID - } - ], - "non_revoked": { - "from": NOW_EPOCH, - "to": NOW_EPOCH - } - }, - "0_screencapture_uuid": { - "name": "screenCapture", - "restrictions": [ - { - "cred_def_id": CD_ID - } - ], - "non_revoked": { - "from": NOW_EPOCH, - "to": NOW_EPOCH - } - } - }, - "requested_predicates": { - "0_highscore_GE_uuid": { - "name": "highScore", - "p_type": ">=", - "p_value": 1000000, - "restrictions": [ - { - "cred_def_id": CD_ID - } - ], - "non_revoked": { - "from": NOW_EPOCH, - "to": NOW_EPOCH - } - } - } - } - def test_deserialize(self): """Test deserialization.""" dump = { "@type": PRESENTATION_PREVIEW, - "attributes": { - CD_ID: { - "player": { - "value": "Richie Knucklez" - }, - "screenCapture": { - "value": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl", - "encoding": "base64", - "mime-type": "image/png" - } + "attributes": [ + { + "name": "player", + "cred_def_id": CD_ID, + "value": "Richie Knucklez" + }, + { + "name": "screencapture", + "cred_def_id": CD_ID, + "mime-type": "image/png", + "value": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" } - }, - "predicates": { - CD_ID: { - ">=": { - "highScore": 1000000 - } + ], + "predicates": [ + { + "name": "highscore", + "cred_def_id": CD_ID, + "predicate": ">=", + "threshold": 1000000 } - }, - "non_revocation_times": { - CD_ID: NOW_8601 - } + ] } preview = PresentationPreview.deserialize(dump) @@ -302,36 +332,25 @@ def test_serialize(self): preview_dict = PRES_PREVIEW.serialize() assert preview_dict == { "@type": PRESENTATION_PREVIEW, - "attributes": { - CD_ID: { - "player": { - "value": "Richie Knucklez" - }, - "screenCapture": { - "value": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl", - "encoding": "base64", - "mime-type": "image/png" - } + "attributes": [ + { + "name": "player", + "cred_def_id": CD_ID, + "value": "Richie Knucklez" + }, + { + "name": "screencapture", + "cred_def_id": CD_ID, + "mime-type": "image/png", + "value": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" } - }, - "predicates": { - CD_ID: { - ">=": { - "highScore": 1000000 - } + ], + "predicates": [ + { + "name": "highscore", + "cred_def_id": CD_ID, + "predicate": ">=", + "threshold": 1000000 } - }, - "non_revocation_times": { - CD_ID: NOW_8601 - } + ] } - - -class TestPresentationPreviewSchema(TestCase): - """Test presentation preview schema""" - - def test_make_model(self): - """Test making model.""" - data = PRES_PREVIEW.serialize() - model_instance = PresentationPreview.deserialize(data) - assert isinstance(model_instance, PresentationPreview) diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation_proposal.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation_proposal.py index 8e9d0f84c5..931b8fa09b 100644 --- a/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation_proposal.py +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/tests/test_presentation_proposal.py @@ -1,30 +1,34 @@ from ..presentation_proposal import PresentationProposal -from ..inner.presentation_preview import PresentationAttrPreview, PresentationPreview +from ..inner.presentation_preview import PresAttrSpec, PresPredSpec, PresentationPreview from ...message_types import PRESENTATION_PREVIEW, PRESENTATION_PROPOSAL from unittest import TestCase -CD_ID = "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag" +S_ID = "NcYxiDXkpYi6ov5FcYDi1e:2:vidya:1.0" +CD_ID = f"NcYxiDXkpYi6ov5FcYDi1e:3:CL:{S_ID}:tag1" PRES_PREVIEW = PresentationPreview( - attributes={ - CD_ID: { - "player": PresentationAttrPreview(value="Richie Knucklez"), - "screenCapture": PresentationAttrPreview( - mime_type="image/png", - encoding="base64", - value="aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" - ) - } - }, - predicates={ - CD_ID: { - ">=": { - "highScore": "1000000" - } - } - }, - non_revocation_times={} + attributes=[ + PresAttrSpec( + name="player", + cred_def_id=CD_ID, + value="Richie Knucklez" + ), + PresAttrSpec( + name="screenCapture", + cred_def_id=CD_ID, + mime_type="image/png", + value="aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" + ) + ], + predicates=[ + PresPredSpec( + name="highScore", + cred_def_id=CD_ID, + predicate=">=", + threshold=1000000 + ) + ] ) @@ -52,29 +56,7 @@ def test_deserialize(self): obj = { "@type": PRESENTATION_PROPOSAL, "comment": "Hello World", - "presentation_proposal": { - "@type": PRESENTATION_PREVIEW, - "attributes": { - CD_ID: { - "player": { - "value": "Richie Knucklez" - }, - "screenCapture": { - "value": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl", - "encoding": "base64", - "mime-type": "image/png" - } - } - }, - "predicates": { - CD_ID: { - ">=": { - "highScore": 1000000 - } - } - }, - "non_revocation_times": {} - } + "presentation_proposal": PRES_PREVIEW.serialize() } pres_proposal = PresentationProposal.deserialize(obj) @@ -94,29 +76,7 @@ def test_serialize(self): assert pres_proposal_dict == { "@type": PRESENTATION_PROPOSAL, "comment": "Hello World", - "presentation_proposal": { - "@type": PRESENTATION_PREVIEW, - "attributes": { - CD_ID: { - "player": { - "value": "Richie Knucklez", - }, - "screenCapture": { - "value": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl", - "encoding": "base64", - "mime-type": "image/png" - } - } - }, - "predicates": { - CD_ID: { - ">=": { - "highScore": 1000000 - } - } - }, - "non_revocation_times": {} - } + "presentation_proposal": PRES_PREVIEW.serialize() } class TestPresentationProposalSchema(TestCase): diff --git a/aries_cloudagent/messaging/present_proof/v1_0/models/presentation_exchange.py b/aries_cloudagent/messaging/present_proof/v1_0/models/presentation_exchange.py index afc1494e9b..69a7ff5d52 100644 --- a/aries_cloudagent/messaging/present_proof/v1_0/models/presentation_exchange.py +++ b/aries_cloudagent/messaging/present_proof/v1_0/models/presentation_exchange.py @@ -15,7 +15,7 @@ class Meta: RECORD_TYPE = "v10_presentation_exchange" RECORD_ID_NAME = "presentation_exchange_id" - WEBHOOK_TOPIC = "Aries#0037 v1.0 presentations" + WEBHOOK_TOPIC = "aries37_v10_presentations" INITIATOR_SELF = "self" INITIATOR_EXTERNAL = "external" diff --git a/aries_cloudagent/messaging/present_proof/v1_0/routes.py b/aries_cloudagent/messaging/present_proof/v1_0/routes.py index 22650aba6b..8b6db20bfb 100644 --- a/aries_cloudagent/messaging/present_proof/v1_0/routes.py +++ b/aries_cloudagent/messaging/present_proof/v1_0/routes.py @@ -2,6 +2,8 @@ import json +from uuid import uuid4 + from aiohttp import web from aiohttp_apispec import docs, request_schema, response_schema from marshmallow import fields, Schema @@ -10,6 +12,15 @@ from ....storage.error import StorageNotFoundError from ...connections.models.connection_record import ConnectionRecord +from ...decorators.attach_decorator import AttachDecorator +from ...valid import ( + INDY_CRED_DEF_ID, + INDY_DID, + INDY_PREDICATE, + INDY_SCHEMA_ID, + INDY_VERSION, + INT_EPOCH +) from .manager import PresentationManager from .messages.inner.presentation_preview import ( @@ -17,6 +28,7 @@ PresentationPreviewSchema ) from .messages.presentation_proposal import PresentationProposal +from .messages.presentation_request import PresentationRequest from .models.presentation_exchange import ( V10PresentationExchange, V10PresentationExchangeSchema, @@ -40,7 +52,7 @@ class V10PresentationProposalRequestSchema(Schema): description="Human-readable comment", required=False, default="") - presentation_proposal = fields.Nested(PresentationPreviewSchema, required=True) + presentation_proposal = fields.Nested(PresentationPreviewSchema(), required=True) auto_present = fields.Boolean( description=( "Whether to respond automatically to presentation requests, building " @@ -51,26 +63,146 @@ class V10PresentationProposalRequestSchema(Schema): ) -class V10PresentationProposalResultSchema(V10PresentationExchangeSchema): - """Result schema for sending a presentation proposal admin message.""" +class IndyProofReqSpecRestrictionsSchema(Schema): + """Schema for restrictions in attr or pred specifier indy proof request.""" + + credential_definition_id = fields.Str( + description="Credential definition identifier", + required=True, + **INDY_CRED_DEF_ID + ) + schema_id = fields.String( + description="Schema identifier", + required=False, + **INDY_SCHEMA_ID + ) + schema_issuer_did = fields.String( + description="Schema issuer (origin) DID", + required=False, + **INDY_DID + ) + schema_name = fields.String( + example="transcript", + description="Schema name", + required=False + ) + schema_version = fields.String( + description="Schema version", + required=False, + **INDY_VERSION + ) + issuer_did = fields.String( + description="Credential issuer DID", + required=False, + **INDY_DID + ) + cred_def_id = fields.String( + description="Credential definition identifier", + required=False, + **INDY_CRED_DEF_ID + ) -class V10PresentationRequestRequestSchema(Schema): - """Request schema for sending a proof request.""" +class IndyProofReqNonRevoked(Schema): + """Non-revocation times specification in indy proof request.""" - connection_id = fields.UUID(description="Connection identifier", required=True) - name = fields.String(example="proof-request", description="Proof request name") - version = fields.String(example="1.0", description="Proof request version") - comment = fields.String( - description="Human-readable comment", + from_epoch = fields.Int( + description="Earliest epoch of interest for non-revocation proof", + required=True, + **INT_EPOCH + ) + to_epoch = fields.Int( + description="Latest epoch of interest for non-revocation proof", + required=True, + **INT_EPOCH + ) + + +class IndyProofReqAttrSpecSchema(Schema): + """Schema for attribute specification in indy proof request.""" + + name = fields.String( + example="favouriteDrink", + description="Attribute name", + required=True) + restrictions = fields.List( + fields.Nested(IndyProofReqSpecRestrictionsSchema()), + description="If present, credential must satisfy one of given restrictions", + required=False + ) + non_revoked = fields.Nested( + IndyProofReqNonRevoked(), + description="Non-revocation times of interest for revocable credentials", + required=False + ) + + +class IndyProofReqPredSpecSchema(Schema): + """Schema for predicate specification in indy proof request.""" + + name = fields.String( + example="index", + description="Attribute name", + required=True) + p_type: fields.String( + description="Predicate type (indy currently supports only '>=')", + required=True, + **INDY_PREDICATE + ) + p_value: fields.Integer( + description="Threshold value", + required=True, + ) + restrictions = fields.List( + fields.Nested(IndyProofReqSpecRestrictionsSchema()), + description="If present, credential must satisfy one of given restrictions", + required=False + ) + non_revoked = fields.Nested( + IndyProofReqNonRevoked(), + required=False + ) + + +class IndyProofRequestSchema(Schema): + """Schema for indy proof request.""" + + nonce = fields.String( + description="Nonce", + required=False, + example="1234567890" + ) + name = fields.String( + description="Proof request name", + required=False, + example="Proof request", + default="Proof request" + ) + version = fields.String( + description="Proof request version", required=False, - default="" + default="1.0", + **INDY_VERSION + ) + requested_attributes = fields.Dict( + description=("Requested attribute specifications of proof request"), + required=True, + keys=fields.Str(example="0_attr_uuid"), # marshmallow/apispec v3.0 ignores + values=fields.Nested(IndyProofReqAttrSpecSchema()) + ) + requested_predicates = fields.Dict( + description=("Requested predicate specifications of proof request"), + required=True, + keys=fields.Str(example="0_age_GE_uuid"), # marshmallow/apispec v3.0 ignores + values=fields.Nested(IndyProofReqPredSpecSchema()) ) - presentation_proposal = fields.Nested(PresentationPreviewSchema, required=True) -class V10PresentationRequestResultSchema(V10PresentationExchangeSchema): - """Result schema for sending a presentation request admin message.""" +class V10PresentationRequestRequestSchema(Schema): + """Request schema for sending a proof request.""" + + connection_id = fields.UUID(description="Connection identifier", required=True) + proof_request = fields.Nested(IndyProofRequestSchema(), required=True) class IndyRequestedCredsRequestedAttrSchema(Schema): @@ -105,7 +237,7 @@ class V10PresentationRequestSchema(Schema): self_attested_attributes = fields.Dict( description=("Self-attested attributes to build into proof"), required=True, - keys=fields.Str(example="attr_name"), # marshmallow/apispec v3.0rc3 ignores + keys=fields.Str(example="attr_name"), # marshmallow/apispec v3.0 ignores values=fields.Str( example="self_attested_value", description=( @@ -120,7 +252,7 @@ class V10PresentationRequestSchema(Schema): "requested-attribute specifiers" ), required=True, - keys=fields.Str(example="attr_referent"), # marshmallow/apispec v3.0rc3 ignores + keys=fields.Str(example="attr_referent"), # marshmallow/apispec v3.0 ignores values=fields.Nested(IndyRequestedCredsRequestedAttrSchema()) ) requested_predicates = fields.Dict( @@ -129,13 +261,13 @@ class V10PresentationRequestSchema(Schema): "requested-predicate specifiers" ), required=True, - keys=fields.Str(example="pred_referent"), # marshmallow/apispec v3.0rc3 ignores + keys=fields.Str(example="pred_referent"), # marshmallow/apispec v3.0 ignores values=fields.Nested(IndyRequestedCredsRequestedPredSchema()) ) @docs( - tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + tags=["*EXPERIMENTAL* aries#0037 v1.0 present-proof exchange"], summary="Fetch all present-proof exchange records" ) @response_schema(V10PresentationExchangeListSchema(), 200) @@ -166,7 +298,7 @@ async def presentation_exchange_list(request: web.BaseRequest): @docs( - tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + tags=["*EXPERIMENTAL* aries#0037 v1.0 present-proof exchange"], summary="Fetch a single presentation exchange record" ) @response_schema(V10PresentationExchangeSchema(), 200) @@ -194,7 +326,7 @@ async def presentation_exchange_retrieve(request: web.BaseRequest): @docs( - tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + tags=["*EXPERIMENTAL* aries#0037 v1.0 present-proof exchange"], summary="Fetch credentials for a presentation request from wallet", parameters=[ { @@ -231,7 +363,7 @@ async def presentation_exchange_credentials_list(request: web.BaseRequest): context = request.app["request_context"] presentation_exchange_id = request.match_info["pres_ex_id"] - presentation_referent = request.match_info["referent"] + presentation_referent = request.match_info.get("referent") try: presentation_exchange_record = await V10PresentationExchange.retrieve_by_id( @@ -261,15 +393,25 @@ async def presentation_exchange_credentials_list(request: web.BaseRequest): extra_query ) + presentation_exchange_record.log_state( + context, + "Retrieved presentation credentials", + { + "presentation_exchange_id": presentation_exchange_id, + "referent": presentation_referent, + "extra_query": extra_query, + "credentials": credentials + } + ) return web.json_response(credentials) @docs( - tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], - summary="Sends a presentation request" + tags=["*EXPERIMENTAL* aries#0037 v1.0 present-proof exchange"], + summary="Sends a presentation proposal" ) @request_schema(V10PresentationProposalRequestSchema()) -@response_schema(V10PresentationProposalResultSchema(), 200) +@response_schema(V10PresentationExchangeSchema(), 200) async def presentation_exchange_send_proposal(request: web.BaseRequest): """ Request handler for sending a presentation proposal. @@ -296,7 +438,7 @@ async def presentation_exchange_send_proposal(request: web.BaseRequest): raise web.HTTPBadRequest() if not connection_record.is_ready: - return web.HTTPForbidden() + raise web.HTTPForbidden() comment = body.get("comment") # Aries#0037 calls it a proposal in the proposal struct but it's of type preview @@ -325,11 +467,11 @@ async def presentation_exchange_send_proposal(request: web.BaseRequest): @docs( - tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + tags=["*EXPERIMENTAL* aries#0037 v1.0 present-proof exchange"], summary="Sends a free presentation request not bound to any proposal" ) @request_schema(V10PresentationRequestRequestSchema()) -@response_schema(V10PresentationRequestResultSchema(), 200) +@response_schema(V10PresentationExchangeSchema(), 200) async def presentation_exchange_send_free_request(request: web.BaseRequest): """ Request handler for sending a presentation request free from any proposal. @@ -356,33 +498,27 @@ async def presentation_exchange_send_free_request(request: web.BaseRequest): raise web.HTTPBadRequest() if not connection_record.is_ready: - return web.HTTPForbidden() + raise web.HTTPForbidden() comment = body.get("comment") - name = body.get("name", "proof-request") - version = body.get("version", "1.0") - presentation_proposal = body.get("presentation_proposal") - presentation_proposal_message = PresentationProposal( - comment=comment, - presentation_proposal=PresentationPreview.deserialize(presentation_proposal) - ) + indy_proof_request = body.get("proof_request") + if not indy_proof_request.get("nonce"): + indy_proof_request["nonce"] = str(uuid4().int) - presentation_exchange_record = V10PresentationExchange( - connection_id=connection_id, - initiator=V10PresentationExchange.INITIATOR_SELF, - presentation_proposal_dict=presentation_proposal_message.serialize() + presentation_request_message = PresentationRequest( + comment=comment, + request_presentations_attach=[ + AttachDecorator.from_indy_dict(indy_proof_request) + ] ) presentation_manager = PresentationManager(context) - ( - presentation_exchange_record, - presentation_request_message, - ) = await presentation_manager.create_request( - presentation_exchange_record, - name=name, - version=version, - comment=comment + presentation_exchange_record = ( + await presentation_manager.create_exchange_for_request( + connection_id=connection_id, + presentation_request_message=presentation_request_message + ) ) await outbound_handler(presentation_request_message, connection_id=connection_id) @@ -391,11 +527,11 @@ async def presentation_exchange_send_free_request(request: web.BaseRequest): @docs( - tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + tags=["*EXPERIMENTAL* aries#0037 v1.0 present-proof exchange"], summary="Sends a presentation request in reference to a proposal" ) @request_schema(V10PresentationRequestRequestSchema()) -@response_schema(V10PresentationRequestResultSchema(), 200) +@response_schema(V10PresentationExchangeSchema(), 200) async def presentation_exchange_send_bound_request(request: web.BaseRequest): """ Request handler for sending a presentation request free from any proposal. @@ -430,14 +566,14 @@ async def presentation_exchange_send_bound_request(request: web.BaseRequest): raise web.HTTPBadRequest() if not connection_record.is_ready: - return web.HTTPForbidden() + raise web.HTTPForbidden() presentation_manager = PresentationManager(context) ( presentation_exchange_record, presentation_request_message - ) = await presentation_manager.create_request( + ) = await presentation_manager.create_bound_request( presentation_exchange_record ) @@ -447,7 +583,7 @@ async def presentation_exchange_send_bound_request(request: web.BaseRequest): @docs( - tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + tags=["*EXPERIMENTAL* aries#0037 v1.0 present-proof exchange"], summary="Sends a proof presentation" ) @request_schema(V10PresentationRequestSchema()) @@ -483,7 +619,7 @@ async def presentation_exchange_send_presentation(request: web.BaseRequest): raise web.HTTPBadRequest() if not connection_record.is_ready: - return web.HTTPForbidden() + raise web.HTTPForbidden() assert ( presentation_exchange_record.state @@ -509,7 +645,7 @@ async def presentation_exchange_send_presentation(request: web.BaseRequest): @docs( - tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + tags=["*EXPERIMENTAL* aries#0037 v1.0 present-proof exchange"], summary="Verify a received presentation" ) @response_schema(V10PresentationExchangeSchema()) @@ -544,7 +680,7 @@ async def presentation_exchange_verify_presentation( raise web.HTTPBadRequest() if not connection_record.is_ready: - return web.HTTPForbidden() + raise web.HTTPForbidden() assert ( presentation_exchange_record.state @@ -560,7 +696,7 @@ async def presentation_exchange_verify_presentation( @docs( - tags=["*EXPERIMENTAL* Aries#0037 v1.0 present-proof exchange"], + tags=["*EXPERIMENTAL* aries#0037 v1.0 present-proof exchange"], summary="Remove an existing presentation exchange record", ) async def presentation_exchange_remove(request: web.BaseRequest): @@ -590,37 +726,44 @@ async def register(app: web.Application): app.add_routes( [ - web.get("/v1.0/present_proof_exchange", presentation_exchange_list), web.get( - "/v1.0/present_proof_exchange/{pres_ex_id}", + "/aries0037/v1.0/present_proof", + presentation_exchange_list + ), + web.get( + "/aries0037/v1.0/present_proof/{pres_ex_id}", presentation_exchange_retrieve ), web.get( - "/v1.0/present_proof_exchange/{pres_ex_id}/credentials/{referent}", + "/aries0037/v1.0/present_proof/{pres_ex_id}/credentials", + presentation_exchange_credentials_list, + ), + web.get( + "/aries0037/v1.0/present_proof/{pres_ex_id}/credentials/{referent}", presentation_exchange_credentials_list, ), web.post( - "/v1.0/present_proof_exchange/send_proposal", + "/aries0037/v1.0/present_proof/send_proposal", presentation_exchange_send_proposal, ), web.post( - "/v1.0/present_proof_exchange/send_request", + "/aries0037/v1.0/present_proof/send_request", presentation_exchange_send_free_request, ), web.post( - "/v1.0/present_proof_exchange/{pres_ex_id}/send_request", + "/aries0037/v1.0/present_proof/{pres_ex_id}/send_request", presentation_exchange_send_bound_request, ), web.post( - "/v1.0/present_proof_exchange/{pres_ex_id}/send_presentation", + "/aries0037/v1.0/present_proof/{pres_ex_id}/send_presentation", presentation_exchange_send_presentation, ), web.post( - "/v1.0/present_proof_exchange/{pres_ex_id}/verify_presentation", + "/aries0037/v1.0/present_proof/{pres_ex_id}/verify_presentation", presentation_exchange_verify_presentation, ), web.post( - "/v1.0/present_proof_exchange/{pres_ex_id}/remove", + "/aries0037/v1.0/present_proof/{pres_ex_id}/remove", presentation_exchange_remove ), ] diff --git a/aries_cloudagent/messaging/present_proof/v1_0/util/__init__.py b/aries_cloudagent/messaging/present_proof/v1_0/util/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/util/indy.py b/aries_cloudagent/messaging/present_proof/v1_0/util/indy.py similarity index 51% rename from aries_cloudagent/messaging/present_proof/v1_0/messages/util/indy.py rename to aries_cloudagent/messaging/present_proof/v1_0/util/indy.py index 30b8436c2c..086b2354bd 100644 --- a/aries_cloudagent/messaging/present_proof/v1_0/messages/util/indy.py +++ b/aries_cloudagent/messaging/present_proof/v1_0/util/indy.py @@ -4,23 +4,10 @@ from enum import Enum from typing import Any -Relation = namedtuple("Relation", "fortran wql math yes no") - - -def canon(raw_attr_name: str) -> str: - """ - Canonicalize input attribute name for indy proofs and credential offers. +from .....holder.base import BaseHolder - Args: - raw_attr_name: raw attribute name - - Returns: - canonicalized attribute name - """ - if raw_attr_name: # do not dereference None, and "" is already canonical - return raw_attr_name.replace(" ", "").lower() - return raw_attr_name +Relation = namedtuple("Relation", "fortran wql math yes no") class Predicate(Enum): @@ -76,3 +63,53 @@ def to_int(value: Any) -> int: if isinstance(value, (bool, int)): return int(value) return int(str(value)) # kick out floats + + +async def indy_proof_request2indy_requested_creds( + indy_proof_request: dict, + holder: BaseHolder +): + """ + Build indy requested-credentials structure. + + Given input proof request, use credentials in holder's wallet to + build indy requested credentials structure for input to proof creation. + + Args: + indy_proof_request: indy proof request + holder: holder injected into current context + + """ + req_creds = { + "self_attested_attributes": {}, + "requested_attributes": {}, + "requested_predicates": {} + } + + for category in ("requested_attributes", "requested_predicates"): + for referent in indy_proof_request[category]: + credentials = ( + await holder.get_credentials_for_presentation_request_by_referent( + indy_proof_request, + (referent,), + 0, + 2, + {} + ) + ) + if len(credentials) != 1: + raise ValueError( + f"Could not automatically construct presentation for " + + f"presentation request {indy_proof_request['name']}" + + f":{indy_proof_request['version']} because referent " + + f"{referent} did not produce exactly one credential " + + f"result. The wallet returned {len(credentials)} " + + f"matching credentials." + ) + + req_creds[category][referent] = { + "cred_id": credentials[0]["cred_info"]["referent"], + "revealed": True + } + + return req_creds diff --git a/aries_cloudagent/messaging/util.py b/aries_cloudagent/messaging/util.py index 913d246c76..62a70fd641 100644 --- a/aries_cloudagent/messaging/util.py +++ b/aries_cloudagent/messaging/util.py @@ -96,3 +96,19 @@ def datetime_now() -> datetime: def time_now() -> str: """Timestamp in ISO format.""" return datetime_to_str(datetime_now()) + + +def canon(raw_attr_name: str) -> str: + """ + Canonicalize input attribute name for indy proofs and credential offers. + + Args: + raw_attr_name: raw attribute name + + Returns: + canonicalized attribute name + + """ + if raw_attr_name: # do not dereference None, and "" is already canonical + return raw_attr_name.replace(" ", "").lower() + return raw_attr_name diff --git a/aries_cloudagent/messaging/valid.py b/aries_cloudagent/messaging/valid.py index ffa9fd7f53..228c41cffd 100644 --- a/aries_cloudagent/messaging/valid.py +++ b/aries_cloudagent/messaging/valid.py @@ -1,15 +1,45 @@ """Validators for schema fields.""" -from base58 import alphabet from datetime import datetime + +from base58 import alphabet from marshmallow.exceptions import ValidationError -from marshmallow.validate import OneOf, Regexp +from marshmallow.validate import OneOf, Range, Regexp from .util import epoch_to_str B58 = alphabet if isinstance(alphabet, str) else alphabet.decode("ascii") +class IntEpoch(Range): + """Validate value against (integer) epoch format.""" + + EXAMPLE = int(datetime.now().timestamp()) + + def __init__(self): + """Initializer.""" + + super().__init__( + min=0, + max=2147483647, + error="Value {input} is not a valid integer epoch time." + ) + + +class IndyDID(Regexp): + """Validate value against indy DID.""" + + EXAMPLE = "WgWxqztrNooG92RXvxSTWv" + + def __init__(self): + """Initializer.""" + + super().__init__( + rf"^[{B58}]{{21,22}}$", + error="Value {input} is not an indy decentralized identifier (DID)." + ) + + class IndyCredDefId(Regexp): """Validate value against indy credential definition identifier specification.""" @@ -19,11 +49,31 @@ def __init__(self): """Initializer.""" super().__init__( - rf"^[{B58}]{{21,22}}:3:CL:[1-9][0-9]*:.+$", + ( + rf"([{B58}]{{21,22}})" # issuer DID + f":3" # cred def id marker + f":CL" # sig alg + rf":(([1-9][0-9]*)|([{B58}]{{21,22}}:2:.+:[0-9.]+))" # schema txn / id + f"(.+)?$" # tag + ), error="Value {input} is not an indy credential definition identifier." ) +class IndyVersion(Regexp): + """Validate value against indy version specification.""" + + EXAMPLE = "1.0" + + def __init__(self): + """Initializer.""" + + super().__init__( + rf"^[0-9.]+$", + error="Value {input} is not an indy version (use only digits and '.')." + ) + + class IndySchemaId(Regexp): """Validate value against indy schema identifier specification.""" @@ -85,7 +135,7 @@ def __call__(self, value): if value is None or len(value) % 4: raise ValidationError(self.error) - + return super().__call__(value) @@ -104,10 +154,22 @@ def __init__(self): # Instances for marshmallow schema specification +INT_EPOCH = { + "validate": IntEpoch(), + "example": IntEpoch.EXAMPLE +} +INDY_DID = { + "validate": IndyDID(), + "example": IndyDID.EXAMPLE +} INDY_CRED_DEF_ID = { "validate": IndyCredDefId(), "example": IndyCredDefId.EXAMPLE } +INDY_VERSION = { + "validate": IndyVersion(), + "example": IndyVersion.EXAMPLE +} INDY_SCHEMA_ID = { "validate": IndySchemaId(), "example": IndySchemaId.EXAMPLE diff --git a/demo/AcmeDemoWorkshop.md b/demo/AcmeDemoWorkshop.md index b92deb5dad..2eb904dbae 100644 --- a/demo/AcmeDemoWorkshop.md +++ b/demo/AcmeDemoWorkshop.md @@ -59,27 +59,43 @@ First locate the code that is triggered by option ```2```: # TODO presentation requests ``` -Add the following code under the ```# TODO``` commment: - -``` - # TODO presentation requests - # ask for any degree, don't restrict to Faber (we can check the issuer when we receive the proof) - proof_attrs = [ - {"name": "name", "restrictions": [{"schema_name": "degree schema"}]}, - {"name": "date", "restrictions": [{"schema_name": "degree schema"}]}, - {"name": "degree", "restrictions": [{"schema_name": "degree schema"}]}, +Add the replace the ```# TODO``` commment: + +``` + req_attrs = [ + { + "name": "name", + "restrictions": [{"schema_name": "degree schema"}] + }, + { + "name": "date", + "restrictions": [{"schema_name": "degree schema"}] + }, + { + "name": "degree", + "restrictions": [{"schema_name": "degree schema"}] + } ] - proof_predicates = [] - proof_request = { + req_preds = [] + indy_proof_request = { "name": "Proof of Education", "version": "1.0", + "nonce": str(uuid4().int), + "requested_attributes": { + f"0_{req_attr['name']}_uuid": req_attr + for req_attr in req_attrs + }, + "requested_predicates": {} + } + proof_request_web_request = { "connection_id": agent.connection_id, - "requested_attributes": proof_attrs, - "requested_predicates": proof_predicates, + "proof_request": indy_proof_request } - # this sends the request to our agent, which forwards it to Alice (based on the connection_id) + # this sends the request to our agent, which forwards it to Alice + # (based on the connection_id) await agent.admin_POST( - "/presentation_exchange/send_request", proof_request + "/aries0037/v1.0/present_proof/send_request", + proof_request_web_request ) ``` @@ -91,29 +107,36 @@ Now we need to handle receipt of the proof. Locate the code that handles receiv pass ``` -Add the following code under the ```# TODO``` comment (replace ```pass```): +then replace the ```# TODO``` comment and the ```pass``` statement: ``` - # TODO handle received presentations - # if presentation is a degree schema (proof of education), check the received values - is_proof_of_education = (message['presentation_request']['name'] == 'Proof of Education') + # if presentation is a degree schema (proof of education), + # check values received + pres_req = message["presentation_request"] + pres = message["presentation"] + is_proof_of_education = ( + pres_req["name"] == "Proof of Education" + ) if is_proof_of_education: log_status("#28.1 Received proof of education, check claims") - for attr, value in message['presentation_request']['requested_attributes'].items(): - # just print out the received claim values - self.log(value['name'], message['presentation']['requested_proof']['revealed_attrs'][attr]['raw']) - for identifier in message['presentation']['identifiers']: + for (referent, attr_spec) in pres_req["requested_attributes"].items(): + self.log( + f"{attr_spec['name']}: " + f"{pres['requested_proof']['revealed_attrs'][referent]['raw']}" + ) + for id_spec in pres["identifiers"]: # just print out the schema/cred def id's of presented claims - self.log(identifier['schema_id'], identifier['cred_def_id']) + self.log(f"schema_id: {id_spec['schema_id']}") + self.log(f"cred_def_id {id_spec['cred_def_id']}") # TODO placeholder for the next step else: # in case there are any other kinds of proofs received - self.log("#28.1 Received ", message['presentation_request']['name']) + self.log("#28.1 Received ", message["presentation_request"]["name"]) ``` -Right now this just prints out information received in the proof, but in "real life" your application could do somethign useful with this information. +Right now this just prints out information received in the proof, but in "real life" your application could do something useful with this information. -Now you can run the Faber/Alice/Acme script from the "preview" section above, and you should see Acme receive a proof from Alice! +Now you can run the Faber/Alice/Acme script from the "Preview of the Acme Controller" section above, and you should see Acme receive a proof from Alice! ## Issuing Alice a Work Credential @@ -127,19 +150,48 @@ We're going to do option (a), but you can try to implement option (b) as homewor First though we need to register a schema and credential definition. Find this code: ``` + with log_timer("Publish schema duration:"): + pass # TODO define schema - #(schema_id, credential_definition_id) = await agent.register_schema_and_creddef( - # "employee id schema", version, ["employee_id", "name", "date", "position"] - # ) -``` - -... and just uncommment it. Easy, no? - -``` - # TODO define schema - (schema_id, credential_definition_id) = await agent.register_schema_and_creddef( - "employee id schema", version, ["employee_id", "name", "date", "position"] + # version = format( + # "%d.%d.%d" + # % ( + # random.randint(1, 101), + # random.randint(1, 101), + # random.randint(1, 101), + # ) + # ) + # ( + # schema_id, + # credential_definition_id, + # ) = await agent.register_schema_and_creddef( + # "employee id schema", + # version, + # ["employee_id", "name", "date", "position"], + # ) +``` + +... and just remove the ```pass``` statement and ```TODO ```, then uncommment the rest. Easy, no? + +``` + with log_timer("Publish schema duration:"): + # define schema + version = format( + "%d.%d.%d" + % ( + random.randint(1, 101), + random.randint(1, 101), + random.randint(1, 101), ) + ) + ( + schema_id, + credential_definition_id, + ) = await agent.register_schema_and_creddef( + "employee id schema", + version, + ["employee_id", "name", "date", "position"], + ) ``` For option (a) we want to replace the ```# TODO``` comment here: @@ -150,22 +202,29 @@ For option (a) we want to replace the ```# TODO``` comment here: # TODO credential offers ``` -Add the following code: +with the following code: ``` - # TODO credential offers - log_status("#13 Issue credential offer to X") - offer = { - "credential_definition_id": credential_definition_id, - "connection_id": agent.connection_id, - } agent.cred_attrs[credential_definition_id] = { "employee_id": "ACME0009", "name": "Alice Smith", - "date": "2019-06-30", - "position": "CEO", + "date": date.isoformat(date.today()), + "position": "CEO" + } + offer_request = { + "connection_id": agent.connection_id, + "credential_definition_id": credential_definition_id, + "comment": f"Offer on cred def id {credential_definition_id}", + "credential_preview": CredentialPreview( + attributes=CredAttrSpec.list_plain( + agent.cred_attrs[credential_definition_id] + ) + ).serialize() } - await agent.admin_POST("/credential_exchange/send-offer", offer) + await agent.admin_POST( + "/aries0036/v1.0/issue_credential/send_offer", + offer_request + ) ``` ... and then locate the code that handles the credential request callback: @@ -176,17 +235,20 @@ Add the following code: pass ``` -... and add the following code: +... and replace the ```# TODO``` comment and ```pass``` statement with the following code: ``` - # TODO issue credentials based on the credential_definition_id + # issue credentials based on the credential_definition_id cred_attrs = self.cred_attrs[message["credential_definition_id"]] await self.admin_POST( - f"/credential_exchange/{credential_exchange_id}/issue", - {"credential_values": cred_attrs}, + f"/aries0036/v1.0/issue_credential/{credential_exchange_id}/issue", + { + "comment": f"Issuing credential, exchange {credential_exchange_id}", + "credential_preview": CredentialPreview( + attributes=CredAttrSpec.list_plain(cred_attrs) + ).serialize() + } ) ``` Now you can run the Faber/Alice/Acme script again. You should be able to receive a proof and then issue a credential to Alice. - - diff --git a/demo/AriesOpenAPIDemo-DMV.md b/demo/AriesOpenAPIDemo-DMV.md index 01ae10e91e..0154e56e73 100644 --- a/demo/AriesOpenAPIDemo-DMV.md +++ b/demo/AriesOpenAPIDemo-DMV.md @@ -54,7 +54,7 @@ In one of the terminal windows, follow the [Running the Network Locally](https:/ To start the DMV agent, open up a second terminal window and in it change directory to the root of your clone of this repo and execute the following command: ```bash -PORTS="5000:5000 10000:10000" ./scripts/run_docker -it http 0.0.0.0 10000 -ot http --admin 0.0.0.0 5000 -e http://`docker run --net=host codenvy/che-ip`:10000 --genesis-url http://`docker run --net=host codenvy/che-ip`:9000/genesis --seed 00000000000000000000000000000000 --auto-ping-connection --auto-accept-invites --auto-accept-requests --auto-verify-presentation --wallet-type indy --label "DMV Agent" +PORTS="5000:5000 10000:10000" ./scripts/run_docker -it http 0.0.0.0 10000 -ot http --admin 0.0.0.0 5000 -e http://`docker run --net=host codenvy/che-ip`:10000 --genesis-url http://`docker run --net=host codenvy/che-ip`:9000/genesis --seed 00000000000000000000000000000000 --admin-insecure-mode --auto-ping-connection --auto-accept-invites --auto-accept-requests --auto-respond-credential-request --auto-verify-presentation --wallet-type indy --label "DMV Agent" ``` If all goes well, the agent will show a message indicating it is running. Use the second of the browser tabs to navigate to [http://localhost:5000](http://localhost:5000). You should see an OpenAPI user interface with a (long-ish) list of API endpoints. These are the endpoints exposed by the DMV Agent. @@ -66,7 +66,7 @@ The `run_docker` script provides a lot of options for configuring your agent. We To start Alice’s agent, open up a third terminal window and in it change directory to the root of your clone of this repo and execute the following command: ``` bash -PORTS="5001:5001 10001:10001" ./scripts/run_docker -it http 0.0.0.0 10001 -ot http --admin 0.0.0.0 5001 -e http://`docker run --net=host codenvy/che-ip`:10001 --genesis-url http://`docker run --net=host codenvy/che-ip`:9000/genesis --seed 00000000000000000000000000000001 --auto-ping-connection --accept-invites --accept-requests --auto-verify-presentation --auto-respond-credential-offer --auto-respond-presentation-request --wallet-type indy --label "Alice Agent" +PORTS="5001:5001 10001:10001" ./scripts/run_docker -it http 0.0.0.0 10001 -ot http --admin 0.0.0.0 5001 -e http://`docker run --net=host codenvy/che-ip`:10001 --genesis-url http://`docker run --net=host codenvy/che-ip`:9000/genesis --seed 00000000000000000000000000000001 --admin-insecure-mode --auto-ping-connection --auto-accept-invites --auto-accept-requests --auto-respond-credential-offer --auto-respond-presentation-request --auto-store-credential --wallet-type indy --label "Alice Agent" ``` If all goes well, the agent will show a message indicating it is running. Use the third tab to navigate to [http://localhost:5001](http://localhost:5001). Again, you should see an OpenAPI user interface with a list of API endpoints, this time the endpoints for Alice’s agent. @@ -75,8 +75,8 @@ If all goes well, the agent will show a message indicating it is running. Use th When you are done, or to stop the demo so you can restart it, carry out the following steps: -1. In the DMV and Alice agent terminal windows, hit Ctrl-C to terminate the agents. -2. In the `von-network` terminal window, hit Ctrl-C to stop the logging, and then run the command `./manage down` to both stop the network and remove the data on the ledger. +1. in the DMV and Alice agent terminal windows, hit Ctrl-C to terminate the agents; +2. in the `von-network` terminal window, hit Ctrl-C to stop the logging, and then run the command `./manage down` to both stop the network and remove the data on the ledger. ### Running the Demo Steps @@ -84,12 +84,12 @@ The demo is run entirely in the Browser tabs - DMV ([http://localhost:5000](http Using the OpenAPI user interface is pretty simple. In the steps below, we’ll indicate what API endpoint you need use, such as **`POST /connections/create-invitation`**. That means you must: -1. Scroll to and find that endpoint. -2. Click on the endpoint name to expand its section of the UI. -3. Click on the `Try it now` button. -4. Fill in any data necessary to run the command. -5. Click `Execute` -6. Check the response to see if the request worked. +1. scroll to and find that endpoint; +2. click on the endpoint name to expand its section of the UI; +3. click on the `Try it out` button. +4. fill in any data necessary to run the command. +5. click `Execute` +6. check the response to see if the request worked. So, the mechanical steps are easy. It’s fourth step from the list above that can be tricky. Supplying the right data and, where JSON is involved, getting the syntax correct - braces and quotes. When steps don’t work, start your debugging by looking at your JSON. @@ -143,10 +143,10 @@ To publish that schema, go to the DMV browser and get ready to execute the **`PO ``` JSONC { - "schema_name": "drivers-licence", "attributes": [ - "age" , "hair_colour" + "age", + "hair_colour" ], "schema_version": "1.0" } @@ -178,23 +178,30 @@ OK, we have the one time setup work for issuing a credential complete. We can no ## Issuing a Credential -Issuing a credential from the DMV agent to Alice’s agent is easy. In the DMV browser tab, scroll down to the **`POST /credential_exchange/send`** and get ready to (but don’t yet) execute the request. Before execution, you need to find some other data to complete the JSON. +Issuing a credential from the DMV agent to Alice’s agent is easy. In the DMV browser tab, scroll down to the **`POST /aries0036/v1.0/issue_credential/send`** and get ready to (but don’t yet) execute the request. Before execution, you need to find some other data to complete the JSON. -First, scroll back up to the **`GET /connections`** API endpoint and execute it. From the result, find the the `connection_id` and copy the value. Go back to the `/credential_exchange/send` section and paste it as the value for the `connection_id` +First, scroll back up to the **`GET /connections`** API endpoint and execute it. From the result, find the the `connection_id` and copy the value. Go back to the **`POST /aries0036/v1.0/issue_credential/send`** section and paste it as the value for the `connection_id`. -Next, scroll down to the **`POST /credential-definitions`** section that was executed in the previous step. Expand it (if necessary) and find and copy the value of the `credential_definition_id`. You could also get it from the Indy Ledger browser tab, or from earlier in this tutorial. Go back to the **`POST /credential_exchange/send`** section and paste it as the value for the `credential_defintion_id`. +Next, scroll down to the **`POST /credential-definitions`** section that you executed in the previous step. Expand it (if necessary) and find and copy the value of the `credential_definition_id`. You could also get it from the Indy Ledger browser tab, or from earlier in this tutorial. Go back to the **`POST /aries0036/v1.0/issue_credential/send`** section and paste it as the value for the `credential_defintion_id`. -Finally, for the credential values, put the following between the curly brackets: +Finally, for the credential values, put the following between the `attributes` square brackets: ``` -"age" : "19", "hair_colour" : "brown" + { + "name": "age", + "value": "19" + }, + { + "name": "hair_colour", + "value": "brown" + } ``` Ok, finally, you are ready to click `Execute`. The request should work, but if it doesn’t - check your JSON! Did you get all the quotes and commas right? -To confirm the issuance worked, scroll up to the top of the credential_exchange section and excute the **`GET /credential_exchange`** endpoint. You should see a lot of information about the exchange, including the state - `issued`. +To confirm the issuance worked, scroll up to the top of the `aries#0036 v1.0 issue-credential exchange` section and execute the **`GET /aries0036/v1.0/credential_exchange`** endpoint. You should see a lot of information about the exchange, including the state - `stored`. -Let’s look at it from Alice’s side. Switch to the Alice’s agent browser tab, find the `Credentials` section and within that, execute the **`GET /credentials`** endpoint. There should be a list of credentials held by Alice, with just a single entry, the credential issued from the DMV agent. +Let’s look at it from Alice’s side. Switch to the Alice’s agent browser tab, find the `credentials` section and within that, execute the **`GET /credentials`** endpoint. There should be a list of credentials held by Alice, with just a single entry, the credential issued from the DMV agent. You’ve done it, issued a credential! W00t! @@ -202,48 +209,54 @@ You’ve done it, issued a credential! W00t! Those that know something about the Indy process for issuing a credential and the DIDcomm `Issue Credential` protocol know that there a multiple steps to issuing credentials, a back and forth between the Issuer and the Holder to (at least) offer, request and issue the credential. All of those messages happened, but the two agents took care of those details rather than bothering the controller (you, in this case) with managing the back and forth. -* On the DMV agent side, this is because we used the **`POST /credential_exchange/send`** administrative message, which handles the back and forth for the issuer automatically. We could have used the other `/credential_exchange/` endpoint to allow the controller to handle each step of the protocol. -* On Alice's agent side, this is because in the startup options for the agent, we used the `--auto-respond-credential-offer` parameter. +* On the DMV agent side, this is because we used the **`POST /aries0036/v1.0/issue_credential/send`** administrative message, which handles the back and forth for the issuer automatically. We could have used the other `/aries0036/v1.0/issue_credential/` endpoints to allow the controller to handle each step of the protocol. +* On Alice's agent side, this is because in the startup options for the agent, we used the `--auto-respond-credential-offer` and `--auto-store-credential` parameters. ### Bonus Points -If you would like to manually perform all of the issuance steps on the DMV agent side, use a sequence of the other `/credential_exchange/` messages. Use the **`GET /credential_exchange`** to both check the credential exchange state as you progress through the protocol and to find some of the data you’ll need in executing the sequence of requests. If you want to run both the DMV and Alice sides in sequence, you’ll have to rerun the tutorial with Alice’s agent started without the `--auto-respond-credential-offer` parameter set. +If you would like to perform all of the issuance steps manually on the DMV agent side, use a sequence of the other `/aries0036/v1.0/issue_credential/` messages. Use the **`GET /aries0036/v1.0/credential_exchange`** to both check the credential exchange state as you progress through the protocol and to find some of the data you’ll need in executing the sequence of requests. If you want to run both the DMV and Alice sides in sequence, you’ll have to rerun the tutorial with Alice’s agent started without the `--auto-respond-credential-offer` and `--auto-store-credential` parameters set. ## Requesting/Presenting a Proof Alice now has her DMV credential. Let’s have the DMV agent send a request for a presentation (a proof) using that credential. This should be pretty easy for you at this point. -From the DMV browser tab, get ready to execute the **`POST /presentation_exchange/send_request`** endpoint. Replace the pre-populated text with the following. In doing so, use the techniques we used in issuing the credential to replace the `string` values for each instance of `cred_def_id` (there are two) and `connection_id`. +From the DMV browser tab, get ready to execute the **`POST /aries0037/v1.0/present_proof/send_request`** endpoint. Select the entire pre-populated text and replace it with the following. In doing so, use the techniques we used in issuing the credential to replace the sample values for each instance of `cred_def_id` (there are four) and `connection_id`. ``` JSONC { - "requested_predicates": [ - { - "name": "age", - "p_type": ">=", - "restrictions": [ - {"cred_def_id" : "string"} - ], - "p_value": 18 - } - ], - "requested_attributes": [ - { - "name": "hair_colour", - "restrictions": [ - {"cred_def_id" : "string"} - ] + "connection_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "proof_request": { + "name": "bar-checks", + "version": "1.0", + "requested_attributes": { + "0_hair_colour_uuid": { + "name": "hair_colour", + "restrictions": [ + { + "cred_def_id": "4QxzWk3ajdnEA37NdNU5Kt:3:CL:7:default" + } + ] + } + }, + "requested_predicates": { + "0_age_GE_uuid": { + "name": "age", + "p_type": ">=", + "p_value": 18, + "restrictions": [ + { + "cred_def_id": "4QxzWk3ajdnEA37NdNU5Kt:3:CL:7:default" + } + ] + } } - ], - "name": "bar-checks", - "version": "1.0", - "connection_id": "string" + } } ``` Notice that the proof request is using a predicate to check if Alice is older than 18 without asking for her age. Click `Execute` and cross your fingers. If the request fails check your JSON! -Note that in the response, the state is `request_sent`. That is because when the HTTP response was generated (immediately after sending the request), Alice’s agent had not yet responded to the request. We’ll have to do another request to verify the presentation worked. Copy the value of the `presentation_exchange_id` field from the response and use it in executing the **`GET /presentation_exchange/{id}`** endpoint. That should return a result showing a status of `verified`. Proof positive! +Note that in the response, the state is `request_sent`. That is because when the HTTP response was generated (immediately after sending the request), Alice’s agent had not yet responded to the request. We’ll have to do another request to verify the presentation worked. Copy the value of the `presentation_exchange_id` field from the response and use it in executing the **`GET /aries0037/v1.0/present_proof/{pres_ex_id}`** endpoint. That should return a result showing the state as `verified` and `verified` as `true`. Proof positive! ### Notes diff --git a/demo/AriesOpenAPIDemo.md b/demo/AriesOpenAPIDemo.md index 22ab5f4171..b1d61fa901 100644 --- a/demo/AriesOpenAPIDemo.md +++ b/demo/AriesOpenAPIDemo.md @@ -31,7 +31,7 @@ This demo is for developers comfortable with playing around with APIs using the ## Running in a Browser - +T We will get started by getting three browser tabs ready that will be used throughout the lab. Two will be Swagger UIs for the Faber and Alice Agent and one for the Public Ledger (showing the Hyperledger Indy ledger). In your browser, go to the docker playground service [Play with VON](http://play-with-von.vonx.io) (from the BC Gov). On the title screen, click "Start". On the next screen, click (in the left menu) "+Add a new instance". That will start up a terminal in your browser. Run the following commands to start the Faber agent. It is not a typo that the last line has "faber" in it. We're just borrowing that script to get started. @@ -117,12 +117,12 @@ The demo is run entirely in the browser tabs you've already opened - Faber, Alic Using the OpenAPI user interface is pretty simple. In the steps below, we’ll indicate what API endpoint you need use, such as **`POST /connections/create-invitation`**. That means you must: -1. Scroll to and find that endpoint. -2. Click on the endpoint name to expand its section of the UI. -3. Click on the `Try it now` button. -4. Fill in any data necessary to run the command. -5. Click `Execute` -6. Check the response to see if the request worked. +1. scroll to and find that endpoint; +2. click on the endpoint name to expand its section of the UI; +3. click on the `Try it out` button; +4. fill in any data necessary to run the command; +5. click `Execute`; +6. check the response to see if the request worked. So, the mechanical steps are easy. It’s fourth step from the list above that can be tricky. Supplying the right data and, where JSON is involved, getting the syntax correct - braces and quotes can be a pain. When steps don’t work, start your debugging by looking at your JSON. @@ -136,7 +136,7 @@ In the Faber browser tab, execute the **`POST /connections/create-invitation`**. Copy the entire block of the `invitation` object, from the curly brackets `{}`, excluding the trailing comma. -Switch to the Alice browser tab and get ready to execute the **`POST /connections/receive-invitation`** section. Erase the pre-populated text and paste the invitation object from the Faber tab. When you click `Execute` a you should get back a connection ID, an invitation key, and the state of the connection, which should be `requested`. +Switch to the Alice browser tab and get ready to execute the **`POST /connections/receive-invitation`** section. Select all of the pre-populated text and replace it with the invitation object from the Faber tab. When you click `Execute` a you should get back a connection ID, an invitation key, and the state of the connection, which should be `requested`. Scroll to and execute **`GET /connections`** to see a list of Alice's connections, and the information tracked about each connection. You should see the one connection Alice’s agent has, that it is with the Faber agent, and that its status is `active`. @@ -152,9 +152,9 @@ The next thing we want to do in the demo is have the Faber agent issue a credent Before the Faber agent can issue a credential, it must register a DID on the Indy public ledger, publish a schema, and create a credential definition. In the “real world”, the Faber agent would do this before connecting with any other agents. And, since we are using the handy "./run_demo faber" (and "./run_demo alice") scripts to start up our agents, the Faber version of the script has already: -1. Registered a public DID and stored it on the ledger -2. Created a schema and registered it on the ledger -3. Created a credential definition and registered it on the ledger +1. registered a public DID and stored it on the ledger; +2. created a schema and registered it on the ledger; +3. created a credential definition and registered it on the ledger. The schema and credential definition could also be created through this swagger interface. @@ -166,21 +166,36 @@ OK, the one time setup work for issuing a credential complete. We can now issue ## Issuing a Credential -Issuing a credential from the Faber agent to Alice’s agent is done with another API call. In the Faber browser tab, scroll down to the **`POST /credential_exchange/send`** and get ready to (but don’t yet) execute the request. Before execution, you need to find some other data to complete the JSON. +Issuing a credential from the Faber agent to Alice’s agent is done with another API call. In the Faber browser tab, scroll down to the **`POST /aries0036/v1.0/issue_credential/send`** and get ready to (but don’t yet) execute the request. Before execution, you need to find some other data to complete the JSON. First, scroll back up to the **`GET /connections`** API endpoint and execute it. From the result, find the the `connection_id` and copy the value. A little trickier to find is the `credential_definition_id`. Go back to the terminal where you started the Faber agent, and scroll back until you see the text `#3/4 Create a new schema/cred def on the ledger` and then just below that `Cred def Id:`. Copy the text following that label. Another way to get the `credential_definition_id` is to find it by searching the Indy network transactions posted to the ledger browser app. That works well if you are running locally by clicking the `Domain` link and using the search feature. However, that approach is harder to do when running in the browser, because there are many credential definitions on that ledger instance. -Now we need put into the JSON the data values for the credential. Cppy and paste the following between the curly brackets. Feel free to change the data values (but not the labels) as you see fit: +Now we need put into the JSON the data values for the credential. Copy and paste the following between the `attributes` square brackets. Feel free to change the attribute values (but neither the labels nor the names) as you see fit: ``` -"name": "Alice Smith", "date": "2018-05-28", "degree": "Maths", "age": "24" + { + "name": "name", + "value": "Alice Smith" + }, + { + "name": "date", + "value": "2018-05-28" + }, + { + "name": "degree", + "value": "Maths" + }, + { + "name": "age", + "value": "24" + } ``` Ok, finally, you are ready to click `Execute`. The request should work, but if it doesn’t - check your JSON! Did you get all the quotes and commas right? -To confirm the issuance worked, scroll up to the top of the credential_exchange section and execute the **`GET /credential_exchange`** endpoint. You should see a lot of information about the exchange, including the state - `issued`. +To confirm the issuance worked, scroll up to the top of the `aries#0036 v1.0 issue-credential exchange` section and execute the **`GET /aries0036/v1.0/credential_exchange`** endpoint. You should see a lot of information about the exchange, including the state - `stored`. -Let’s look at it from Alice’s side. Switch to the Alice’s agent browser tab, find the `Credentials` section and within that, execute the **`GET /credentials`** endpoint. There should be a list of credentials held by Alice, with just a single entry, the credential issued from the Faber agent. +Let’s look at it from Alice’s side. Switch to the Alice’s agent browser tab, find the `credentials` section and within that, execute the **`GET /credentials`** endpoint. There should be a list of credentials held by Alice, with just a single entry, the credential issued from the Faber agent. You’ve done it, issued a credential! W00t! @@ -188,54 +203,73 @@ You’ve done it, issued a credential! W00t! Those that know something about the Indy process for issuing a credential and the DIDcomm `Issue Credential` protocol know that there a multiple steps to issuing credentials, a back and forth between the Issuer and the Holder to (at least) offer, request and issue the credential. All of those messages happened, but the two agents took care of those details rather than bothering the controller (you, in this case) with managing the back and forth. -* On the Faber agent side, this is because we used the **`POST /credential_exchange/send`** administrative message, which handles the back and forth for the issuer automatically. We could have used the other `/credential_exchange/` endpoint to allow the controller to handle each step of the protocol. -* On Alice's agent side, this is because in the startup options for the agent, we used the `--auto-respond-credential-offer` parameter. +* On the Faber agent side, this is because we used the **`POST /aries0036/v1.0/issue_credential/send`** administrative message, which handles the back and forth for the issuer automatically. We could have used the other `/aries0036/v1.0/issue_credential/` endpoints to allow the controller to handle each step of the protocol. +* On Alice's agent side, this is because in the startup options for the agent, we used the `--auto-respond-credential-offer` and `--auto-store-credential` parameters. ### Bonus Points -If you would like to manually perform all of the issuance steps on the Faber agent side, use a sequence of the other `/credential_exchange/` messages. Use the **`GET /credential_exchange`** to both check the credential exchange state as you progress through the protocol and to find some of the data you’ll need in executing the sequence of requests. +If you would like to perform all of the issuance steps manually on the Faber agent side, use a sequence of the other `/aries0036/v1.0/issue_credential/` messages. Use the **`GET /aries0036/v1.0/credential_exchange`** to both check the credential exchange state as you progress through the protocol and to find some of the data you’ll need in executing the sequence of requests. ## Requesting/Presenting a Proof Alice now has her Faber credential. Let’s have the Faber agent send a request for a presentation (a proof) using that credential. This should be pretty easy for you at this point. -From the Faber browser tab, get ready to execute the **`POST /presentation_exchange/send_request`** endpoint. Replace the pre-populated text with the following. In doing so, use the techniques we used in issuing the credential to replace the `string` values for each instance of `cred_def_id` (there are three) and `connection_id`. +From the Faber browser tab, get ready to execute the **`POST /aries0037/v1.0/present_proof/send_request`** endpoint. Select the entire pre-populated text and replace it with the following. In doing so, use the techniques we used in issuing the credential to replace the sample values for each instance of `cred_def_id` (there are four) and `connection_id`. ``` JSONC { - "requested_predicates": [ - { - "name": "age", - "p_type": ">=", - "restrictions": [ - {"cred_def_id" : "string"} - ], - "p_value": 18 - } - ], - "requested_attributes": [ - { - "name": "name", - "restrictions": [ - {"cred_def_id" : "string"} - ] + "connection_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "proof_request": { + "name": "Proof of Education", + "version": "1.0", + "requested_attributes": { + "0_name_uuid": { + "name": "name", + "restrictions": [ + { + "cred_def_id": "WgWxqztrNooG92RXvxSTWv:3:CL:20:tag" + } + ] + }, + "0_date_uuid": { + "name": "date", + "restrictions": [ + { + "cred_def_id": "WgWxqztrNooG92RXvxSTWv:3:CL:20:tag" + } + ] + }, + "0_degree_uuid": { + "name": "degree", + "restrictions": [ + { + "cred_def_id": "WgWxqztrNooG92RXvxSTWv:3:CL:20:tag" + } + ] + }, + "0_self_attested_thing_uuid": { + "name": "self_attested_thing" + } }, - { - "name": "degree", - "restrictions": [ - {"cred_def_id" : "string"} - ] + "requested_predicates": { + "0_age_GE_uuid": { + "name": "age", + "p_type": ">=", + "p_value": 18, + "restrictions": [ + { + "cred_def_id": "WgWxqztrNooG92RXvxSTWv:3:CL:20:tag" + } + ] + } } - ], - "name": "Proof of Education", - "version": "1.0", - "connection_id": "string" + } } ``` Notice that the proof request is using a predicate to check if Alice is older than 18 without asking for her age. (Not sure what this has to do with her education level!) Click `Execute` and cross your fingers. If the request fails check your JSON! -Note that in the response, the state is `request_sent`. That is because when the HTTP response was generated (immediately after sending the request), Alice’s agent had not yet responded to the request. We’ll have to do another request to verify the presentation worked. Copy the value of the `presentation_exchange_id` field from the response and use it in executing the **`GET /presentation_exchange/{id}`** endpoint. That should return a result showing a status of `verified`. Proof positive! +Note that in the response, the state is `request_sent`. That is because when the HTTP response was generated (immediately after sending the request), Alice’s agent had not yet responded to the request. We’ll have to do another request to verify the presentation worked. Copy the value of the `presentation_exchange_id` field from the response and use it in executing the **`GET /aries0037/v1.0/present_proof/{pres_ex_id}`** endpoint. That should return a result showing the state as `verified` and `verified` as `true`. Proof positive! ### Notes diff --git a/demo/runners/acme.py b/demo/runners/acme.py index e88e1dd5dc..8c3dde4736 100644 --- a/demo/runners/acme.py +++ b/demo/runners/acme.py @@ -2,10 +2,18 @@ import json import logging import os +import random import sys +from datetime import date + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # noqa +from aries_cloudagent.messaging.issue_credential.v1_0.messages.inner.credential_preview import ( + CredAttrSpec, + CredentialPreview +) + from runners.support.agent import DemoAgent, default_genesis_txns from runners.support.utils import ( log_json, @@ -22,7 +30,14 @@ class AcmeAgent(DemoAgent): def __init__(self, http_port: int, admin_port: int, **kwargs): - super().__init__("Acme Agent", http_port, admin_port, prefix="Acme", **kwargs) + super().__init__( + "Acme Agent", + http_port, + admin_port, + prefix="Acme", + extra_args=["--auto-accept-invites", "--auto-accept-requests"], + **kwargs + ) self.connection_id = None self._connection_ready = asyncio.Future() self.cred_state = {} @@ -43,7 +58,7 @@ async def handle_connections(self, message): self.log("Connected") self._connection_ready.set_result(True) - async def handle_credentials(self, message): + async def handle_aries36_v10_credentials(self, message): state = message["state"] credential_exchange_id = message["credential_exchange_id"] prev_state = self.cred_state.get(credential_exchange_id) @@ -62,7 +77,7 @@ async def handle_credentials(self, message): # TODO issue credentials based on the credential_definition_id pass - async def handle_presentations(self, message): + async def handle_aries37_v10_presentations(self, message): state = message["state"] presentation_exchange_id = message["presentation_exchange_id"] diff --git a/demo/runners/alice.py b/demo/runners/alice.py index 18590e8a4d..e4a75d8713 100644 --- a/demo/runners/alice.py +++ b/demo/runners/alice.py @@ -17,6 +17,7 @@ require_indy, ) + LOGGER = logging.getLogger(__name__) @@ -52,7 +53,7 @@ async def handle_connections(self, message): self.log("Connected") self._connection_ready.set_result(True) - async def handle_credentials(self, message): + async def handle_aries36_v10_credentials(self, message): state = message["state"] credential_exchange_id = message["credential_exchange_id"] prev_state = self.cred_state.get(credential_exchange_id) @@ -70,12 +71,15 @@ async def handle_credentials(self, message): if state == "offer_received": log_status("#15 After receiving credential offer, send credential request") await self.admin_POST( - f"/credential_exchange/{credential_exchange_id}/send-request" + "/aries0036/v1.0/issue_credential/" + f"{credential_exchange_id}/send_request" ) elif state == "stored": - self.log("Stored credential in wallet") + # elif state == "credential_received": ?? + self.log("Storing credential in wallet") cred_id = message["credential_id"] + log_status(f"#18.1 Stored credential {cred_id} in wallet") resp = await self.admin_GET(f"/credential/{cred_id}") log_json(resp, label="Credential details:") log_json( @@ -86,7 +90,7 @@ async def handle_credentials(self, message): self.log("credential_definition_id", message["credential_definition_id"]) self.log("schema_id", message["schema_id"]) - async def handle_presentations(self, message): + async def handle_aries37_v10_presentations(self, message): state = message["state"] presentation_exchange_id = message["presentation_exchange_id"] presentation_request = message["presentation_request"] @@ -111,7 +115,7 @@ async def handle_presentations(self, message): # select credentials to provide for the proof credentials = await self.admin_GET( - f"/presentation_exchange/{presentation_exchange_id}/credentials" + f"/aries0037/v1.0/present_proof/{presentation_exchange_id}/credentials" ) if credentials: for row in credentials: @@ -140,9 +144,7 @@ async def handle_presentations(self, message): } log_status("#25 Generate the proof") - proof = { - "name": presentation_request["name"], - "version": presentation_request["version"], + request = { "requested_predicates": predicates, "requested_attributes": revealed, "self_attested_attributes": self_attested, @@ -150,8 +152,11 @@ async def handle_presentations(self, message): log_status("#26 Send the proof to X") await self.admin_POST( - f"/presentation_exchange/{presentation_exchange_id}/send_presentation", - proof, + ( + "/aries0037/v1.0/present_proof/" + f"{presentation_exchange_id}/send_presentation" + ), + request, ) async def handle_basicmessages(self, message): diff --git a/demo/runners/faber.py b/demo/runners/faber.py index 9f3fa239ba..6e6c1ccad9 100644 --- a/demo/runners/faber.py +++ b/demo/runners/faber.py @@ -5,8 +5,14 @@ import random import sys +from uuid import uuid4 + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # noqa +from aries_cloudagent.messaging.issue_credential.v1_0.messages.inner.credential_preview import ( + CredAttrSpec, + CredentialPreview +) from runners.support.agent import DemoAgent, default_genesis_txns from runners.support.utils import ( log_json, @@ -29,7 +35,7 @@ def __init__(self, http_port: int, admin_port: int, **kwargs): admin_port, prefix="Faber", extra_args=["--auto-accept-invites", "--auto-accept-requests"], - **kwargs, + **kwargs ) self.connection_id = None self._connection_ready = asyncio.Future() @@ -51,7 +57,7 @@ async def handle_connections(self, message): self.log("Connected") self._connection_ready.set_result(True) - async def handle_credentials(self, message): + async def handle_aries36_v10_credentials(self, message): state = message["state"] credential_exchange_id = message["credential_exchange_id"] prev_state = self.cred_state.get(credential_exchange_id) @@ -71,11 +77,16 @@ async def handle_credentials(self, message): # issue credentials based on the credential_definition_id cred_attrs = self.cred_attrs[message["credential_definition_id"]] await self.admin_POST( - f"/credential_exchange/{credential_exchange_id}/issue", - {"credential_values": cred_attrs}, + f"/aries0036/v1.0/issue_credential/{credential_exchange_id}/issue", + { + "comment": f"Issuing credential, exchange {credential_exchange_id}", + "credential_preview": CredentialPreview( + attributes=CredAttrSpec.list_plain(cred_attrs) + ).serialize() + } ) - async def handle_presentations(self, message): + async def handle_aries37_v10_presentations(self, message): state = message["state"] presentation_exchange_id = message["presentation_exchange_id"] @@ -90,7 +101,8 @@ async def handle_presentations(self, message): log_status("#27 Process the proof provided by X") log_status("#28 Check if proof is valid") proof = await self.admin_POST( - f"/presentation_exchange/{presentation_exchange_id}/verify_presentation" + f"/aries0037/v1.0/present_proof/{presentation_exchange_id}/" + "verify_presentation" ) self.log("Proof =", proof["verified"]) @@ -132,7 +144,7 @@ async def main(start_port: int, show_timing: bool = False): ) ) ( - schema_id, + _, # schema id credential_definition_id, ) = await agent.register_schema_and_creddef( "degree schema", version, ["name", "date", "degree", "age"] @@ -165,10 +177,7 @@ async def main(start_port: int, show_timing: bool = False): elif option == "1": log_status("#13 Issue credential offer to X") - offer = { - "credential_definition_id": credential_definition_id, - "connection_id": agent.connection_id, - } + # TODO define attributes to send for credential agent.cred_attrs[credential_definition_id] = { "name": "Alice Smith", @@ -176,34 +185,67 @@ async def main(start_port: int, show_timing: bool = False): "degree": "Maths", "age": "24", } - await agent.admin_POST("/credential_exchange/send-offer", offer) + + offer_request = { + "connection_id": agent.connection_id, + "credential_definition_id": credential_definition_id, + "comment": f"Offer on cred def id {credential_definition_id}", + "credential_preview": CredentialPreview( + attributes=CredAttrSpec.list_plain( + agent.cred_attrs[credential_definition_id] + ) + ).serialize() + } + await agent.admin_POST( + "/aries0036/v1.0/issue_credential/send_offer", + offer_request + ) # TODO issue an additional credential for Student ID elif option == "2": log_status("#20 Request proof of degree from alice") - proof_attrs = [ + req_attrs = [ {"name": "name", "restrictions": [{"issuer_did": agent.did}]}, {"name": "date", "restrictions": [{"issuer_did": agent.did}]}, {"name": "degree", "restrictions": [{"issuer_did": agent.did}]}, - {"name": "self_attested_thing"}, + {"name": "self_attested_thing"} + ] + req_preds = [ + { + "name": "age", + "p_type": ">=", + "p_value": 18, + "restrictions": [{"issuer_did": agent.did}] + } ] - proof_predicates = [{"name": "age", "p_type": ">=", "p_value": 18}] - proof_request = { + indy_proof_request = { "name": "Proof of Education", "version": "1.0", + "nonce": str(uuid4().int), + "requested_attributes": { + f"0_{req_attr['name']}_uuid": req_attr + for req_attr in req_attrs + }, + "requested_predicates": { + f"0_{req_pred['name']}_GE_uuid": req_pred + for req_pred in req_preds + } + } + proof_request_web_request = { "connection_id": agent.connection_id, - "requested_attributes": proof_attrs, - "requested_predicates": proof_predicates, + "proof_request": indy_proof_request } await agent.admin_POST( - "/presentation_exchange/send_request", proof_request + "/aries0037/v1.0/present_proof/send_request", + proof_request_web_request ) elif option == "3": msg = await prompt("Enter message: ") await agent.admin_POST( - f"/connections/{agent.connection_id}/send-message", {"content": msg} + f"/connections/{agent.connection_id}/send-message", + {"content": msg} ) if show_timing: diff --git a/demo/runners/support/agent.py b/demo/runners/support/agent.py index 8e24e44fe3..61dd9b6f85 100644 --- a/demo/runners/support/agent.py +++ b/demo/runners/support/agent.py @@ -331,9 +331,16 @@ async def _receive_webhook(self, request: ClientRequest): async def handle_webhook(self, topic: str, payload): if topic != "webhook": # would recurse - method = getattr(self, f"handle_{topic}", None) + handler = f"handle_{topic}" + method = getattr(self, handler, None) if method: await method(payload) + else: + log_msg( + f"Error: agent {self.ident} " + f"has no method {handler} " + f"to handle webhook on topic {topic}" + ) async def admin_request(self, method, path, data=None, text=False, params=None): params = {k: v for (k, v) in (params or {}).items() if v is not None} diff --git a/scripts/run_tests_indy b/scripts/run_tests_indy index 80d0c75f21..bd0f791ed2 100755 --- a/scripts/run_tests_indy +++ b/scripts/run_tests_indy @@ -24,6 +24,6 @@ if [ ! -z "$POSTGRES_URL" ]; then fi $DOCKER run --rm -ti --name aries-cloudagent-runner \ - -v "$(pwd)/../test-reports:/home/indy/src/test-reports" \ + -v "$(pwd)/../test-reports:/home/indy/src/app/test-reports" \ $DOCKER_ARGS \ aries-cloudagent-test "$@" From 037315e6c3baf70d932a8aec30635ffeed75b4fb Mon Sep 17 00:00:00 2001 From: sklump Date: Fri, 30 Aug 2019 13:15:40 -0400 Subject: [PATCH 4/4] tweak and unit test for self-attested attrs in proof req from proof preview Signed-off-by: sklump --- .../v1_0/tests/test_routes.py | 7 +++--- .../messages/inner/presentation_preview.py | 20 ++++++++------- .../inner/tests/test_presentation_preview.py | 25 ++++++++++++++++++- .../messaging/present_proof/v1_0/util/indy.py | 2 +- 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/aries_cloudagent/messaging/issue_credential/v1_0/tests/test_routes.py b/aries_cloudagent/messaging/issue_credential/v1_0/tests/test_routes.py index a1f646ede6..d2fb916839 100644 --- a/aries_cloudagent/messaging/issue_credential/v1_0/tests/test_routes.py +++ b/aries_cloudagent/messaging/issue_credential/v1_0/tests/test_routes.py @@ -34,15 +34,14 @@ async def test_credential_exchange_send(self): mock_cred_ex_record = async_mock.MagicMock() - mock_credential_manager.return_value.create_offer.return_value = ( - mock_cred_ex_record, - async_mock.MagicMock(), + mock_credential_manager.return_value.prepare_send.return_value = ( + mock_cred_ex_record ) await test_module.credential_exchange_send(mock) test_module.web.json_response.assert_called_once_with( - mock_credential_manager.return_value.prepare_send.return_value.serialize.return_value + mock_cred_ex_record.serialize.return_value ) async def test_credential_exchange_send_no_conn_record(self): diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/presentation_preview.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/presentation_preview.py index 6ef1cd18fa..90fc6336bb 100644 --- a/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/presentation_preview.py +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/presentation_preview.py @@ -325,13 +325,17 @@ def ord_cred_def_id(cred_def_id: str): } for attr_spec in self.attributes: - cd_id = attr_spec.cred_def_id - revo_support = bool(ledger and await ledger.get_credential_definition( - cd_id - )["value"]["revocation"]) + if attr_spec.posture == PresAttrSpec.Posture.SELF_ATTESTED: + proof_req["requested_attributes"][f"{canon(attr_spec.name)}"] = { + "name": canon(attr_spec.name) + } + else: + cd_id = attr_spec.cred_def_id + revo_support = bool(ledger and await ledger.get_credential_definition( + cd_id + )["value"]["revocation"]) - timestamp = non_revo(attr_spec.cred_def_id) - if attr_spec.posture != PresAttrSpec.Posture.SELF_ATTESTED: + timestamp = non_revo(attr_spec.cred_def_id) proof_req["requested_attributes"][ "{}_{}_uuid".format( ord_cred_def_id(cd_id), @@ -339,9 +343,7 @@ def ord_cred_def_id(cred_def_id: str): ) ] = { "name": canon(attr_spec.name), - "restrictions": [ - {"cred_def_id": cd_id} - ], + "restrictions": [{"cred_def_id": cd_id}], **{ "non_revoked": { "from": timestamp, diff --git a/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py index 62b6f80f07..f20e615c81 100644 --- a/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py +++ b/aries_cloudagent/messaging/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py @@ -256,7 +256,7 @@ async def test_to_indy_proof_request(self): spec["name"] = canon(spec["name"]) for spec in CANON_INDY_PROOF_REQ["requested_predicates"].values(): spec["name"] = canon(spec["name"]) - + pres_preview = deepcopy(PRES_PREVIEW) indy_proof_req = await pres_preview.indy_proof_request( @@ -265,6 +265,22 @@ async def test_to_indy_proof_request(self): assert indy_proof_req == CANON_INDY_PROOF_REQ + async def test_to_indy_proof_request_self_attested(self): + """Test presentation preview to inty proof request with self-attested values.""" + + pres_preview_selfie = deepcopy(PRES_PREVIEW) + for attr_spec in pres_preview_selfie.attributes: + attr_spec.cred_def_id=None + + indy_proof_req_selfie = await pres_preview_selfie.indy_proof_request( + **{k: INDY_PROOF_REQ[k] for k in ("name", "version", "nonce")} + ) + + assert not any( + "restrictions" in attr_spec + for attr_spec in indy_proof_req_selfie["requested_attributes"].values() + ) + @pytest.mark.asyncio async def test_satisfaction(self): """Test presentation preview predicate satisfaction.""" @@ -282,6 +298,13 @@ async def test_satisfaction(self): ) assert attr_spec.satisfies(pred_spec) + attr_spec = PresAttrSpec( + name="HIGHSCORE", + cred_def_id=CD_ID, + value=985260 + ) + assert not attr_spec.satisfies(pred_spec) + @pytest.mark.indy class TestPresentationPreview(TestCase): diff --git a/aries_cloudagent/messaging/present_proof/v1_0/util/indy.py b/aries_cloudagent/messaging/present_proof/v1_0/util/indy.py index 086b2354bd..1890884444 100644 --- a/aries_cloudagent/messaging/present_proof/v1_0/util/indy.py +++ b/aries_cloudagent/messaging/present_proof/v1_0/util/indy.py @@ -109,7 +109,7 @@ async def indy_proof_request2indy_requested_creds( req_creds[category][referent] = { "cred_id": credentials[0]["cred_info"]["referent"], - "revealed": True + "revealed": True # TODO allow specification of unrevealed attrs? } return req_creds