diff --git a/.gitignore b/.gitignore index f78373d767..2ba41336e2 100644 --- a/.gitignore +++ b/.gitignore @@ -185,3 +185,4 @@ $RECYCLE.BIN/ # Docs build _build/ +**/*.iml diff --git a/aries_cloudagent/config/default_context.py b/aries_cloudagent/config/default_context.py index c94cac6015..dd6957565d 100644 --- a/aries_cloudagent/config/default_context.py +++ b/aries_cloudagent/config/default_context.py @@ -82,10 +82,10 @@ async def bind_providers(self, context: InjectionContext): StatsProvider( LedgerProvider(), ( + "create_and_send_credential_definition", + "create_and_send_schema", "get_credential_definition", "get_schema", - "send_credential_definition", - "send_schema", ), ) ), @@ -154,6 +154,7 @@ async def load_plugins(self, context: InjectionContext): "aries_cloudagent.messaging.credential_definitions" ) plugin_registry.register_plugin("aries_cloudagent.messaging.schemas") + plugin_registry.register_plugin("aries_cloudagent.revocation") plugin_registry.register_plugin("aries_cloudagent.wallet") # Register external plugins diff --git a/aries_cloudagent/holder/base.py b/aries_cloudagent/holder/base.py index 7f1e145d71..ed81df4878 100644 --- a/aries_cloudagent/holder/base.py +++ b/aries_cloudagent/holder/base.py @@ -1,9 +1,10 @@ """Base holder class.""" -from abc import ABC +from abc import ABC, ABCMeta, abstractmethod +from typing import Union -class BaseHolder(ABC): +class BaseHolder(ABC, metaclass=ABCMeta): """Base class for holder.""" def __repr__(self) -> str: @@ -15,3 +16,101 @@ def __repr__(self) -> str: """ return "<{}>".format(self.__class__.__name__) + + @abstractmethod + async def get_credential(self, credential_id: str): + """ + Get a credential stored in the wallet. + + Args: + credential_id: Credential id to retrieve + + """ + + @abstractmethod + async def delete_credential(self, credential_id: str): + """ + Remove a credential stored in the wallet. + + Args: + credential_id: Credential id to remove + + """ + + @abstractmethod + async def get_mime_type( + self, credential_id: str, attr: str = None + ) -> Union[dict, str]: + """ + Get MIME type per attribute (or for all attributes). + + Args: + credential_id: credential id + attr: attribute of interest or omit for all + + Returns: Attribute MIME type or dict mapping attribute names to MIME types + attr_meta_json = all_meta.tags.get(attr) + + """ + + @abstractmethod + async def create_presentation( + self, + presentation_request: dict, + requested_credentials: dict, + schemas: dict, + credential_definitions: dict, + rev_states_json: dict = None, + ): + """ + Get credentials stored in the wallet. + + Args: + presentation_request: Valid indy format presentation request + requested_credentials: Indy format requested_credentials + schemas: Indy formatted schemas_json + credential_definitions: Indy formatted schemas_json + rev_states_json: Indy format revocation states + """ + + @abstractmethod + async def create_credential_request( + self, credential_offer, credential_definition, holder_did: str + ): + """ + Create a credential offer for the given credential definition id. + + Args: + credential_offer: The credential offer to create request for + credential_definition: The credential definition to create an offer for + holder_did: the DID of the agent making the request + + Returns: + A credential request + + """ + + @abstractmethod + async def store_credential( + self, + credential_definition, + credential_data, + credential_request_metadata, + credential_attr_mime_types=None, + credential_id=None, + rev_reg_def_json=None, + ): + """ + Store a credential in the wallet. + + 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_mime_types: dict mapping attribute names to (optional) + MIME types to store as non-secret record, if specified + credential_id: optionally override the stored credential id + rev_reg_def_json: optional revocation registry definition in json + + """ diff --git a/aries_cloudagent/holder/indy.py b/aries_cloudagent/holder/indy.py index 81ef652c8c..7ed4d6ad3a 100644 --- a/aries_cloudagent/holder/indy.py +++ b/aries_cloudagent/holder/indy.py @@ -35,7 +35,7 @@ def __init__(self, wallet): self.wallet = wallet async def create_credential_request( - self, credential_offer, credential_definition, did + self, credential_offer, credential_definition, holder_did: str ): """ Create a credential offer for the given credential definition id. @@ -43,6 +43,7 @@ async def create_credential_request( Args: credential_offer: The credential offer to create request for credential_definition: The credential definition to create an offer for + holder_did: the DID of the agent making the request Returns: A credential request @@ -54,7 +55,7 @@ async def create_credential_request( credential_request_metadata_json, ) = await indy.anoncreds.prover_create_credential_req( self.wallet.handle, - did, + holder_did, json.dumps(credential_offer), json.dumps(credential_definition), self.wallet.master_secret_id, @@ -77,7 +78,8 @@ async def store_credential( credential_data, credential_request_metadata, credential_attr_mime_types=None, - credential_id=None + credential_id=None, + rev_reg_def_json=None, ): """ Store a credential in the wallet. @@ -89,15 +91,17 @@ async def store_credential( by the issuer credential_attr_mime_types: dict mapping attribute names to (optional) MIME types to store as non-secret record, if specified + credential_id: optionally override the stored credential id + rev_reg_def_json: revocation registry definition in json """ credential_id = await indy.anoncreds.prover_store_credential( - self.wallet.handle, - credential_id, - json.dumps(credential_request_metadata), - json.dumps(credential_data), - json.dumps(credential_definition), - None, # We don't support revocation yet + wallet_handle=self.wallet.handle, + cred_id=credential_id, + cred_req_metadata_json=json.dumps(credential_request_metadata), + cred_json=json.dumps(credential_data), + cred_def_json=json.dumps(credential_definition), + rev_reg_def_json=json.dumps(rev_reg_def_json) if rev_reg_def_json else None, ) if credential_attr_mime_types: @@ -111,7 +115,7 @@ async def store_credential( type=IndyHolder.RECORD_TYPE_MIME_TYPES, value=credential_id, tags=mime_types, - id=f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{credential_id}" + id=f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{credential_id}", ) indy_stor = IndyStorage(self.wallet) await indy_stor.add_record(record) @@ -129,8 +133,7 @@ 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... @@ -139,8 +142,7 @@ 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) @@ -248,7 +250,7 @@ async def delete_credential(self, credential_id: str): indy_stor = IndyStorage(self.wallet) mime_types_record = await indy_stor.get_record( IndyHolder.RECORD_TYPE_MIME_TYPES, - f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{credential_id}" + f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{credential_id}", ) await indy_stor.delete_record(mime_types_record) except StorageNotFoundError: @@ -267,9 +269,7 @@ async def delete_credential(self, credential_id: str): raise async def get_mime_type( - self, - credential_id: str, - attr: str = None + self, credential_id: str, attr: str = None ) -> Union[dict, str]: """ Get MIME type per attribute (or for all attributes). @@ -285,7 +285,7 @@ async def get_mime_type( try: mime_types_record = await IndyStorage(self.wallet).get_record( IndyHolder.RECORD_TYPE_MIME_TYPES, - f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{credential_id}" + f"{IndyHolder.RECORD_TYPE_MIME_TYPES}::{credential_id}", ) except StorageError: return None # no MIME types: not an error @@ -298,6 +298,7 @@ async def create_presentation( requested_credentials: dict, schemas: dict, credential_definitions: dict, + rev_states_json: dict = None, ): """ Get credentials stored in the wallet. @@ -307,6 +308,7 @@ async def create_presentation( requested_credentials: Indy format requested_credentials schemas: Indy formatted schemas_json credential_definitions: Indy formatted schemas_json + rev_states_json: Indy format revocation states """ @@ -317,7 +319,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(rev_states_json) if rev_states_json else "{}", ) presentation = json.loads(presentation_json) diff --git a/aries_cloudagent/holder/tests/test_indy.py b/aries_cloudagent/holder/tests/test_indy.py index dc59ea5656..7b90aa1070 100644 --- a/aries_cloudagent/holder/tests/test_indy.py +++ b/aries_cloudagent/holder/tests/test_indy.py @@ -55,12 +55,12 @@ async def test_store_credential(self, mock_store_cred): ) mock_store_cred.assert_called_once_with( - mock_wallet.handle, - None, - json.dumps("credential_request_metadata"), - json.dumps("credential_data"), - json.dumps("credential_definition"), - None, + wallet_handle=mock_wallet.handle, + cred_id=None, + cred_req_metadata_json=json.dumps("credential_request_metadata"), + cred_json=json.dumps("credential_data"), + cred_def_json=json.dumps("credential_definition"), + rev_reg_def_json=None ) assert cred_id == "cred_id" diff --git a/aries_cloudagent/issuer/base.py b/aries_cloudagent/issuer/base.py index 2b1af7278d..3e51049ef5 100644 --- a/aries_cloudagent/issuer/base.py +++ b/aries_cloudagent/issuer/base.py @@ -1,9 +1,9 @@ """Ledger issuer class.""" -from abc import ABC +from abc import ABC, ABCMeta, abstractmethod -class BaseIssuer(ABC): +class BaseIssuer(ABC, metaclass=ABCMeta): """Base class for issuer.""" def __repr__(self) -> str: @@ -15,3 +15,57 @@ def __repr__(self) -> str: """ return "<{}>".format(self.__class__.__name__) + + @abstractmethod + def create_credential_offer(self, credential_definition_id): + """ + Create a credential offer for the given credential definition id. + + Args: + credential_definition_id: The credential definition to create an offer for + + Returns: + A credential offer + + """ + pass + + @abstractmethod + async def create_credential( + self, + schema, + credential_offer, + credential_request, + credential_values, + revoc_reg_id: str = None, + tails_reader_handle: int = None, + ): + """ + Create a credential. + + Args + schema: Schema to create credential for + credential_offer: Credential Offer to create credential for + credential_request: Credential request to create credential for + credential_values: Values to go in credential + revoc_reg_id: ID of the revocation registry + tails_reader_handle: Handle for the tails file blob reader + + Returns: + A tuple of created credential, revocation id + + """ + pass + + @abstractmethod + def revoke_credential(self, revoc_reg_id, tails_reader_handle, cred_revoc_id): + """ + Revoke a credential. + + Args + revoc_reg_id: ID of the revocation registry + tails_reader_handle: handle for the registry tails file + cred_revoc_id: index of the credential in the revocation registry + + """ + pass diff --git a/aries_cloudagent/issuer/indy.py b/aries_cloudagent/issuer/indy.py index 08b350c46b..f9e6c0f3ff 100644 --- a/aries_cloudagent/issuer/indy.py +++ b/aries_cloudagent/issuer/indy.py @@ -4,6 +4,7 @@ import logging import indy.anoncreds +from indy.error import AnoncredsRevocationRegistryFullError from ..core.error import BaseError from ..messaging.util import encode @@ -15,12 +16,16 @@ class IssuerError(BaseError): """Generic issuer error.""" +class IssuerRevocationRegistryFullError(IssuerError): + """Revocation registry is full when issuing a new credential.""" + + class IndyIssuer(BaseIssuer): """Indy issuer class.""" def __init__(self, wallet): """ - Initialize an IndyLedger instance. + Initialize an IndyIssuer instance. Args: wallet: IndyWallet instance @@ -49,7 +54,13 @@ async def create_credential_offer(self, credential_definition_id: str): return credential_offer async def create_credential( - self, schema, credential_offer, credential_request, credential_values + self, + schema, + credential_offer, + credential_request, + credential_values, + revoc_reg_id: str = None, + tails_reader_handle: int = None, ): """ Create a credential. @@ -59,6 +70,8 @@ async def create_credential( credential_offer: Credential Offer to create credential for credential_request: Credential request to create credential for credential_values: Values to go in credential + revoc_reg_id: ID of the revocation registry + tails_reader_handle: Handle for the tails file blob reader Returns: A tuple of created credential, revocation id @@ -82,17 +95,42 @@ async def create_credential( encoded_values[attribute]["raw"] = str(credential_value) encoded_values[attribute]["encoded"] = encode(credential_value) - ( - credential_json, - credential_revocation_id, - _, - ) = await indy.anoncreds.issuer_create_credential( - self.wallet.handle, - json.dumps(credential_offer), - json.dumps(credential_request), - json.dumps(encoded_values), - None, - None, - ) + try: + ( + credential_json, + credential_revocation_id, + revoc_reg_delta_json, + ) = await indy.anoncreds.issuer_create_credential( + self.wallet.handle, + json.dumps(credential_offer), + json.dumps(credential_request), + json.dumps(encoded_values), + revoc_reg_id, + tails_reader_handle, + ) + except AnoncredsRevocationRegistryFullError: + self.logger.error("Revocation registry is full when creating a credential.") + raise IssuerRevocationRegistryFullError("Revocation registry full") return json.loads(credential_json), credential_revocation_id + + async def revoke_credential( + self, revoc_reg_id: str, tails_reader_handle: int, cred_revoc_id: str + ) -> dict: + """ + Revoke a credential. + + Args + revoc_reg_id: ID of the revocation registry + tails_reader_handle: handle for the registry tails file + cred_revoc_id: index of the credential in the revocation registry + + """ + revoc_reg_delta_json = await indy.anoncreds.issuer_revoke_credential( + self.wallet.handle, tails_reader_handle, revoc_reg_id, cred_revoc_id + ) + # may throw AnoncredsInvalidUserRevocId if using ISSUANCE_ON_DEMAND + + delta = json.loads(revoc_reg_delta_json) + + return delta diff --git a/aries_cloudagent/ledger/base.py b/aries_cloudagent/ledger/base.py index 63c586f1f6..e285c62c8b 100644 --- a/aries_cloudagent/ledger/base.py +++ b/aries_cloudagent/ledger/base.py @@ -1,10 +1,11 @@ """Ledger base class.""" -from abc import ABC, abstractmethod +from abc import ABC, abstractmethod, ABCMeta import re +from typing import Tuple, Sequence -class BaseLedger(ABC): +class BaseLedger(ABC, metaclass=ABCMeta): """Base class for ledger.""" LEDGER_TYPE = None @@ -86,3 +87,79 @@ async def get_latest_txn_author_acceptance(self): def taa_digest(self, version: str, text: str): """Generate the digest of a TAA record.""" + + @abstractmethod + async def create_and_send_schema( + self, schema_name: str, schema_version: str, attribute_names: Sequence[str] + ) -> Tuple[str, dict]: + """ + Send schema to ledger. + + Args: + schema_name: The schema name + schema_version: The schema version + attribute_names: A list of schema attributes + + """ + + @abstractmethod + async def get_revoc_reg_def(self, revoc_reg_id: str) -> dict: + """Look up a revocation registry definition by ID.""" + + @abstractmethod + async def send_revoc_reg_def(self, revoc_reg_def: dict, issuer_did: str = None): + """Publish a revocation registry definition to the ledger.""" + + @abstractmethod + async def send_revoc_reg_entry( + self, + revoc_reg_id: str, + revoc_def_type: str, + revoc_reg_entry: dict, + issuer_did: str = None, + ): + """Publish a revocation registry entry to the ledger.""" + + @abstractmethod + async def create_and_send_credential_definition( + self, schema_id: str, tag: str = None, support_revocation: bool = False + ) -> Tuple[str, dict]: + """ + Send credential definition to ledger and store relevant key matter in wallet. + + Args: + schema_id: The schema id of the schema to create cred def for + tag: Optional tag to distinguish multiple credential definitions + support_revocation: Optional flag to enable revocation for this cred def + + """ + + @abstractmethod + async def get_credential_definition(self, credential_definition_id: str) -> dict: + """ + Get a credential definition from the cache if available, otherwise the ledger. + + Args: + credential_definition_id: The schema id of the schema to fetch cred def for + + """ + + @abstractmethod + async def get_revoc_reg_delta( + self, revoc_reg_id: str, timestamp_from=0, timestamp_to=None + ) -> (dict, int): + """Look up a revocation registry delta by ID.""" + + @abstractmethod + async def get_schema(self, schema_id: str) -> dict: + """ + Get a schema from the cache if available, otherwise fetch from the ledger. + + Args: + schema_id: The schema id (or stringified sequence number) to retrieve + + """ + + @abstractmethod + async def get_revoc_reg_entry(self, revoc_reg_id: str, timestamp: int): + """Get revocation registry entry by revocation registry ID and timestamp.""" diff --git a/aries_cloudagent/ledger/indy.py b/aries_cloudagent/ledger/indy.py index 2ba0addcd7..68dcb3d339 100644 --- a/aries_cloudagent/ledger/indy.py +++ b/aries_cloudagent/ledger/indy.py @@ -5,13 +5,14 @@ import logging import tempfile +from datetime import datetime, date from hashlib import sha256 from os import path -from datetime import datetime, date from time import time -from typing import Sequence, Type +from typing import Sequence, Tuple, Type import indy.anoncreds +import indy.blob_storage import indy.ledger import indy.pool from indy.error import IndyError, ErrorCode @@ -21,7 +22,8 @@ from ..messaging.schemas.util import SCHEMA_SENT_RECORD_TYPE from ..storage.base import StorageRecord from ..storage.indy import IndyStorage -from ..wallet.base import BaseWallet +from ..utils import sentinel +from ..wallet.base import BaseWallet, DIDInfo from .base import BaseLedger from .error import ( @@ -39,6 +41,8 @@ GENESIS_TRANSACTION_PATH, "indy_genesis_transactions.txt" ) +DEFAULT_CRED_DEF_TAG = "default" + class IndyErrorHandler: """Trap IndyError and raise an appropriate LedgerError instead.""" @@ -222,7 +226,7 @@ async def _submit( request_json: str, sign: bool = None, taa_accept: bool = None, - public_did: str = "", + sign_did: DIDInfo = sentinel, ) -> str: """ Sign and submit request to ledger. @@ -231,7 +235,7 @@ async def _submit( request_json: The json string to submit sign: whether or not to sign the request taa_accept: whether to apply TAA acceptance to the (signed, write) request - public_did: override the public DID used to sign the request + sign_did: override the signing DID """ @@ -242,18 +246,17 @@ async def _submit( ) ) - if (sign is None and public_did == "") or (sign and not public_did): - did_info = await self.wallet.get_public_did() - if did_info: - public_did = did_info.did - if public_did and sign is None: - sign = True + if sign is None or sign: + if sign_did is sentinel: + sign_did = await self.wallet.get_public_did() + if sign is None: + sign = bool(sign_did) if taa_accept is None and sign: taa_accept = True if sign: - if not public_did: + if not sign_did: raise BadLedgerRequestError("Cannot sign request without a public DID") if taa_accept: acceptance = await self.get_latest_txn_author_acceptance() @@ -269,7 +272,7 @@ async def _submit( ) ) submit_op = indy.ledger.sign_and_submit_request( - self.pool_handle, self.wallet.handle, public_did, request_json + self.pool_handle, self.wallet.handle, sign_did.did, request_json ) else: submit_op = indy.ledger.submit_request(self.pool_handle, request_json) @@ -296,9 +299,9 @@ async def _submit( f"Unexpected operation code from ledger: {operation}" ) - async def send_schema( + async def create_and_send_schema( self, schema_name: str, schema_version: str, attribute_names: Sequence[str] - ): + ) -> Tuple[str, dict]: """ Send schema to ledger. @@ -313,11 +316,12 @@ async def send_schema( if not public_info: raise BadLedgerRequestError("Cannot publish schema without a public DID") - schema_id = await self.check_existing_schema( + schema_info = await self.check_existing_schema( public_info.did, schema_name, schema_version, attribute_names ) - if schema_id: - self.logger.warning("Schema already exists on ledger. Returning ID.") + if schema_info: + self.logger.warning("Schema already exists on ledger. Returning details.") + schema_id, schema_def = schema_info else: if self.read_only: raise LedgerError( @@ -331,6 +335,7 @@ async def send_schema( schema_version, json.dumps(attribute_names), ) + schema_def = json.loads(schema_json) with IndyErrorHandler("Exception when building schema request"): request_json = await indy.ledger.build_schema_request( @@ -338,7 +343,7 @@ async def send_schema( ) try: - await self._submit(request_json, public_did=public_info.did) + await self._submit(request_json, True, sign_did=public_info) except LedgerTransactionError as e: # Identify possible duplicate schema errors on indy-node < 1.9 and > 1.9 if ( @@ -347,14 +352,16 @@ async def send_schema( ): # handle potential race condition if multiple agents are publishing # the same schema simultaneously - schema_id = await self.check_existing_schema( + schema_info = await self.check_existing_schema( public_info.did, schema_name, schema_version, attribute_names ) - if schema_id: + if schema_info: self.logger.warning( - "Schema already exists on ledger. Returning ID. Error: %s", + "Schema already exists on ledger. Returning details." + + " Error: %s", e, ) + schema_id, schema_def = schema_info else: raise @@ -366,11 +373,11 @@ async def send_schema( "schema_version": schema_id_parts[-1], "epoch": str(int(time())), } - record = StorageRecord(SCHEMA_SENT_RECORD_TYPE, schema_id, schema_tags,) + record = StorageRecord(SCHEMA_SENT_RECORD_TYPE, schema_id, schema_tags) storage = self.get_indy_storage() await storage.add_record(record) - return schema_id + return schema_id, schema_def async def check_existing_schema( self, @@ -378,7 +385,7 @@ async def check_existing_schema( schema_name: str, schema_version: str, attribute_names: Sequence[str], - ) -> str: + ) -> Tuple[str, dict]: """Check if a schema has already been published.""" fetch_schema_id = f"{public_did}:2:{schema_name}:{schema_version}" schema = await self.fetch_schema_by_id(fetch_schema_id) @@ -392,9 +399,9 @@ async def check_existing_schema( "Schema already exists on ledger, but attributes do not match: " + f"{schema_name}:{schema_version} {fetched_attrs} != {cmp_attrs}" ) - return fetch_schema_id + return fetch_schema_id, schema - async def get_schema(self, schema_id: str): + async def get_schema(self, schema_id: str) -> dict: """ Get a schema from the cache if available, otherwise fetch from the ledger. @@ -412,7 +419,7 @@ async def get_schema(self, schema_id: str): else: return await self.fetch_schema_by_id(schema_id) - async def fetch_schema_by_id(self, schema_id: str): + async def fetch_schema_by_id(self, schema_id: str) -> dict: """ Get schema from ledger. @@ -432,7 +439,7 @@ async def fetch_schema_by_id(self, schema_id: str): public_did, schema_id ) - response_json = await self._submit(request_json, public_did=public_did) + response_json = await self._submit(request_json, sign_did=public_info) response = json.loads(response_json) if not response["result"]["seqNo"]: # schema not found @@ -485,13 +492,16 @@ async def fetch_schema_by_seq_no(self, seq_no: int): f"Could not get schema from ledger for seq no {seq_no}" ) - async def send_credential_definition(self, schema_id: str, tag: str = None): + async def create_and_send_credential_definition( + self, schema_id: str, tag: str = None, support_revocation: bool = False + ) -> Tuple[str, dict]: """ Send credential definition to ledger and store relevant key matter in wallet. Args: schema_id: The schema id of the schema to create cred def for - tag: Option tag to distinguish multiple credential definitions + tag: Optional tag to distinguish multiple credential definitions + support_revocation: Optional flag to enable revocation for this cred def """ @@ -499,13 +509,13 @@ async def cred_def_in_wallet(wallet_handle, cred_def_id) -> bool: """Create an offer to check whether cred def id is in wallet.""" try: await indy.anoncreds.issuer_create_credential_offer( - wallet_handle, - cred_def_id + wallet_handle, cred_def_id ) return True except IndyError as error: if error.error_code not in ( - ErrorCode.CommonInvalidStructure, ErrorCode.WalletItemNotFound, + ErrorCode.CommonInvalidStructure, + ErrorCode.WalletItemNotFound, ): raise IndyErrorHandler.wrap_error(error) from error # recognized error signifies no such cred def in wallet: pass @@ -522,7 +532,7 @@ async def cred_def_in_wallet(wallet_handle, cred_def_id) -> bool: raise LedgerError(f"Ledger {self.pool_name} has no schema {schema_id}") # check if cred def is on ledger already - for test_tag in [tag, ] if tag else ["tag", "default"]: + for test_tag in [tag] if tag else ["tag", DEFAULT_CRED_DEF_TAG]: credential_definition_id = ( f"{public_info.did}:3:CL:{str(schema['seqNo'])}:{test_tag}" ) @@ -542,6 +552,7 @@ async def cred_def_in_wallet(wallet_handle, cred_def_id) -> bool: f"Credential definition {credential_definition_id} is on " f"ledger {self.pool_name} but not in wallet {self.wallet.name}" ) + credential_definition_json = json.dumps(ledger_cred_def) break else: # no such cred def on ledger if await cred_def_in_wallet(self.wallet.handle, credential_definition_id): @@ -559,9 +570,9 @@ async def cred_def_in_wallet(wallet_handle, cred_def_id) -> bool: self.wallet.handle, public_info.did, json.dumps(schema), - tag or "default", + tag or DEFAULT_CRED_DEF_TAG, "CL", - json.dumps({"support_revocation": False}), + json.dumps({"support_revocation": support_revocation}), ) except IndyError as error: raise IndyErrorHandler.wrap_error(error) from error @@ -576,7 +587,7 @@ async def cred_def_in_wallet(wallet_handle, cred_def_id) -> bool: request_json = await indy.ledger.build_cred_def_request( public_info.did, credential_definition_json ) - await self._submit(request_json, True, public_did=public_info.did) + await self._submit(request_json, True, sign_did=public_info) ledger_cred_def = await self.fetch_credential_definition( credential_definition_id ) @@ -586,7 +597,7 @@ async def cred_def_in_wallet(wallet_handle, cred_def_id) -> bool: storage = self.get_indy_storage() found = await storage.search_records( type_filter=CRED_DEF_SENT_RECORD_TYPE, - tag_query={"cred_def_id": credential_definition_id} + tag_query={"cred_def_id": credential_definition_id}, ).fetch_all() if not found: @@ -601,13 +612,13 @@ async def cred_def_in_wallet(wallet_handle, cred_def_id) -> bool: "epoch": str(int(time())), } record = StorageRecord( - CRED_DEF_SENT_RECORD_TYPE, credential_definition_id, cred_def_tags, + CRED_DEF_SENT_RECORD_TYPE, credential_definition_id, cred_def_tags ) await storage.add_record(record) - return credential_definition_id + return credential_definition_id, json.loads(credential_definition_json) - async def get_credential_definition(self, credential_definition_id: str): + async def get_credential_definition(self, credential_definition_id: str) -> dict: """ Get a credential definition from the cache if available, otherwise the ledger. @@ -624,7 +635,7 @@ async def get_credential_definition(self, credential_definition_id: str): return await self.fetch_credential_definition(credential_definition_id) - async def fetch_credential_definition(self, credential_definition_id: str): + async def fetch_credential_definition(self, credential_definition_id: str) -> dict: """ Get a credential definition from the ledger by id. @@ -641,7 +652,7 @@ async def fetch_credential_definition(self, credential_definition_id: str): public_did, credential_definition_id ) - response_json = await self._submit(request_json, public_did=public_did) + response_json = await self._submit(request_json, sign_did=public_info) with IndyErrorHandler("Exception when parsing cred def response"): try: @@ -692,7 +703,7 @@ async def get_key_for_did(self, did: str) -> str: public_did = public_info.did if public_info else None with IndyErrorHandler("Exception when building nym request"): request_json = await indy.ledger.build_get_nym_request(public_did, nym) - response_json = await self._submit(request_json, public_did=public_did) + response_json = await self._submit(request_json, sign_did=public_info) data_json = (json.loads(response_json))["result"]["data"] return json.loads(data_json)["verkey"] @@ -709,7 +720,7 @@ async def get_endpoint_for_did(self, did: str) -> str: request_json = await indy.ledger.build_get_attrib_request( public_did, nym, "endpoint", None, None ) - response_json = await self._submit(request_json, public_did=public_did) + response_json = await self._submit(request_json, sign_did=public_info) data_json = json.loads(response_json)["result"]["data"] if data_json: endpoint = json.loads(data_json).get("endpoint", None) @@ -764,7 +775,7 @@ async def register_nym( public_info = await self.wallet.get_public_did() public_did = public_info.did if public_info else None r = await indy.ledger.build_nym_request(public_did, did, verkey, alias, role) - await self._submit(r, True, True, public_did=public_did) + await self._submit(r, True, True, sign_did=public_info) def nym_to_did(self, nym: str) -> str: """Format a nym with the ledger's DID prefix.""" @@ -773,13 +784,13 @@ def nym_to_did(self, nym: str) -> str: nym = self.did_to_nym(nym) return f"did:sov:{nym}" - async def get_txn_author_agreement(self, reload: bool = False): + async def get_txn_author_agreement(self, reload: bool = False) -> dict: """Get the current transaction author agreement, fetching it if necessary.""" if not self.taa_cache or reload: self.taa_cache = await self.fetch_txn_author_agreement() return self.taa_cache - async def fetch_txn_author_agreement(self): + async def fetch_txn_author_agreement(self) -> dict: """Fetch the current AML and TAA from the ledger.""" public_info = await self.wallet.get_public_did() public_did = public_info.did if public_info else None @@ -787,13 +798,13 @@ async def fetch_txn_author_agreement(self): get_aml_req = await indy.ledger.build_get_acceptance_mechanisms_request( public_did, None, None ) - response_json = await self._submit(get_aml_req, public_did=public_did) + response_json = await self._submit(get_aml_req, sign_did=public_info) aml_found = (json.loads(response_json))["result"]["data"] get_taa_req = await indy.ledger.build_get_txn_author_agreement_request( public_did, None ) - response_json = await self._submit(get_taa_req, public_did=public_did) + response_json = await self._submit(get_taa_req, sign_did=public_info) taa_found = (json.loads(response_json))["result"]["data"] taa_required = bool(taa_found and taa_found["text"]) if taa_found: @@ -849,7 +860,7 @@ async def accept_txn_author_agreement( cache_key = TAA_ACCEPTED_RECORD_TYPE + "::" + self.pool_name await self.cache.set(cache_key, acceptance, self.cache_duration) - async def get_latest_txn_author_acceptance(self): + async def get_latest_txn_author_acceptance(self) -> dict: """Look up the latest TAA acceptance.""" cache_key = TAA_ACCEPTED_RECORD_TYPE + "::" + self.pool_name acceptance = self.cache and await self.cache.get(cache_key) @@ -868,3 +879,100 @@ async def get_latest_txn_author_acceptance(self): if self.cache: await self.cache.set(cache_key, acceptance, self.cache_duration) return acceptance + + async def get_revoc_reg_def(self, revoc_reg_id: str) -> dict: + """Get revocation registry definition by ID.""" + public_info = await self.wallet.get_public_did() + try: + fetch_req = await indy.ledger.build_get_revoc_reg_def_request( + public_info and public_info.did, revoc_reg_id + ) + response_json = await self._submit(fetch_req, sign_did=public_info) + ( + found_id, + found_def_json, + ) = await indy.ledger.parse_get_revoc_reg_def_response(response_json) + except IndyError as e: + logging.error( + f"get_revoc_reg_def failed with revoc_reg_id={revoc_reg_id}. " + f"{e.error_code}: {e.message}" + ) + raise e + + assert found_id == revoc_reg_id + return json.loads(found_def_json) + + async def get_revoc_reg_entry(self, revoc_reg_id: str, timestamp: int): + """Get revocation registry entry by revocation registry ID and timestamp.""" + public_info = await self.wallet.get_public_did() + fetch_req = await indy.ledger.build_get_revoc_reg_request( + public_info and public_info.did, revoc_reg_id, timestamp + ) + response_json = await self._submit(fetch_req, sign_did=public_info) + ( + found_id, + found_reg_json, + timestamp2, + ) = await indy.ledger.parse_get_revoc_reg_response(response_json) + assert found_id == revoc_reg_id + return json.loads(found_reg_json), timestamp2 + + async def get_revoc_reg_delta( + self, revoc_reg_id: str, timestamp_from=0, timestamp_to=None + ) -> (dict, int): + """ + Look up a revocation registry delta by ID. + + :param revoc_reg_id revocation registry id + :param timestamp_from from time. a total number of seconds from Unix Epoch + :param timestamp_to to time. a total number of seconds from Unix Epoch + + :returns delta response, delta timestamp + """ + if timestamp_to is None: + timestamp_to = int(time()) + public_info = await self.wallet.get_public_did() + fetch_req = await indy.ledger.build_get_revoc_reg_delta_request( + public_info and public_info.did, revoc_reg_id, timestamp_from, timestamp_to + ) + response_json = await self._submit(fetch_req, sign_did=public_info) + ( + found_id, + found_delta_json, + delta_timestamp, + ) = await indy.ledger.parse_get_revoc_reg_delta_response(response_json) + assert found_id == revoc_reg_id + return json.loads(found_delta_json), delta_timestamp + + async def send_revoc_reg_def(self, revoc_reg_def: dict, issuer_did: str = None): + """Publish a revocation registry definition to the ledger.""" + # NOTE - issuer DID could be extracted from the revoc_reg_def ID + if issuer_did: + did_info = await self.wallet.get_local_did(issuer_did) + else: + did_info = await self.wallet.get_public_did() + if not did_info: + raise LedgerTransactionError( + "No issuer DID found for revocation registry definition" + ) + request_json = await indy.ledger.build_revoc_reg_def_request( + did_info.did, json.dumps(revoc_reg_def) + ) + await self._submit(request_json, True, True, did_info) + + async def send_revoc_reg_entry( + self, + revoc_reg_id: str, + revoc_def_type: str, + revoc_reg_entry: dict, + issuer_did: str = None, + ): + """Publish a revocation registry entry to the ledger.""" + if issuer_did: + did_info = await self.wallet.get_local_did(issuer_did) + else: + did_info = await self.wallet.get_public_did() + request_json = await indy.ledger.build_revoc_reg_entry_request( + did_info.did, revoc_reg_id, revoc_def_type, json.dumps(revoc_reg_entry) + ) + await self._submit(request_json, True, True, did_info) diff --git a/aries_cloudagent/ledger/tests/test_indy.py b/aries_cloudagent/ledger/tests/test_indy.py index 1a9709db31..34a713ee18 100644 --- a/aries_cloudagent/ledger/tests/test_indy.py +++ b/aries_cloudagent/ledger/tests/test_indy.py @@ -27,6 +27,7 @@ @pytest.mark.indy class TestIndyLedger(AsyncTestCase): + test_did_info = DIDInfo("55GkHamhTU1ZbTbV2ab9DE", "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx", None) test_did = "55GkHamhTU1ZbTbV2ab9DE" test_verkey = "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx" @@ -257,7 +258,7 @@ async def test_submit_signed_taa_accept( request_json="{}", sign=None, taa_accept=True, - public_did=self.test_did + sign_did=self.test_did_info ) mock_wallet.get_public_did.assert_not_called() @@ -421,7 +422,7 @@ async def test_send_schema( mock_wallet.get_public_did.return_value = None with self.assertRaises(BadLedgerRequestError): - schema_id = await ledger.send_schema( + schema_id, schema_def = await ledger.create_and_send_schema( "schema_name", "schema_version", [1, 2, 3] ) @@ -429,7 +430,7 @@ async def test_send_schema( mock_did = mock_wallet.get_public_did.return_value mock_did.did = self.test_did - schema_id = await ledger.send_schema( + schema_id, schema_def = await ledger.create_and_send_schema( "schema_name", "schema_version", [1, 2, 3] ) @@ -443,7 +444,7 @@ async def test_send_schema( ) mock_submit.assert_called_once_with( - mock_build_schema_req.return_value, public_did=mock_did.did + mock_build_schema_req.return_value, True, sign_did=mock_wallet.get_public_did.return_value ) assert schema_id == mock_create_schema.return_value[0] @@ -474,18 +475,19 @@ async def test_send_schema_already_exists( mock_wallet.get_public_did = async_mock.CoroutineMock() mock_wallet.get_public_did.return_value.did = "abc" - mock_create_schema.return_value = (1, 2) + mock_create_schema.return_value = (1, "{}") fetch_schema_id = f"{mock_wallet.get_public_did.return_value.did}:{2}:schema_name:schema_version" - mock_check_existing.return_value = fetch_schema_id + mock_check_existing.return_value = (fetch_schema_id, {}) ledger = IndyLedger("name", mock_wallet) async with ledger: - schema_id = await ledger.send_schema( + schema_id, schema_def = await ledger.create_and_send_schema( "schema_name", "schema_version", [1, 2, 3] ) assert schema_id == fetch_schema_id + assert schema_def == {} @async_mock.patch("indy.pool.set_protocol_version") @async_mock.patch("indy.pool.create_pool_ledger_config") @@ -512,10 +514,10 @@ async def test_send_schema_ledger_transaction_error_already_exists( mock_wallet.get_public_did = async_mock.CoroutineMock() mock_wallet.get_public_did.return_value.did = "abc" - mock_create_schema.return_value = (1, 2) + mock_create_schema.return_value = (1, "{}") fetch_schema_id = f"{mock_wallet.get_public_did.return_value.did}:{2}:schema_name:schema_version" - mock_check_existing.side_effect = [None, fetch_schema_id] + mock_check_existing.side_effect = [None, (fetch_schema_id, "{}")] ledger = IndyLedger("name", mock_wallet) ledger._submit = async_mock.CoroutineMock( @@ -523,7 +525,7 @@ async def test_send_schema_ledger_transaction_error_already_exists( ) async with ledger: - schema_id = await ledger.send_schema( + schema_id, schema_def = await ledger.create_and_send_schema( "schema_name", "schema_version", [1, 2, 3] ) assert schema_id == fetch_schema_id @@ -553,7 +555,7 @@ async def test_send_schema_ledger_transaction_error( mock_wallet.get_public_did = async_mock.CoroutineMock() mock_wallet.get_public_did.return_value.did = "abc" - mock_create_schema.return_value = (1, 2) + mock_create_schema.return_value = (1, "{}") fetch_schema_id = ( f"{mock_wallet.get_public_did.return_value.did}:{2}:" @@ -568,7 +570,7 @@ async def test_send_schema_ledger_transaction_error( async with ledger: with self.assertRaises(LedgerTransactionError): - await ledger.send_schema( + await ledger.create_and_send_schema( "schema_name", "schema_version", [1, 2, 3] ) @@ -593,7 +595,7 @@ async def test_check_existing_schema( ledger = IndyLedger("name", mock_wallet) async with ledger: - schema_id = await ledger.check_existing_schema( + schema_id, schema_def = await ledger.check_existing_schema( public_did=self.test_did, schema_name="test", schema_version="1.0", @@ -640,7 +642,7 @@ async def test_get_schema( mock_wallet.get_public_did.assert_called_once_with() mock_build_get_schema_req.assert_called_once_with(mock_did.did, "schema_id") mock_submit.assert_called_once_with( - mock_build_get_schema_req.return_value, public_did=mock_did.did + mock_build_get_schema_req.return_value, sign_did=mock_did ) mock_parse_get_schema_resp.assert_called_once_with(mock_submit.return_value) @@ -676,7 +678,7 @@ async def test_get_schema_not_found( mock_wallet.get_public_did.assert_called_once_with() mock_build_get_schema_req.assert_called_once_with(mock_did.did, "schema_id") mock_submit.assert_called_once_with( - mock_build_get_schema_req.return_value, public_did=mock_did.did + mock_build_get_schema_req.return_value, sign_did=mock_did ) assert response is None @@ -749,7 +751,7 @@ async def test_get_schema_by_seq_no( async_mock.call(mock_build_get_txn_req.return_value), async_mock.call( mock_build_get_schema_req.return_value, - public_did=mock_did.did + sign_did=mock_did ) ] ) @@ -875,7 +877,7 @@ async def test_send_credential_definition( mock_wallet.get_public_did.return_value = None with self.assertRaises(BadLedgerRequestError): - await ledger.send_credential_definition(schema_id, tag) + await ledger.create_and_send_credential_definition(schema_id, tag) mock_wallet.get_public_did = async_mock.CoroutineMock() mock_wallet.get_public_did.return_value = DIDInfo( @@ -885,7 +887,7 @@ async def test_send_credential_definition( ) mock_did = mock_wallet.get_public_did.return_value - result_id = await ledger.send_credential_definition(schema_id, tag) + result_id, result_def = await ledger.create_and_send_credential_definition(schema_id, tag) assert result_id == cred_def_id mock_wallet.get_public_did.assert_called_once_with() @@ -916,7 +918,7 @@ async def test_send_credential_definition_no_such_schema( mock_wallet.get_public_did = async_mock.CoroutineMock() with self.assertRaises(LedgerError): - await ledger.send_credential_definition(schema_id, tag) + await ledger.create_and_send_credential_definition(schema_id, tag) @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger.get_schema") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open") @@ -965,7 +967,7 @@ async def test_send_credential_definition_offer_exception( mock_wallet.get_public_did = async_mock.CoroutineMock() with self.assertRaises(LedgerError): - await ledger.send_credential_definition(schema_id, tag) + await ledger.create_and_send_credential_definition(schema_id, tag) @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger.get_schema") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open") @@ -1016,7 +1018,7 @@ async def test_send_credential_definition_cred_def_in_wallet_not_ledger( mock_wallet.get_public_did = async_mock.CoroutineMock() with self.assertRaises(LedgerError): - await ledger.send_credential_definition(schema_id, tag) + await ledger.create_and_send_credential_definition(schema_id, tag) @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger.get_schema") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open") @@ -1071,7 +1073,7 @@ async def test_send_credential_definition_cred_def_on_ledger_not_in_wallet( mock_wallet.get_public_did = async_mock.CoroutineMock() with self.assertRaises(LedgerError): - await ledger.send_credential_definition(schema_id, tag) + await ledger.create_and_send_credential_definition(schema_id, tag) @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger.get_schema") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open") @@ -1139,7 +1141,7 @@ async def test_send_credential_definition_on_ledger_in_wallet( mock_wallet.get_public_did.return_value = None with self.assertRaises(BadLedgerRequestError): - await ledger.send_credential_definition(schema_id, tag) + await ledger.create_and_send_credential_definition(schema_id, tag) mock_wallet.get_public_did = async_mock.CoroutineMock() mock_wallet.get_public_did.return_value = DIDInfo( @@ -1149,7 +1151,7 @@ async def test_send_credential_definition_on_ledger_in_wallet( ) mock_did = mock_wallet.get_public_did.return_value - result_id = await ledger.send_credential_definition(schema_id, tag) + result_id, result_def = await ledger.create_and_send_credential_definition(schema_id, tag) assert result_id == cred_def_id mock_wallet.get_public_did.assert_called_once_with() @@ -1232,7 +1234,7 @@ async def test_send_credential_definition_create_cred_def_exception( ) with self.assertRaises(LedgerError): - await ledger.send_credential_definition(schema_id, tag) + await ledger.create_and_send_credential_definition(schema_id, tag) @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_close") @@ -1269,7 +1271,7 @@ async def test_get_credential_definition( mock_did.did, "cred_def_id" ) mock_submit.assert_called_once_with( - mock_build_get_cred_def_req.return_value, public_did=mock_did.did + mock_build_get_cred_def_req.return_value, sign_did=mock_did ) mock_parse_get_cred_def_resp.assert_called_once_with( mock_submit.return_value @@ -1317,7 +1319,7 @@ async def test_get_credential_definition_ledger_not_found( mock_did.did, "cred_def_id" ) mock_submit.assert_called_once_with( - mock_build_get_cred_def_req.return_value, public_did=mock_did.did + mock_build_get_cred_def_req.return_value, sign_did=mock_did ) mock_parse_get_cred_def_resp.assert_called_once_with( mock_submit.return_value @@ -1350,9 +1352,7 @@ async def test_get_key_for_did( async with ledger: mock_wallet.get_public_did = async_mock.CoroutineMock( - return_value = DIDInfo( - self.test_did, self.test_verkey, None - ) + return_value = self.test_did_info ) response = await ledger.get_key_for_did(self.test_did) @@ -1362,7 +1362,7 @@ async def test_get_key_for_did( ) assert mock_submit.called_once_with( mock_build_get_nym_req.return_value, - public_did=self.test_did + sign_did=mock_wallet.get_public_did.return_value ) assert response == self.test_verkey @@ -1398,9 +1398,7 @@ async def test_get_endpoint_for_did( async with ledger: mock_wallet.get_public_did = async_mock.CoroutineMock( - return_value = DIDInfo( - self.test_did, self.test_verkey, None - ) + return_value = self.test_did_info ) response = await ledger.get_endpoint_for_did(self.test_did) @@ -1413,7 +1411,7 @@ async def test_get_endpoint_for_did( ) assert mock_submit.called_once_with( mock_build_get_attrib_req.return_value, - public_did=self.test_did + sign_did=mock_wallet.get_public_did.return_value ) assert response == endpoint @@ -1446,9 +1444,7 @@ async def test_get_endpoint_for_did_address_none( async with ledger: mock_wallet.get_public_did = async_mock.CoroutineMock( - return_value = DIDInfo( - self.test_did, self.test_verkey, None - ) + return_value = self.test_did_info ) response = await ledger.get_endpoint_for_did(self.test_did) @@ -1461,7 +1457,7 @@ async def test_get_endpoint_for_did_address_none( ) assert mock_submit.called_once_with( mock_build_get_attrib_req.return_value, - public_did=self.test_did + sign_did=mock_wallet.get_public_did.return_value, ) assert response is None @@ -1490,9 +1486,7 @@ async def test_get_endpoint_for_did_no_endpoint( async with ledger: mock_wallet.get_public_did = async_mock.CoroutineMock( - return_value = DIDInfo( - self.test_did, self.test_verkey, None - ) + return_value = self.test_did_info ) response = await ledger.get_endpoint_for_did(self.test_did) @@ -1505,7 +1499,7 @@ async def test_get_endpoint_for_did_no_endpoint( ) assert mock_submit.called_once_with( mock_build_get_attrib_req.return_value, - public_did=self.test_did + sign_did=mock_wallet.get_public_did.return_value ) assert response is None @@ -1545,9 +1539,7 @@ async def test_update_endpoint_for_did( async with ledger: mock_wallet.get_public_did = async_mock.CoroutineMock( - return_value = DIDInfo( - self.test_did, self.test_verkey, None - ) + return_value = self.test_did_info ) response = await ledger.update_endpoint_for_did(self.test_did, endpoint[1]) @@ -1562,7 +1554,7 @@ async def test_update_endpoint_for_did( [ async_mock.call( mock_build_get_attrib_req.return_value, - public_did=self.test_did + sign_did=mock_wallet.get_public_did.return_value ), async_mock.call( mock_build_attrib_req.return_value, @@ -1605,9 +1597,7 @@ async def test_update_endpoint_for_did_duplicate( async with ledger: mock_wallet.get_public_did = async_mock.CoroutineMock( - return_value = DIDInfo( - self.test_did, self.test_verkey, None - ) + return_value = self.test_did_info ) response = await ledger.update_endpoint_for_did(self.test_did, endpoint) @@ -1620,7 +1610,7 @@ async def test_update_endpoint_for_did_duplicate( ) assert mock_submit.called_once_with( mock_build_get_attrib_req.return_value, - public_did=self.test_did + sign_did=mock_wallet.get_public_did.return_value ) assert not response @@ -1642,9 +1632,7 @@ async def test_register_nym( async with ledger: mock_wallet.get_public_did = async_mock.CoroutineMock( - return_value = DIDInfo( - self.test_did, self.test_verkey, None - ) + return_value = self.test_did_info ) await ledger.register_nym( self.test_did, @@ -1664,7 +1652,7 @@ async def test_register_nym( mock_build_nym_req.return_value, True, True, - public_did=self.test_did + sign_did=mock_wallet.get_public_did.return_value ) @async_mock.patch("indy.pool.open_pool_ledger") @@ -1714,9 +1702,7 @@ async def test_get_txn_author_agreement( async with ledger: mock_wallet.get_public_did = async_mock.CoroutineMock( - return_value = DIDInfo( - self.test_did, self.test_verkey, None - ) + return_value = self.test_did_info ) response = await ledger.get_txn_author_agreement(reload=True) @@ -1730,11 +1716,11 @@ async def test_get_txn_author_agreement( [ async_mock.call( mock_build_get_acc_mech_req.return_value, - public_did=self.test_did + sign_did=mock_wallet.get_public_did.return_value ), async_mock.call( mock_build_get_taa_req.return_value, - public_did=self.test_did + sign_did=mock_wallet.get_public_did.return_value ) ] ) diff --git a/aries_cloudagent/messaging/credential_definitions/routes.py b/aries_cloudagent/messaging/credential_definitions/routes.py index c54b7a5490..69787ee159 100644 --- a/aries_cloudagent/messaging/credential_definitions/routes.py +++ b/aries_cloudagent/messaging/credential_definitions/routes.py @@ -9,16 +9,18 @@ from ...ledger.base import BaseLedger from ...storage.base import BaseStorage + from ..valid import INDY_CRED_DEF_ID, INDY_SCHEMA_ID, INDY_VERSION + from .util import CRED_DEF_TAGS, CRED_DEF_SENT_RECORD_TYPE class CredentialDefinitionSendRequestSchema(Schema): """Request schema for schema send request.""" - schema_id = fields.Str( - description="Schema identifier", - **INDY_SCHEMA_ID + schema_id = fields.Str(description="Schema identifier", **INDY_SCHEMA_ID) + support_revocation = fields.Boolean( + required=False, description="Revocation supported flag" ) tag = fields.Str( required=False, @@ -32,18 +34,14 @@ class CredentialDefinitionSendResultsSchema(Schema): """Results schema for schema send request.""" credential_definition_id = fields.Str( - description="Credential definition identifier", - **INDY_CRED_DEF_ID + description="Credential definition identifier", **INDY_CRED_DEF_ID ) class CredentialDefinitionSchema(Schema): """Credential definition schema.""" - ver = fields.Str( - description="Node protocol version", - **INDY_VERSION - ) + ver = fields.Str(description="Node protocol version", **INDY_VERSION) ident = fields.Str( description="Credential definition identifier", data_key="id", @@ -51,7 +49,7 @@ class CredentialDefinitionSchema(Schema): ) schemaId = fields.Str( description="Schema identifier within credential definition identifier", - example=":".join(INDY_CRED_DEF_ID["example"].split(":")[3:-1]) # long or short + example=":".join(INDY_CRED_DEF_ID["example"].split(":")[3:-1]), # long or short ) typ = fields.Constant( constant="CL", @@ -61,7 +59,7 @@ class CredentialDefinitionSchema(Schema): ) tag = fields.Str( description="Tag within credential definition identifier", - example=INDY_CRED_DEF_ID["example"].split(":")[-1] + example=INDY_CRED_DEF_ID["example"].split(":")[-1], ) value = fields.Dict( description="Credential definition primary and revocation values" @@ -78,10 +76,7 @@ class CredentialDefinitionsCreatedResultsSchema(Schema): """Results schema for cred-defs-created request.""" credential_definition_ids = fields.List( - fields.Str( - description="Credential definition identifiers", - **INDY_CRED_DEF_ID - ) + fields.Str(description="Credential definition identifiers", **INDY_CRED_DEF_ID) ) @@ -107,12 +102,15 @@ async def credential_definitions_send_credential_definition(request: web.BaseReq body = await request.json() schema_id = body.get("schema_id") + support_revocation = bool(body.get("support_revocation")) tag = body.get("tag") ledger: BaseLedger = await context.inject(BaseLedger) async with ledger: - credential_definition_id = await shield( - ledger.send_credential_definition(schema_id, tag) + credential_definition_id, credential_definition = await shield( + ledger.create_and_send_credential_definition( + schema_id, tag=tag, support_revocation=support_revocation + ) ) return web.json_response({"credential_definition_id": credential_definition_id}) @@ -121,12 +119,8 @@ async def credential_definitions_send_credential_definition(request: web.BaseReq @docs( tags=["credential-definition"], parameters=[ - { - "name": p, - "in": "query", - "schema": {"type": "string"}, - "required": False, - } for p in CRED_DEF_TAGS + {"name": p, "in": "query", "schema": {"type": "string"}, "required": False} + for p in CRED_DEF_TAGS ], summary="Search for matching credential definitions that agent originated", ) @@ -147,9 +141,7 @@ async def credential_definitions_created(request: web.BaseRequest): storage = await context.inject(BaseStorage) found = await storage.search_records( type_filter=CRED_DEF_SENT_RECORD_TYPE, - tag_query={ - p: request.query[p] for p in CRED_DEF_TAGS if p in request.query - } + tag_query={p: request.query[p] for p in CRED_DEF_TAGS if p in request.query}, ).fetch_all() return web.json_response( @@ -197,12 +189,7 @@ async def register(app: web.Application): ] ) app.add_routes( - [ - web.get( - "/credential-definitions/created", - credential_definitions_created, - ) - ] + [web.get("/credential-definitions/created", credential_definitions_created,)] ) app.add_routes( [ diff --git a/aries_cloudagent/messaging/schemas/routes.py b/aries_cloudagent/messaging/schemas/routes.py index c8962bd77b..f99aabe500 100644 --- a/aries_cloudagent/messaging/schemas/routes.py +++ b/aries_cloudagent/messaging/schemas/routes.py @@ -16,67 +16,39 @@ class SchemaSendRequestSchema(Schema): """Request schema for schema send request.""" - schema_name = fields.Str( - required=True, - description="Schema name", - example="prefs", - ) + schema_name = fields.Str(required=True, description="Schema name", example="prefs",) schema_version = fields.Str( - required=True, - description="Schema version", - **INDY_VERSION + required=True, description="Schema version", **INDY_VERSION ) attributes = fields.List( - fields.Str( - description="attribute name", - example="score", - ), + fields.Str(description="attribute name", example="score",), required=True, - description="List of schema attributes" + description="List of schema attributes", ) class SchemaSendResultsSchema(Schema): """Results schema for schema send request.""" - schema_id = fields.Str( - description="Schema identifier", - **INDY_SCHEMA_ID - ) + schema_id = fields.Str(description="Schema identifier", **INDY_SCHEMA_ID) + schema = fields.Dict(description="Schema result") class SchemaSchema(Schema): """Content for returned schema.""" - ver = fields.Str( - description="Node protocol version", - **INDY_VERSION - ) - ident = fields.Str( - data_key="id", - description="Schema identifier", - **INDY_SCHEMA_ID - ) + ver = fields.Str(description="Node protocol version", **INDY_VERSION) + ident = fields.Str(data_key="id", description="Schema identifier", **INDY_SCHEMA_ID) name = fields.Str( - description="Schema name", - example=INDY_SCHEMA_ID["example"].split(":")[2], - ) - version = fields.Str( - description="Schema version", - **INDY_VERSION + description="Schema name", example=INDY_SCHEMA_ID["example"].split(":")[2], ) + version = fields.Str(description="Schema version", **INDY_VERSION) attr_names = fields.List( - fields.Str( - description="Attribute name", - example="score", - ), + fields.Str(description="Attribute name", example="score",), description="Schema attribute names", data_key="attrNames", ) - seqNo = fields.Integer( - description="Schema sequence number", - example=999 - ) + seqNo = fields.Integer(description="Schema sequence number", example=999) class SchemaGetResultsSchema(Schema): @@ -89,10 +61,7 @@ class SchemasCreatedResultsSchema(Schema): """Results schema for a schemas-created request.""" schema_ids = fields.List( - fields.Str( - description="Schema identifiers", - **INDY_SCHEMA_ID - ) + fields.Str(description="Schema identifiers", **INDY_SCHEMA_ID) ) @@ -120,22 +89,18 @@ async def schemas_send_schema(request: web.BaseRequest): ledger: BaseLedger = await context.inject(BaseLedger) async with ledger: - schema_id = await shield( - ledger.send_schema(schema_name, schema_version, attributes) + schema_id, schema_def = await shield( + ledger.create_and_send_schema(schema_name, schema_version, attributes) ) - return web.json_response({"schema_id": schema_id}) + return web.json_response({"schema_id": schema_id, "schema": schema_def}) @docs( tags=["schema"], parameters=[ - { - "name": p, - "in": "query", - "schema": {"type": "string"}, - "required": False, - } for p in SCHEMA_TAGS + {"name": p, "in": "query", "schema": {"type": "string"}, "required": False} + for p in SCHEMA_TAGS ], summary="Search for matching schema that agent originated", ) @@ -156,9 +121,7 @@ async def schemas_created(request: web.BaseRequest): storage = await context.inject(BaseStorage) found = await storage.search_records( type_filter=SCHEMA_SENT_RECORD_TYPE, - tag_query={ - p: request.query[p] for p in SCHEMA_TAGS if p in request.query - } + tag_query={p: request.query[p] for p in SCHEMA_TAGS if p in request.query}, ).fetch_all() return web.json_response({"schema_ids": [record.value for record in found]}) @@ -185,7 +148,7 @@ async def schemas_get_schema(request: web.BaseRequest): async with ledger: schema = await ledger.get_schema(schema_id) - return web.json_response({"schema_json": schema}) + return web.json_response({"schema": schema}) async def register(app: web.Application): diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/manager.py b/aries_cloudagent/protocols/issue_credential/v1_0/manager.py index 37149c7171..b5cd42907b 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/manager.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/manager.py @@ -3,6 +3,15 @@ import logging from typing import Mapping, Tuple +from indy.error import IndyError + +from .messages.credential_ack import CredentialAck +from .messages.credential_issue import CredentialIssue +from .messages.credential_offer import CredentialOffer +from .messages.credential_proposal import CredentialProposal +from .messages.credential_request import CredentialRequest +from .messages.inner.credential_preview import CredentialPreview +from .models.credential_exchange import V10CredentialExchange from ....cache.base import BaseCache from ....config.injection_context import InjectionContext from ....core.error import BaseError @@ -11,19 +20,13 @@ from ....ledger.base import BaseLedger from ....messaging.credential_definitions.util import ( CRED_DEF_TAGS, - CRED_DEF_SENT_RECORD_TYPE + CRED_DEF_SENT_RECORD_TYPE, ) +from ....revocation.indy import IndyRevocation +from ....revocation.models.revocation_registry import RevocationRegistry from ....storage.base import BaseStorage from ....storage.error import StorageNotFoundError -from .messages.credential_issue import CredentialIssue -from .messages.credential_offer import CredentialOffer -from .messages.credential_proposal import CredentialProposal -from .messages.credential_request import CredentialRequest -from .messages.credential_ack import CredentialAck -from .messages.inner.credential_preview import CredentialPreview -from .models.credential_exchange import V10CredentialExchange - class CredentialManagerError(BaseError): """Credential error.""" @@ -58,8 +61,7 @@ async def _match_sent_cred_def_id(self, tag_query: Mapping[str, str]) -> str: storage: BaseStorage = await self.context.inject(BaseStorage) found = await storage.search_records( - type_filter=CRED_DEF_SENT_RECORD_TYPE, - tag_query=tag_query, + type_filter=CRED_DEF_SENT_RECORD_TYPE, tag_query=tag_query ).fetch_all() if not found: raise CredentialManagerError( @@ -70,7 +72,9 @@ async def _match_sent_cred_def_id(self, tag_query: Mapping[str, str]) -> str: async def prepare_send( self, connection_id: str, - credential_proposal: CredentialProposal + credential_proposal: CredentialProposal, + revoc_reg_id: str = None, + auto_remove: bool = None, ) -> Tuple[V10CredentialExchange, CredentialOffer]: """ Set up a new credential exchange for an automated send. @@ -79,17 +83,23 @@ async def prepare_send( connection_id: Connection to create offer for credential_proposal: The credential proposal with preview on attribute values to use if auto_issue is enabled + revoc_reg_id: ID of the revocation registry to use + auto_remove: Flag to automatically remove the record on completion Returns: A tuple of the new credential exchange record and credential offer message """ + if auto_remove is None: + auto_remove = not self.context.settings.get("preserve_exchange_records") credential_exchange = V10CredentialExchange( auto_issue=True, + auto_remove=auto_remove, connection_id=connection_id, initiator=V10CredentialExchange.INITIATOR_SELF, role=V10CredentialExchange.ROLE_ISSUER, credential_proposal_dict=credential_proposal.serialize(), + revoc_reg_id=revoc_reg_id, ) (credential_exchange, credential_offer) = await self.create_offer( credential_exchange_record=credential_exchange, @@ -102,6 +112,7 @@ async def create_proposal( connection_id: str, *, auto_offer: bool = None, + auto_remove: bool = None, comment: str = None, credential_preview: CredentialPreview = None, schema_id: str = None, @@ -110,6 +121,7 @@ async def create_proposal( schema_version: str = None, cred_def_id: str = None, issuer_did: str = None, + revoc_reg_id: str = None, ) -> V10CredentialExchange: """ Create a credential proposal. @@ -118,6 +130,7 @@ async def create_proposal( connection_id: Connection to create proposal for auto_offer: Should this proposal request automatically be handled to offer a credential + auto_remove: Should the record be automatically removed on completion comment: Optional human-readable comment to include in proposal credential_preview: The credential preview to use to create the credential proposal @@ -127,6 +140,7 @@ async def create_proposal( schema_version: Schema version for credential proposal cred_def_id: Credential definition id for credential proposal issuer_did: Issuer DID for credential proposal + revoc_reg_id: ID of the revocation registry to use Returns: Resulting credential exchange record including credential proposal @@ -140,9 +154,11 @@ async def create_proposal( schema_name=schema_name, schema_version=schema_version, cred_def_id=cred_def_id, - issuer_did=issuer_did + issuer_did=issuer_did, ) + if auto_remove is None: + auto_remove = not self.context.settings.get("preserve_exchange_records") credential_exchange_record = V10CredentialExchange( connection_id=connection_id, thread_id=credential_proposal_message._thread_id, @@ -151,6 +167,8 @@ async def create_proposal( state=V10CredentialExchange.STATE_PROPOSAL_SENT, credential_proposal_dict=credential_proposal_message.serialize(), auto_offer=auto_offer, + auto_remove=auto_remove, + revoc_reg_id=revoc_reg_id, ) await credential_exchange_record.save( self.context, reason="create credential proposal" @@ -211,7 +229,8 @@ async def create_offer( cred_def_id = await self._match_sent_cred_def_id( { t: getattr(credential_proposal_message, t) - for t in CRED_DEF_TAGS if getattr(credential_proposal_message, t) + for t in CRED_DEF_TAGS + if getattr(credential_proposal_message, t) } ) @@ -221,6 +240,19 @@ async def create_offer( cred_preview = None async def _create(cred_def_id): + ledger: BaseLedger = await self.context.inject(BaseLedger) + async with ledger: + credential_definition = await ledger.get_credential_definition( + cred_def_id + ) + if ( + credential_definition["value"].get("revocation") + and not credential_exchange_record.revoc_reg_id + ): + raise CredentialManagerError( + "Missing revocation registry ID for revocable credential definition" + ) + issuer: BaseIssuer = await self.context.inject(BaseIssuer) return await issuer.create_credential_offer(cred_def_id) @@ -461,12 +493,29 @@ async def issue_credential( async with ledger: schema = await ledger.get_schema(schema_id) + if credential_exchange_record.revoc_reg_id: + revoc = IndyRevocation(self.context) + registry_record = await revoc.get_issuer_revocation_record( + credential_exchange_record.revoc_reg_id + ) + # FIXME exception on missing + + registry = await registry_record.get_registry() + tails_reader = await registry.create_tails_reader(self.context) + else: + tails_reader = None + issuer: BaseIssuer = await self.context.inject(BaseIssuer) ( credential_exchange_record.credential, - _, # credential_revocation_id + credential_exchange_record.revocation_id, ) = await issuer.create_credential( - schema, credential_offer, credential_request, credential_values + schema, + credential_offer, + credential_request, + credential_values, + credential_exchange_record.revoc_reg_id, + tails_reader, ) credential_exchange_record.state = V10CredentialExchange.STATE_ISSUED @@ -515,8 +564,9 @@ async def receive_credential(self) -> V10CredentialExchange: return credential_exchange_record async def store_credential( - self, credential_exchange_record: V10CredentialExchange, - credential_id: str = None + self, + credential_exchange_record: V10CredentialExchange, + credential_id: str = None, ) -> Tuple[V10CredentialExchange, CredentialAck]: """ Store a credential in holder wallet; send ack to issuer. @@ -530,17 +580,25 @@ async def store_credential( """ raw_credential = credential_exchange_record.raw_credential - + revoc_reg_def = None ledger: BaseLedger = await self.context.inject(BaseLedger) async with ledger: credential_definition = await ledger.get_credential_definition( raw_credential["cred_def_id"] ) + if ( + "rev_reg_id" in raw_credential + and raw_credential["rev_reg_id"] is not None + ): + revoc_reg_def = await ledger.get_revoc_reg_def( + raw_credential["rev_reg_id"] + ) holder: BaseHolder = await self.context.inject(BaseHolder) if ( - credential_exchange_record.credential_proposal_dict and - "credential_proposal" in credential_exchange_record.credential_proposal_dict + credential_exchange_record.credential_proposal_dict + and "credential_proposal" + in credential_exchange_record.credential_proposal_dict ): mime_types = CredentialPreview.deserialize( credential_exchange_record.credential_proposal_dict[ @@ -550,19 +608,36 @@ async def store_credential( else: mime_types = None - credential_id = await holder.store_credential( - credential_definition, - raw_credential, - credential_exchange_record.credential_request_metadata, - mime_types, - credential_id=credential_id - ) + if revoc_reg_def: + revoc_reg = RevocationRegistry.from_definition(revoc_reg_def, True) + if not revoc_reg.has_local_tails_file(self.context): + self._logger.info( + "Downloading the tails file for the revocation registry: " + f"{revoc_reg.registry_id}" + ) + await revoc_reg.retrieve_tails(self.context) + + try: + credential_id = await holder.store_credential( + credential_definition, + raw_credential, + credential_exchange_record.credential_request_metadata, + mime_types, + credential_id=credential_id, + rev_reg_def_json=revoc_reg_def, + ) + except IndyError as e: + self._logger.error(f"Error storing credential. {e.error_code}: {e.message}") + raise e credential = await holder.get_credential(credential_id) credential_exchange_record.state = V10CredentialExchange.STATE_ACKED credential_exchange_record.credential_id = credential_id credential_exchange_record.credential = credential + credential_exchange_record.revoc_reg_id = credential.get("rev_reg_id", None) + credential_exchange_record.revocation_id = credential.get("cred_rev_id", None) + await credential_exchange_record.save(self.context, reason="store credential") credential_ack_message = CredentialAck() @@ -571,9 +646,10 @@ async def store_credential( credential_exchange_record.parent_thread_id, ) - if not self.context.settings.get("preserve_exchange_records"): + if credential_exchange_record.auto_remove: # Delete the exchange record since we're done with it await credential_exchange_record.delete_record(self.context) + return (credential_exchange_record, credential_ack_message) async def receive_credential_ack(self) -> V10CredentialExchange: @@ -596,8 +672,49 @@ async def receive_credential_ack(self) -> V10CredentialExchange: credential_exchange_record.state = V10CredentialExchange.STATE_ACKED await credential_exchange_record.save(self.context, reason="credential acked") - if not self.context.settings.get("preserve_exchange_records"): + if credential_exchange_record.auto_remove: # We're done with the exchange so delete await credential_exchange_record.delete_record(self.context) return credential_exchange_record + + async def revoke_credential( + self, credential_exchange_record: V10CredentialExchange + ): + """ + Revoke a previously-issued credential. + + Args: + credential_exchange_record: the active credential exchange + + """ + + assert ( + credential_exchange_record.revocation_id + and credential_exchange_record.revoc_reg_id + ) + issuer: BaseIssuer = await self.context.inject(BaseIssuer) + + revoc = IndyRevocation(self.context) + registry_record = await revoc.get_issuer_revocation_record( + credential_exchange_record.revoc_reg_id + ) + # FIXME exception on missing + + registry = await registry_record.get_registry() + tails_reader = await registry.create_tails_reader(self.context) + + delta = await issuer.revoke_credential( + registry.registry_id, tails_reader, credential_exchange_record.revocation_id + ) + + # create entry and send to ledger + if delta: + ledger: BaseLedger = await self.context.inject(BaseLedger) + async with ledger: + await ledger.send_revoc_reg_entry( + registry.registry_id, registry.reg_def_type, delta + ) + + credential_exchange_record.state = V10CredentialExchange.STATE_REVOKED + await credential_exchange_record.save(self.context, reason="Revoked credential") diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/models/credential_exchange.py b/aries_cloudagent/protocols/issue_credential/v1_0/models/credential_exchange.py index ec3439543c..1f6636a123 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/models/credential_exchange.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/models/credential_exchange.py @@ -37,6 +37,7 @@ class Meta: STATE_ISSUED = "credential_issued" STATE_CREDENTIAL_RECEIVED = "credential_received" STATE_ACKED = "credential_acked" + STATE_REVOKED = "credential_revoked" def __init__( self, @@ -57,8 +58,11 @@ def __init__( credential_id: str = None, raw_credential: dict = None, # indy credential as received credential: dict = None, # indy credential as stored + revoc_reg_id: str = None, + revocation_id: str = None, auto_offer: bool = False, auto_issue: bool = False, + auto_remove: bool = True, error_msg: str = None, **kwargs, ): @@ -80,8 +84,11 @@ def __init__( self.credential_id = credential_id self.raw_credential = raw_credential self.credential = credential + self.revoc_reg_id = revoc_reg_id + self.revocation_id = revocation_id self.auto_offer = auto_offer self.auto_issue = auto_issue + self.auto_remove = auto_remove self.error_msg = error_msg @property @@ -103,6 +110,7 @@ def record_value(self) -> dict: "error_msg", "auto_offer", "auto_issue", + "auto_remove", "raw_credential", "credential", "parent_thread_id", @@ -110,6 +118,8 @@ def record_value(self) -> dict: "credential_definition_id", "schema_id", "credential_id", + "revoc_reg_id", + "revocation_id", "role", "state", ) @@ -213,8 +223,22 @@ class Meta: description="Issuer choice to issue to request in this credential exchange", example=False, ) + auto_remove = fields.Bool( + required=False, + default=True, + description=( + "Issuer choice to remove this credential exchange record when complete" + ), + example=False, + ) error_msg = fields.Str( required=False, description="Error message", example="credential definition identifier is not set in proposal", ) + revoc_reg_id = fields.Str( + required=False, description="Revocation registry identifier" + ) + revocation_id = fields.Str( + required=False, description="Credential identifier within revocation registry" + ) diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/routes.py b/aries_cloudagent/protocols/issue_credential/v1_0/routes.py index abf15aeb93..2998658214 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/routes.py @@ -2,16 +2,17 @@ from aiohttp import web from aiohttp_apispec import docs, request_schema, response_schema -from marshmallow import fields, Schema - from json.decoder import JSONDecodeError +from marshmallow import fields, Schema from ....connections.models.connection_record import ConnectionRecord from ....holder.base import BaseHolder +from ....issuer.indy import IssuerRevocationRegistryFullError from ....messaging.credential_definitions.util import CRED_DEF_TAGS from ....messaging.valid import ( INDY_CRED_DEF_ID, INDY_DID, + INDY_REV_REG_ID, INDY_SCHEMA_ID, INDY_VERSION, UUIDFour, @@ -65,29 +66,27 @@ class V10CredentialProposalRequestSchemaBase(Schema): **INDY_CRED_DEF_ID, ) schema_id = fields.Str( - description="Schema identifier", - required=False, - **INDY_SCHEMA_ID, + description="Schema identifier", required=False, **INDY_SCHEMA_ID ) schema_issuer_did = fields.Str( - description="Schema issuer DID", - required=False, - **INDY_DID, + description="Schema issuer DID", required=False, **INDY_DID ) schema_name = fields.Str( - description="Schema name", - required=False, - example="preferences", + description="Schema name", required=False, example="preferences" ) schema_version = fields.Str( - description="Schema version", - required=False, - **INDY_VERSION, + description="Schema version", required=False, **INDY_VERSION ) issuer_did = fields.Str( - description="Credential issuer DID", + description="Credential issuer DID", required=False, **INDY_DID + ) + auto_remove = fields.Bool( + description=("Whether to remove the credential exchange record on completion"), required=False, - **INDY_DID, + default=True, + ) + revoc_reg_id = fields.Str( + description="Revocation Registry ID", required=False, **INDY_REV_REG_ID ) comment = fields.Str(description="Human-readable comment", required=False) @@ -125,6 +124,14 @@ class V10CredentialOfferRequestSchema(Schema): required=False, default=False, ) + auto_remove = fields.Bool( + description=("Whether to remove the credential exchange record on completion"), + required=False, + default=True, + ) + revoc_reg_id = fields.Str( + description="Revocation Registry ID", required=False, **INDY_REV_REG_ID + ) comment = fields.Str(description="Human-readable comment", required=False) credential_preview = fields.Nested(CredentialPreviewSchema, required=True) @@ -213,7 +220,7 @@ async def credential_exchange_retrieve(request: web.BaseRequest): @docs( tags=["issue-credential"], - summary="Send holder a credential, automating entire flow" + summary="Send holder a credential, automating entire flow", ) @request_schema(V10CredentialProposalRequestMandSchema()) @response_schema(V10CredentialExchangeSchema(), 200) @@ -239,7 +246,12 @@ async def credential_exchange_send(request: web.BaseRequest): comment = body.get("comment") connection_id = body.get("connection_id") - preview = CredentialPreview.deserialize(body.get("credential_proposal")) + preview_spec = body.get("credential_proposal") + if not preview_spec: + raise web.HTTPBadRequest(reason="credential_proposal must be provided.") + auto_remove = body.get("auto_remove", True) + revoc_reg_id = body.get("revoc_reg_id") + preview = CredentialPreview.deserialize(preview_spec) try: connection_record = await ConnectionRecord.retrieve_by_id( @@ -264,11 +276,12 @@ async def credential_exchange_send(request: web.BaseRequest): credential_offer_message, ) = await credential_manager.prepare_send( connection_id, - credential_proposal=credential_proposal + credential_proposal=credential_proposal, + auto_remove=auto_remove, + revoc_reg_id=revoc_reg_id, ) await outbound_handler( - credential_offer_message, - connection_id=credential_exchange_record.connection_id + credential_offer_message, connection_id=credential_exchange_record.connection_id ) return web.json_response(credential_exchange_record.serialize()) @@ -297,6 +310,8 @@ async def credential_exchange_send_proposal(request: web.BaseRequest): comment = body.get("comment") preview_spec = body.get("credential_proposal") preview = CredentialPreview.deserialize(preview_spec) if preview_spec else None + auto_remove = body.get("auto_remove", True) + revoc_reg_id = body.get("revoc_reg_id") try: connection_record = await ConnectionRecord.retrieve_by_id( @@ -314,6 +329,8 @@ async def credential_exchange_send_proposal(request: web.BaseRequest): connection_id, comment=comment, credential_preview=preview, + auto_remove=auto_remove, + revoc_reg_id=revoc_reg_id, **{t: body.get(t) for t in CRED_DEF_TAGS if body.get(t)}, ) @@ -358,6 +375,8 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): auto_issue = body.get( "auto_issue", context.settings.get("debug.auto_respond_credential_request") ) + auto_remove = body.get("auto_remove", True) + revoc_reg_id = body.get("revoc_reg_id") comment = body.get("comment") preview_spec = body.get("credential_preview") @@ -397,6 +416,8 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): credential_definition_id=cred_def_id, credential_proposal_dict=credential_proposal_dict, auto_issue=auto_issue, + auto_remove=auto_remove, + revoc_reg_id=revoc_reg_id, ) credential_manager = CredentialManager(context) @@ -562,14 +583,17 @@ async def credential_exchange_issue(request: web.BaseRequest): credential_manager = CredentialManager(context) - ( - cred_exch_record, - credential_issue_message, - ) = await credential_manager.issue_credential( - cred_exch_record, - comment=comment, - credential_values=credential_preview.attr_dict(decode=False), - ) + try: + ( + cred_exch_record, + credential_issue_message, + ) = await credential_manager.issue_credential( + cred_exch_record, + comment=comment, + credential_values=credential_preview.attr_dict(decode=False), + ) + except IssuerRevocationRegistryFullError: + raise web.HTTPBadRequest(reason="Revocation registry is full.") await outbound_handler(credential_issue_message, connection_id=connection_id) return web.json_response(cred_exch_record.serialize()) @@ -665,6 +689,45 @@ async def credential_exchange_problem_report(request: web.BaseRequest): return web.json_response({}) +@docs(tags=["issue-credential"], summary="Revoke an issued credential") +@response_schema(V10CredentialExchangeSchema(), 200) +async def credential_exchange_revoke(request: web.BaseRequest): + """ + Request handler for storing a credential request. + + Args: + request: aiohttp request object + + Returns: + The credential request details. + + """ + + context = request.app["request_context"] + + try: + credential_exchange_id = request.match_info["cred_ex_id"] + credential_exchange_record = await V10CredentialExchange.retrieve_by_id( + context, credential_exchange_id + ) + except StorageNotFoundError: + raise web.HTTPNotFound() + + if ( + credential_exchange_record.state + not in (V10CredentialExchange.STATE_ISSUED, V10CredentialExchange.STATE_ACKED) + or not credential_exchange_record.revocation_id + or not credential_exchange_record.revoc_reg_id + ): + raise web.HTTPBadRequest() + + credential_manager = CredentialManager(context) + + await credential_manager.revoke_credential(credential_exchange_record) + + return web.json_response(credential_exchange_record.serialize()) + + @docs( tags=["issue-credential"], summary="Remove an existing credential exchange record" ) @@ -723,6 +786,10 @@ async def register(app: web.Application): "/issue-credential/records/{cred_ex_id}/store", credential_exchange_store, ), + web.post( + "/issue-credential/records/{cred_ex_id}/revoke", + credential_exchange_revoke, + ), web.post( "/issue-credential/records/{cred_ex_id}/problem-report", credential_exchange_problem_report, diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_manager.py b/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_manager.py index 6dfa8e159f..4998f031cd 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_manager.py @@ -41,27 +41,27 @@ async def test_record_eq(self): credential_exchange_id="dummy-0", thread_id="thread-0", credential_definition_id=cred_def_id, - role=V10CredentialExchange.ROLE_ISSUER + role=V10CredentialExchange.ROLE_ISSUER, ) ] * 2 diff = [ V10CredentialExchange( credential_exchange_id="dummy-1", credential_definition_id=cred_def_id, - role=V10CredentialExchange.ROLE_ISSUER + role=V10CredentialExchange.ROLE_ISSUER, ), V10CredentialExchange( credential_exchange_id="dummy-0", thread_id="thread-1", credential_definition_id=cred_def_id, - role=V10CredentialExchange.ROLE_ISSUER + role=V10CredentialExchange.ROLE_ISSUER, ), V10CredentialExchange( credential_exchange_id="dummy-1", thread_id="thread-0", credential_definition_id=f"{cred_def_id}_distinct_tag", - role=V10CredentialExchange.ROLE_ISSUER - ) + role=V10CredentialExchange.ROLE_ISSUER, + ), ] for i in range(len(same) - 1): @@ -86,7 +86,9 @@ async def test_prepare_send(self): self.manager, "create_offer", autospec=True ) as create_offer: create_offer.return_value = (async_mock.MagicMock(), async_mock.MagicMock()) - ret_exchange, ret_cred_offer = await self.manager.prepare_send(connection_id, proposal) + ret_exchange, ret_cred_offer = await self.manager.prepare_send( + connection_id, proposal + ) create_offer.assert_called_once() assert ret_exchange is create_offer.return_value[0] arg_exchange = create_offer.call_args[1]["credential_exchange_record"] @@ -230,11 +232,14 @@ async def test_create_free_offer(self): with async_mock.patch.object( V10CredentialExchange, "save", autospec=True ) as save_ex: + self.ledger.get_credential_definition = async_mock.CoroutineMock( + return_value={"value": {}} + ) self.cache = BasicCache() self.context.injector.bind_instance(BaseCache, self.cache) cred_offer = {"cred_def_id": cred_def_id, "schema_id": schema_id} - issuer = async_mock.MagicMock() + issuer = async_mock.MagicMock(BaseIssuer, autospec=True) issuer.create_credential_offer = async_mock.CoroutineMock( return_value=cred_offer ) @@ -284,9 +289,12 @@ async def test_create_bound_offer(self): ) as get_cached_key, async_mock.patch.object( V10CredentialExchange, "set_cached_key", autospec=True ) as set_cached_key: + self.ledger.get_credential_definition = async_mock.CoroutineMock( + return_value={"value": {}} + ) get_cached_key.return_value = None cred_offer = {"cred_def_id": cred_def_id, "schema_id": schema_id} - issuer = async_mock.MagicMock() + issuer = async_mock.MagicMock(BaseIssuer, autospec=True) issuer.create_credential_offer = async_mock.CoroutineMock( return_value=cred_offer ) @@ -305,7 +313,7 @@ async def test_create_bound_offer(self): "issuer_did": TEST_DID, "cred_def_id": cred_def_id, "epoch": str(int(time())), - } + }, ) storage: BaseStorage = await self.context.inject(BaseStorage) await storage.add_record(cred_def_record) @@ -459,7 +467,7 @@ async def test_create_request(self): indy_offer = { "schema_id": schema_id, "cred_def_id": cred_def_id, - "nonce": nonce + "nonce": nonce, } indy_cred_req = {"schema_id": schema_id, "cred_def_id": cred_def_id} thread_id = "thread-id" @@ -512,11 +520,10 @@ async def test_create_request(self): # cover case with existing cred req stored_exchange.credential_request = indy_cred_req - (ret_existing_exchange, ret_existing_request) = ( - await self.manager.create_request( - stored_exchange, holder_did - ) - ) + ( + ret_existing_exchange, + ret_existing_request, + ) = await self.manager.create_request(stored_exchange, holder_did) assert ret_existing_exchange == ret_exchange assert ret_existing_request._thread_id == thread_id @@ -528,7 +535,7 @@ async def test_create_request_no_cache(self): indy_offer = { "schema_id": schema_id, "cred_def_id": cred_def_id, - "nonce": nonce + "nonce": nonce, } indy_cred_req = {"schema_id": schema_id, "cred_def_id": cred_def_id} thread_id = "thread-id" @@ -671,7 +678,7 @@ async def test_issue_credential(self): save_ex.assert_called_once() issuer.create_credential.assert_called_once_with( - schema, indy_offer, indy_cred_req, cred_values + schema, indy_offer, indy_cred_req, cred_values, None, None ) assert ret_exchange.credential == cred @@ -681,10 +688,11 @@ async def test_issue_credential(self): # cover case with existing cred stored_exchange.credential = cred - (ret_existing_exchange, ret_existing_cred) = ( - await self.manager.issue_credential( - stored_exchange, comment=comment, credential_values=cred_values - ) + ( + ret_existing_exchange, + ret_existing_cred, + ) = await self.manager.issue_credential( + stored_exchange, comment=comment, credential_values=cred_values ) assert ret_existing_exchange == ret_exchange assert ret_existing_cred._thread_id == thread_id @@ -776,6 +784,7 @@ async def test_store_credential(self): cred_req_meta, mock_preview_deserialize.return_value.mime_types.return_value, credential_id=None, + rev_reg_def_json=None, ) holder.get_credential.assert_called_once_with(cred_id) @@ -833,7 +842,8 @@ async def test_store_credential_no_preview(self): cred, cred_req_meta, None, - credential_id=None + credential_id=None, + rev_reg_def_json=None, ) holder.get_credential.assert_called_once_with(cred_id) @@ -892,7 +902,7 @@ async def test_retrieve_records(self): await exchange_record.save(self.context) for i in range(2): # second pass gets from cache - for index in range(2): + for index in range(2): ret_ex = await V10CredentialExchange.retrieve_by_connection_and_thread( self.context, str(index), str(1000 + index) ) diff --git a/aries_cloudagent/protocols/present_proof/v1_0/manager.py b/aries_cloudagent/protocols/present_proof/v1_0/manager.py index 71f680a98c..e78262d78b 100644 --- a/aries_cloudagent/protocols/present_proof/v1_0/manager.py +++ b/aries_cloudagent/protocols/present_proof/v1_0/manager.py @@ -2,7 +2,11 @@ import json import logging +import time +from indy.error import IndyError + +from ....revocation.models.revocation_registry import RevocationRegistry from ....config.injection_context import InjectionContext from ....core.error import BaseError from ....holder.base import BaseHolder @@ -225,6 +229,7 @@ async def create_presentation( requested_credentials: Indy formatted requested_credentials comment: optional human-readable comment + Example `requested_credentials` format: :: @@ -250,55 +255,153 @@ async def create_presentation( A tuple (updated presentation exchange record, presentation message) """ - # 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 = [] + # Get all credentials for this presentation 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) - + credentials = {} + non_revoked_timespan = {} + + # extract credential ids and non_revoked + requested_referents = {} + presentation_request = presentation_exchange_record.presentation_request + attr_creds = requested_credentials.get("requested_attributes", {}) + req_attrs = presentation_request.get("requested_attributes", {}) + for referent in attr_creds: + requested_referents[referent] = {"cred_id": attr_creds[referent]["cred_id"]} + if referent in req_attrs and "non_revoked" in req_attrs[referent]: + requested_referents[referent]["non_revoked"] = req_attrs[referent][ + "non_revoked" + ] + + preds_creds = requested_credentials.get("requested_predicates", {}) + req_preds = presentation_request.get("requested_attributes", {}) + for referent in preds_creds: + requested_referents[referent] = { + "cred_id": preds_creds[referent]["cred_id"], + } + if referent in req_preds and "non_revoked" in req_preds[referent]: + requested_referents[referent]["non_revoked"] = req_preds[referent][ + "non_revoked" + ] + + # extract mapping of presentation referents to credential ids + for referent in requested_referents: + credential_id = requested_referents[referent]["cred_id"] + if credential_id not in credentials: + credentials[credential_id] = await holder.get_credential(credential_id) + + # Get all schema, credential definition, and revocation registry in use + ledger: BaseLedger = await self.context.inject(BaseLedger) schemas = {} credential_definitions = {} + revocation_registries = {} - ledger: BaseLedger = await self.context.inject(BaseLedger) async with ledger: + for credential in credentials.values(): + schema_id = credential["schema_id"] + if schema_id not in schemas: + schemas[schema_id] = await ledger.get_schema(schema_id) + + credential_definition_id = credential["cred_def_id"] + if credential_definition_id not in credential_definitions: + credential_definitions[ + credential_definition_id + ] = await ledger.get_credential_definition(credential_definition_id) + + if "rev_reg_id" in credential and credential["rev_reg_id"] is not None: + revocation_registry_id = credential["rev_reg_id"] + if revocation_registry_id not in revocation_registries: + revocation_registries[ + revocation_registry_id + ] = RevocationRegistry.from_definition( + await ledger.get_revoc_reg_def(revocation_registry_id), True + ) + + # Get delta with timespan defined in "non_revoked" + # of the presentation request or attributes + current_timestamp = int(time.time()) + non_revoked_timespan = presentation_exchange_record.presentation_request.get( + "non_revoked", None + ) - # Build schemas for anoncreds - for schema_id in schema_ids: - schema = await ledger.get_schema(schema_id) - schemas[schema_id] = schema + revoc_reg_deltas = {} + async with ledger: + for referented in requested_referents.values(): + credential_id = referented["cred_id"] + if "rev_reg_id" not in credentials[credential_id]: + continue + + rev_reg_id = credentials[credential_id]["rev_reg_id"] + referent_non_revoked_timespan = referented.get( + "non_revoked", non_revoked_timespan + ) - # Build credential_definitions for anoncreds - for credential_definition_id in credential_definition_ids: - (credential_definition) = await ledger.get_credential_definition( - credential_definition_id + if referent_non_revoked_timespan: + if "from" not in non_revoked_timespan: + non_revoked_timespan["from"] = 0 + if "to" not in non_revoked_timespan: + non_revoked_timespan["to"] = current_timestamp + + key = f"{rev_reg_id}_{non_revoked_timespan['from']}_" \ + f"{non_revoked_timespan['to']}" + if key not in revoc_reg_deltas: + (delta, delta_timestamp) = await ledger.get_revoc_reg_delta( + rev_reg_id, + non_revoked_timespan["from"], + non_revoked_timespan["to"], + ) + revoc_reg_deltas[key] = ( + rev_reg_id, + credential_id, + delta, + delta_timestamp, + ) + referented["timestamp"] = revoc_reg_deltas[key][3] + + # Get revocation states to prove non-revoked + revocation_states = {} + for ( + rev_reg_id, + credential_id, + delta, + delta_timestamp, + ) in revoc_reg_deltas.values(): + if rev_reg_id not in revocation_states: + revocation_states[rev_reg_id] = {} + + rev_reg = revocation_registries[rev_reg_id] + if not rev_reg.has_local_tails_file(self.context): + await rev_reg.retrieve_tails(self.context) + + try: + revocation_states[rev_reg_id][ + delta_timestamp + ] = await rev_reg.create_revocation_state( + self.context, credential["cred_rev_id"], delta, delta_timestamp + ) + except IndyError as e: + logging.error( + f"Failed to create revocation state: {e.error_code}, {e.message}" ) - credential_definitions[credential_definition_id] = credential_definition + raise e + + for (referent, referented) in requested_referents.items(): + if "timestamp" not in referented: + continue + if referent in requested_credentials["requested_attributes"]: + requested_credentials["requested_attributes"][referent][ + "timestamp" + ] = referented["timestamp"] + if referent in requested_credentials["requested_predicates"]: + requested_credentials["requested_predicates"][referent][ + "timestamp" + ] = referented["timestamp"] - holder: BaseHolder = await self.context.inject(BaseHolder) indy_proof = await holder.create_presentation( presentation_exchange_record.presentation_request, requested_credentials, schemas, credential_definitions, + revocation_states, ) presentation_message = Presentation( @@ -353,10 +456,9 @@ async def receive_presentation(self): presentation_preview = exchange_pres_proposal.presentation_proposal proof_req = presentation_exchange_record.presentation_request - for ( - reft, - attr_spec - ) in presentation["requested_proof"]["revealed_attrs"].items(): + for (reft, attr_spec) in presentation["requested_proof"][ + "revealed_attrs" + ].items(): name = proof_req["requested_attributes"][reft]["name"] value = attr_spec["raw"] if not presentation_preview.has_attr_spec( @@ -364,7 +466,7 @@ async def receive_presentation(self): attr_spec["sub_proof_index"] ]["cred_def_id"], name=name, - value=value + value=value, ): raise PresentationManagerError( f"Presentation {name}={value} mismatches proposal value" @@ -401,33 +503,66 @@ async def verify_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 = {} + rev_reg_defs = {} + rev_reg_entries = {} + identifiers = indy_proof["identifiers"] ledger: BaseLedger = await self.context.inject(BaseLedger) async with ledger: + for identifier in identifiers: + schema_ids.append(identifier["schema_id"]) + credential_definition_ids.append(identifier["cred_def_id"]) + + # Build schemas for anoncreds + if identifier["schema_id"] not in schemas: + schemas[identifier["schema_id"]] = await ledger.get_schema( + identifier["schema_id"] + ) - # Build schemas for anoncreds - for schema_id in schema_ids: - schema = await ledger.get_schema(schema_id) - schemas[schema_id] = schema + if identifier["cred_def_id"] not in credential_definitions: + credential_definitions[ + identifier["cred_def_id"] + ] = await ledger.get_credential_definition( + identifier["cred_def_id"] + ) - # 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 + if "rev_reg_id" in identifier and identifier["rev_reg_id"] is not None: + if identifier["rev_reg_id"] not in rev_reg_defs: + rev_reg_defs[ + identifier["rev_reg_id"] + ] = await ledger.get_revoc_reg_def(identifier["rev_reg_id"]) + + if ( + "timestamp" in identifier + and identifier["timestamp"] is not None + ): + ( + found_rev_reg_entry, + found_timestamp, + ) = await ledger.get_revoc_reg_entry( + identifier["rev_reg_id"], identifier["timestamp"] + ) + + if identifier["rev_reg_id"] not in rev_reg_entries: + rev_reg_entries[identifier["rev_reg_id"]] = { + found_timestamp: found_rev_reg_entry + } + else: + rev_reg_entries[identifier["rev_reg_id"]][ + found_timestamp + ] = found_rev_reg_entry 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 + indy_proof_request, + indy_proof, + schemas, + credential_definitions, + rev_reg_defs, + rev_reg_entries, ) ) presentation_exchange_record.state = V10PresentationExchange.STATE_VERIFIED @@ -477,7 +612,7 @@ async def receive_presentation_ack(self): ) = await V10PresentationExchange.retrieve_by_tag_filter( self.context, {"thread_id": self.context.message._thread_id}, - {"connection_id": self.context.connection_record.connection_id} + {"connection_id": self.context.connection_record.connection_id}, ) presentation_exchange_record.state = ( diff --git a/aries_cloudagent/protocols/present_proof/v1_0/tests/test_manager.py b/aries_cloudagent/protocols/present_proof/v1_0/tests/test_manager.py index db853429be..acf5d17f03 100644 --- a/aries_cloudagent/protocols/present_proof/v1_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/present_proof/v1_0/tests/test_manager.py @@ -228,6 +228,7 @@ async def test_create_presentation(self): name=PROOF_REQ_NAME, version=PROOF_REQ_VERSION, nonce=PROOF_REQ_NONCE ) + exchange_in.presentation_request = indy_proof_req request = async_mock.MagicMock() request.indy_proof_request = async_mock.MagicMock() request._thread_id = "dummy" diff --git a/aries_cloudagent/revocation/__init__.py b/aries_cloudagent/revocation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/revocation/error.py b/aries_cloudagent/revocation/error.py new file mode 100644 index 0000000000..75ffe03119 --- /dev/null +++ b/aries_cloudagent/revocation/error.py @@ -0,0 +1,11 @@ +"""Revocation error classes.""" + +from ..core.error import BaseError + + +class RevocationError(BaseError): + """Base exception for revocation-related errors.""" + + +class RevocationNotSupportedError(RevocationError): + """Attempted to create registry for non-revocable cred def.""" diff --git a/aries_cloudagent/revocation/indy.py b/aries_cloudagent/revocation/indy.py new file mode 100644 index 0000000000..2e2a7a4004 --- /dev/null +++ b/aries_cloudagent/revocation/indy.py @@ -0,0 +1,99 @@ +"""Indy revocation registry management.""" + +from typing import Sequence + +from ..config.injection_context import InjectionContext +from ..ledger.base import BaseLedger + +from .error import RevocationNotSupportedError +from .models.issuer_revocation_record import IssuerRevocationRecord +from .models.revocation_registry import RevocationRegistry + + +class IndyRevocation: + """Class for managing Indy credential revocation.""" + + REGISTRY_CACHE = {} + + def __init__(self, context: InjectionContext): + """Initialize the IndyRevocation instance.""" + self._context = context + + async def init_issuer_registry( + self, + cred_def_id: str, + issuer_did: str, + in_advance: bool = True, + max_cred_num: int = None, + revoc_def_type: str = None, + tag: str = None, + ) -> "IssuerRevocationRecord": + """Create a new revocation registry record for a credential definition.""" + ledger: BaseLedger = await self._context.inject(BaseLedger) + async with ledger: + cred_def = await ledger.get_credential_definition(cred_def_id) + if not cred_def: + raise RevocationNotSupportedError("Credential definition not found") + if not cred_def["value"].get("revocation"): + raise RevocationNotSupportedError( + "Credential definition does not support revocation" + ) + record = IssuerRevocationRecord( + cred_def_id=cred_def_id, + issuer_did=issuer_did, + issuance_type=( + IssuerRevocationRecord.ISSUANCE_BY_DEFAULT + if in_advance + else IssuerRevocationRecord.ISSUANCE_ON_DEMAND + ), + max_cred_num=max_cred_num, + revoc_def_type=revoc_def_type, + tag=tag, + ) + await record.save(self._context, reason="Init revocation registry") + self.REGISTRY_CACHE[cred_def_id] = record.record_id + return record + + async def get_active_issuer_revocation_record( + self, cred_def_id: str, await_create: bool = False + ) -> "IssuerRevocationRecord": + """Return the current active registry for issuing a given credential definition. + + If no registry exists, then a new one will be created. + + Args: + cred_def_id: ID of the base credential definition + await_create: Wait for the registry and tails file to be created, if needed + """ + # FIXME filter issuing registries by cred def, state (active or full), pick one + if cred_def_id in self.REGISTRY_CACHE: + registry = await IssuerRevocationRecord.retrieve_by_id( + self._context, self.REGISTRY_CACHE[cred_def_id] + ) + return registry + + async def get_issuer_revocation_record( + self, revoc_reg_id: str + ) -> "IssuerRevocationRecord": + """Return the current active revocation record for a given registry ID. + + If no registry exists, then a new one will be created. + + Args: + revoc_reg_id: ID of the base revocation registry + """ + return await IssuerRevocationRecord.retrieve_by_revoc_reg_id( + self._context, revoc_reg_id + ) + + async def list_issuer_registries(self) -> Sequence["IssuerRevocationRecord"]: + """List the current revocation registries.""" + return await IssuerRevocationRecord.query(self._context) + + async def get_ledger_registry(self, revoc_reg_id: str) -> "RevocationRegistry": + """Get a revocation registry from the ledger, fetching as necessary.""" + ledger: BaseLedger = await self._context.inject(BaseLedger) + async with ledger: + revoc_reg_def = await ledger.get_revoc_reg_def(revoc_reg_id) + # TODO apply caching here? + return RevocationRegistry.from_definition(revoc_reg_def, True) diff --git a/aries_cloudagent/revocation/models/__init__.py b/aries_cloudagent/revocation/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/revocation/models/issuer_revocation_record.py b/aries_cloudagent/revocation/models/issuer_revocation_record.py new file mode 100644 index 0000000000..4b105570ba --- /dev/null +++ b/aries_cloudagent/revocation/models/issuer_revocation_record.py @@ -0,0 +1,248 @@ +"""Issuer revocation registry storage handling.""" + +import json +import logging +import uuid +from typing import Sequence + +import indy.anoncreds +import indy.blob_storage +from marshmallow import fields + +from ...config.injection_context import InjectionContext +from ...messaging.models.base_record import BaseRecord, BaseRecordSchema +from ...ledger.base import BaseLedger +from ...wallet.base import BaseWallet + +from ..error import RevocationError + +from .revocation_registry import RevocationRegistry + +DEFAULT_REGISTRY_SIZE = 100 + +LOGGER = logging.getLogger(__name__) + + +class IssuerRevocationRecord(BaseRecord): + """Class for managing local issuing revocation registries.""" + + class Meta: + """IssuerRevocationRecord metadata.""" + + schema_class = "IssuerRevocationRecordSchema" + + RECORD_ID_NAME = "record_id" + RECORD_TYPE = "issuer_revoc" + LOG_STATE_FLAG = "debug.revocation" + CACHE_ENABLED = False + TAG_NAMES = { + "cred_def_id", + "issuance_type", + "issuer_did", + "revoc_def_type", + "revoc_reg_id", + "state", + } + + ISSUANCE_BY_DEFAULT = "ISSUANCE_BY_DEFAULT" + ISSUANCE_ON_DEMAND = "ISSUANCE_ON_DEMAND" + + REVOC_DEF_TYPE_CL = "CL_ACCUM" + + STATE_INIT = "init" + STATE_GENERATED = "generated" + STATE_PUBLISHED = "published" + STATE_ACTIVE = "active" + STATE_FULL = "full" + + def __init__( + self, + *, + record_id: str = None, + state: str = None, + cred_def_id: str = None, + error_msg: str = None, + issuance_type: str = None, + issuer_did: str = None, + max_cred_num: int = None, + revoc_def_type: str = None, + revoc_reg_id: str = None, + revoc_reg_def: dict = None, + revoc_reg_entry: dict = None, + tag: str = None, + tails_hash: str = None, + tails_local_path: str = None, + tails_public_uri: str = None, + **kwargs, + ): + """Initialize the issuer revocation registry record.""" + super(IssuerRevocationRecord, self).__init__( + record_id, state=state or self.STATE_INIT, **kwargs + ) + self.cred_def_id = cred_def_id + self.error_msg = error_msg + self.issuance_type = issuance_type or self.ISSUANCE_BY_DEFAULT + self.issuer_did = issuer_did + self.max_cred_num = max_cred_num or DEFAULT_REGISTRY_SIZE + self.revoc_def_type = revoc_def_type or self.REVOC_DEF_TYPE_CL + self.revoc_reg_id = revoc_reg_id + self.revoc_reg_def = revoc_reg_def + self.revoc_reg_entry = revoc_reg_entry + self.tag = tag + self.tails_hash = tails_hash + self.tails_local_path = tails_local_path + self.tails_public_uri = tails_public_uri + + @property + def record_id(self) -> str: + """Accessor for the record ID.""" + return self._id + + @property + def record_value(self) -> dict: + """Accessor for the JSON record value properties for this revocation record.""" + return { + prop: getattr(self, prop) + for prop in ( + "error_msg", + "max_cred_num", + "revoc_reg_def", + "revoc_reg_entry", + "tag", + "tails_hash", + "tails_public_uri", + "tails_local_path", + ) + } + + async def generate_registry(self, context: InjectionContext, base_dir: str): + """Create the credential registry definition and tails file.""" + wallet = await context.inject(BaseWallet, required=False) + if not wallet or wallet.WALLET_TYPE != "indy": + raise RevocationError("Wallet type of 'indy' must be provided") + if not self.tag: + self.tag = self._id or str(uuid.uuid4()) + + tails_writer_config = json.dumps({"base_dir": base_dir, "uri_pattern": ""}) + tails_writer = await indy.blob_storage.open_writer( + "default", tails_writer_config + ) + + LOGGER.debug("create revocation registry with size:", self.max_cred_num) + + ( + revoc_reg_id, + revoc_reg_def_json, + revoc_reg_entry_json, + ) = await indy.anoncreds.issuer_create_and_store_revoc_reg( + wallet.handle, + self.issuer_did, + self.revoc_def_type, + self.tag, + self.cred_def_id, + json.dumps( + {"max_cred_num": self.max_cred_num, "issuance_type": self.issuance_type} + ), + tails_writer, + ) + + self.revoc_reg_id = revoc_reg_id + self.revoc_reg_def = json.loads(revoc_reg_def_json) + self.revoc_reg_entry = json.loads(revoc_reg_entry_json) + self.state = self.STATE_GENERATED + self.tails_hash = self.revoc_reg_def["value"]["tailsHash"] + self.tails_local_path = self.revoc_reg_def["value"]["tailsLocation"] + await self.save(context, reason="Generated registry") + + def set_tails_file_public_uri(self, tails_file_uri): + """Update tails file's publicly accessible URI.""" + self.tails_public_uri = tails_file_uri + self.revoc_reg_def["value"]["tailsLocation"] = tails_file_uri + + async def publish_registry_definition(self, context: InjectionContext): + """Send the revocation registry definition to the ledger.""" + ledger: BaseLedger = await context.inject(BaseLedger) + async with ledger: + await ledger.send_revoc_reg_def(self.revoc_reg_def, self.issuer_did) + + async def publish_registry_entry(self, context: InjectionContext): + """Send a registry entry to the ledger.""" + ledger: BaseLedger = await context.inject(BaseLedger) + async with ledger: + await ledger.send_revoc_reg_entry( + self.revoc_reg_id, + self.revoc_def_type, + self.revoc_reg_entry, + self.issuer_did, + ) + + async def get_registry(self) -> RevocationRegistry: + """Create a `RevocationRegistry` instance from this record.""" + return RevocationRegistry( + self.revoc_reg_id, + cred_def_id=self.cred_def_id, + issuer_did=self.issuer_did, + max_creds=self.max_cred_num, + reg_def_type=self.revoc_def_type, + tag=self.tag, + tails_local_path=self.tails_local_path, + tails_public_uri=self.tails_public_uri, + tails_hash=self.tails_hash, + ) + + @classmethod + async def query_by_cred_def_id( + cls, context: InjectionContext, cred_def_id: str, state: str = None + ) -> Sequence["IssuerRevocationRecord"]: + """Retrieve a revocation record by credential definition ID. + + Args: + context: The injection context to use + cred_def_id: The credential definition ID to filter by + state: A state value to filter by + """ + tag_filter = {"cred_def_id": cred_def_id} + if state: + tag_filter["state"] = state + return await cls.query(context, tag_filter) + + @classmethod + async def retrieve_by_revoc_reg_id( + cls, context: InjectionContext, revoc_reg_id: str + ) -> Sequence["IssuerRevocationRecord"]: + """Retrieve a revocation record by revocation registry ID. + + Args: + context: The injection context to use + revoc_reg_id: The revocation registry ID + """ + tag_filter = {"revoc_reg_id": revoc_reg_id} + return await cls.retrieve_by_tag_filter(context, tag_filter) + + async def mark_full(self, context: InjectionContext): + """Change the registry state to full.""" + self.state = self.STATE_FULL + await self.save(context) + + +class IssuerRevocationRecordSchema(BaseRecordSchema): + """Schema to allow serialization/deserialization of revocation records.""" + + class Meta: + """ConnectionRecordSchema metadata.""" + + model_class = IssuerRevocationRecord + + record_id = fields.Str(required=False) + cred_def_id = fields.Str(required=False) + error_msg = fields.Str(required=False) + issuance_type = fields.Str(required=False) + issuer_did = fields.Str(required=False) + max_cred_num = fields.Int(required=False) + revoc_def_type = fields.Str(required=False) + revoc_reg_id = fields.Str(required=False) + revoc_reg_def = fields.Dict(required=False) + revoc_reg_entry = fields.Dict(required=False) + tag = fields.Str(required=False) + tails_hash = fields.Str(required=False) + tails_public_uri = fields.Str(required=False) diff --git a/aries_cloudagent/revocation/models/revocation_registry.py b/aries_cloudagent/revocation/models/revocation_registry.py new file mode 100644 index 0000000000..8a82564721 --- /dev/null +++ b/aries_cloudagent/revocation/models/revocation_registry.py @@ -0,0 +1,225 @@ +"""Classes for managing a revocation registry.""" + +import json +from pathlib import Path + +import indy.blob_storage + +from ...config.injection_context import InjectionContext +from ...utils.http import FetchError, fetch_stream +from ...utils.temp import get_temp_dir + +from ..error import RevocationError +import hashlib +import base58 + + +class RevocationRegistry: + """Manage a revocation registry and tails file.""" + + def __init__( + self, + registry_id: str = None, + *, + cred_def_id: str = None, + issuer_did: str = None, + max_creds: int = None, + reg_def_type: str = None, + tag: str = None, + tails_local_path: str = None, + tails_public_uri: str = None, + tails_hash: str = None, + reg_def_json: str = None, + ): + """Initialize the revocation registry instance.""" + self._cred_def_id = cred_def_id + self._issuer_did = issuer_did + self._max_creds = max_creds + self._reg_def_type = reg_def_type + self._registry_id = registry_id + self._tag = tag + self._tails_local_path = tails_local_path + self._tails_public_uri = tails_public_uri + self._tails_hash = tails_hash + self._reg_def_json = reg_def_json + + @classmethod + def from_definition( + cls, revoc_reg_def: dict, public_def: bool + ) -> "RevocationRegistry": + """Initialize a revocation registry instance from a definition.""" + reg_id = revoc_reg_def["id"] + tails_location = revoc_reg_def["value"]["tailsLocation"] + init = { + "cred_def_id": revoc_reg_def["credDefId"], + "reg_def_type": revoc_reg_def["revocDefType"], + "max_creds": revoc_reg_def["value"]["maxCredNum"], + "tag": revoc_reg_def["tag"], + "tails_hash": revoc_reg_def["value"]["tailsHash"], + "reg_def_json": json.dumps(revoc_reg_def), + } + if public_def: + init["tails_public_uri"] = tails_location + else: + init["tails_local_path"] = tails_location + + # currently ignored - definition version, public keys + return cls(reg_id, **init) + + @classmethod + def get_temp_dir(cls) -> str: + """Accessor for the temp directory.""" + return get_temp_dir("revoc") + + @property + def cred_def_id(self) -> str: + """Accessor for the credential definition ID.""" + return self._cred_def_id + + @property + def issuer_did(self) -> str: + """Accessor for the issuer DID.""" + return self._issuer_did + + @property + def max_creds(self) -> int: + """Accessor for the maximum number of issued credentials.""" + return self._max_creds + + @property + def reg_def_type(self) -> str: + """Accessor for the revocation registry type.""" + return self._reg_def_type + + @property + def registry_id(self) -> str: + """Accessor for the revocation registry ID.""" + return self._registry_id + + @property + def tag(self) -> str: + """Accessor for the tag part of the revoc. reg. ID.""" + return self._tag + + @property + def tails_hash(self) -> str: + """Accessor for the tails file hash.""" + return self._tails_hash + + @property + def tails_local_path(self) -> str: + """Accessor for the tails file local path.""" + return self._tails_local_path + + @tails_local_path.setter + def tails_local_path(self, new_path: str): + """Setter for the tails file local path.""" + self._tails_local_path = new_path + + @property + def tails_public_uri(self) -> str: + """Accessor for the tails file public URI.""" + return self._tails_public_uri + + @tails_public_uri.setter + def tails_public_uri(self, new_uri: str): + """Setter for the tails file public URI.""" + self._tails_public_uri = new_uri + + async def create_tails_reader(self, context: InjectionContext) -> int: + """Get a handle for the blob_storage file reader.""" + tails_file_path = Path(self.get_receiving_tails_local_path(context)) + + if not tails_file_path.exists(): + raise FileNotFoundError("Tails file does not exist.") + + tails_reader_config = json.dumps( + { + "base_dir": str(tails_file_path.parent.absolute()), + "file": str(tails_file_path.name), + } + ) + return await indy.blob_storage.open_reader("default", tails_reader_config) + + def get_receiving_tails_local_path(self, context: InjectionContext): + """Make the local path to the tails file we download from remote URI.""" + if self._tails_local_path: + return self._tails_local_path + + tails_file_dir = context.settings.get( + "holder.revocation.tails_files.path", "/tmp/indy/revocation/tails_files" + ) + return f"{tails_file_dir}/{self._tails_hash}" + + def has_local_tails_file(self, context: InjectionContext) -> bool: + """Test if the tails file exists locally.""" + tails_file_path = Path(self.get_receiving_tails_local_path(context)) + return tails_file_path.is_file() + + async def retrieve_tails(self, context: InjectionContext): + """Fetch the tails file from the public URI.""" + if not self._tails_public_uri: + raise RevocationError("Tails file public URI is empty") + + try: + tails_stream = await fetch_stream(self._tails_public_uri) + except FetchError as e: + raise RevocationError("Error retrieving tails file") from e + + tails_file_path = Path(self.get_receiving_tails_local_path(context)) + tails_file_dir = tails_file_path.parent + if not tails_file_dir.exists(): + tails_file_dir.mkdir(parents=True) + + buffer_size = 65536 # should be multiple of 32 bytes for sha256 + with open(tails_file_path, "wb", buffer_size) as tails_file: + file_hasher = hashlib.sha256() + buf = await tails_stream.read(buffer_size) + while len(buf) > 0: + file_hasher.update(buf) + tails_file.write(buf) + buf = await tails_stream.read(buffer_size) + + download_tails_hash = base58.b58encode(file_hasher.digest()).decode("utf-8") + if download_tails_hash != self.tails_hash: + raise RevocationError( + "The hash of the downloaded tails file does not match." + ) + + self.tails_local_path = tails_file_path + return self.tails_local_path + + async def create_revocation_state( + self, + context: InjectionContext, + cred_rev_id: str, + rev_reg_delta: dict, + timestamp: int, + ): + """ + Get credentials stored in the wallet. + + Args: + cred_rev_id: credential revocation id in revocation registry + rev_reg_delta: revocation delta + timestamp: delta timestamp + + :param context: + :return revocation state + """ + + tails_file_reader = await self.create_tails_reader(context) + rev_state = await indy.anoncreds.create_revocation_state( + tails_file_reader, + rev_reg_def_json=self._reg_def_json, + cred_rev_id=cred_rev_id, + rev_reg_delta_json=json.dumps(rev_reg_delta), + timestamp=timestamp, + ) + + return json.loads(rev_state) + + def __repr__(self) -> str: + """Return a human readable representation of this class.""" + items = ("{}={}".format(k, repr(v)) for k, v in self.__dict__.items()) + return "<{}({})>".format(self.__class__.__name__, ", ".join(items)) diff --git a/aries_cloudagent/revocation/routes.py b/aries_cloudagent/revocation/routes.py new file mode 100644 index 0000000000..6c525df2f8 --- /dev/null +++ b/aries_cloudagent/revocation/routes.py @@ -0,0 +1,243 @@ +"""Revocation registry admin routes.""" + +from asyncio import shield + +from aiohttp import web +from aiohttp_apispec import docs, request_schema, response_schema + +import logging + +from marshmallow import fields, Schema + +from ..messaging.credential_definitions.util import CRED_DEF_SENT_RECORD_TYPE +from ..messaging.valid import INDY_CRED_DEF_ID +from ..storage.base import BaseStorage, StorageNotFoundError + +from .error import RevocationNotSupportedError +from .indy import IndyRevocation +from .models.issuer_revocation_record import IssuerRevocationRecordSchema +from .models.revocation_registry import RevocationRegistry + +LOGGER = logging.getLogger(__name__) + + +class RevRegCreateRequestSchema(Schema): + """Request schema for revocation registry creation request.""" + + credential_definition_id = fields.Str( + description="Credential definition identifier", **INDY_CRED_DEF_ID + ) + + max_cred_num = fields.Int(description="Maximum credential numbers", required=False) + + +class RevRegCreateResultSchema(Schema): + """Result schema for revocation registry creation request.""" + + result = IssuerRevocationRecordSchema() + + +class RevRegUpdateTailsFileUriSchema(Schema): + """Request schema for updating tails file URI.""" + + tails_public_uri = fields.Url( + description="Public URI to the tails file", required=True + ) + + +@docs(tags=["revocation"], summary="Creates a new revocation registry") +@request_schema(RevRegCreateRequestSchema()) +@response_schema(RevRegCreateResultSchema(), 200) +async def revocation_create_registry(request: web.BaseRequest): + """ + Request handler for creating a new revocation registry. + + Args: + request: aiohttp request object + + Returns: + The revocation registry identifier + + """ + context = request.app["request_context"] + + body = await request.json() + + credential_definition_id = body.get("credential_definition_id") + max_cred_num = body.get("max_cred_num") + + # check we published this cred def + storage = await context.inject(BaseStorage) + found = await storage.search_records( + type_filter=CRED_DEF_SENT_RECORD_TYPE, + tag_query={"cred_def_id": credential_definition_id}, + ).fetch_all() + if not found: + raise web.HTTPNotFound() + + try: + issuer_did = credential_definition_id.split(":")[0] + revoc = IndyRevocation(context) + registry_record = await revoc.init_issuer_registry( + credential_definition_id, issuer_did, max_cred_num=max_cred_num + ) + except RevocationNotSupportedError as e: + raise web.HTTPBadRequest(reason=e.message) from e + await shield( + registry_record.generate_registry(context, RevocationRegistry.get_temp_dir()) + ) + + return web.json_response({"result": registry_record.serialize()}) + + +@docs( + tags=["revocation"], + summary="Get current revocation registry", + parameters=[{"in": "path", "name": "id", "description": "revocation registry id."}], +) +@response_schema(RevRegCreateResultSchema(), 200) +async def get_current_registry(request: web.BaseRequest): + """ + Request handler for getting the current revocation registry. + + Args: + request: aiohttp request object + + Returns: + The revocation registry identifier + + """ + context = request.app["request_context"] + + registry_id = request.match_info["id"] + + try: + revoc = IndyRevocation(context) + revoc_registry = await revoc.get_issuer_revocation_record(registry_id) + except StorageNotFoundError as e: + raise web.HTTPNotFound() from e + + return web.json_response({"result": revoc_registry.serialize()}) + + +@docs( + tags=["revocation"], + summary="Download the tails file of revocation registry", + produces="application/octet-stream", + parameters=[{"in": "path", "name": "id", "description": "revocation registry id."}], + responses={200: {"description": "tails file", "schema": {"type": "file"}}}, +) +async def get_tails_file(request: web.BaseRequest) -> web.FileResponse: + """ + Request handler for downloading the tails file of the revocation registry. + + Args: + request: aiohttp request object + + Returns: + The tails file in FileResponse + + """ + context = request.app["request_context"] + + registry_id = request.match_info["id"] + + try: + revoc = IndyRevocation(context) + revoc_registry = await revoc.get_issuer_revocation_record(registry_id) + except StorageNotFoundError as e: + raise web.HTTPNotFound() from e + + return web.FileResponse(path=revoc_registry.tails_local_path, status=200) + + +@docs( + tags=["revocation"], + summary="Publish a given revocation registry", + parameters=[{"in": "path", "name": "id", "description": "revocation registry id."}], +) +@response_schema(RevRegCreateResultSchema(), 200) +async def publish_registry(request: web.BaseRequest): + """ + Request handler for publishing a revocation registry based on the registry id. + + Args: + request: aiohttp request object + + Returns: + The revocation registry record + + """ + context = request.app["request_context"] + registry_id = request.match_info["id"] + + try: + revoc = IndyRevocation(context) + revoc_registry = await revoc.get_issuer_revocation_record(registry_id) + except StorageNotFoundError as e: + raise web.HTTPNotFound() from e + + await revoc_registry.publish_registry_definition(context) + LOGGER.debug("published registry definition: %s", registry_id) + await revoc_registry.publish_registry_entry(context) + LOGGER.debug("published registry entry: %s", registry_id) + + return web.json_response({"result": revoc_registry.serialize()}) + + +@docs( + tags=["revocation"], + summary="Update revocation registry with new public URI to the tails file.", + parameters=[ + { + "in": "path", + "name": "id", + "description": ( + "use credential definition id as the revocation registry id." + ), + } + ], +) +@request_schema(RevRegUpdateTailsFileUriSchema()) +@response_schema(RevRegCreateResultSchema(), 200) +async def update_registry(request: web.BaseRequest): + """ + Request handler for updating a revocation registry based on the registry id. + + Args: + request: aiohttp request object + + Returns: + The revocation registry record + + """ + context = request.app["request_context"] + + body = await request.json() + tails_public_uri = body.get("tails_public_uri") + + registry_id = request.match_info["id"] + + try: + revoc = IndyRevocation(context) + revoc_registry = await revoc.get_issuer_revocation_record(registry_id) + except StorageNotFoundError as e: + raise web.HTTPNotFound() from e + + revoc_registry.set_tails_file_public_uri(tails_public_uri) + await revoc_registry.save(context, reason="Updating tails file public URI") + + return web.json_response({"result": revoc_registry.serialize()}) + + +async def register(app: web.Application): + """Register routes.""" + app.add_routes( + [ + web.post("/revocation/create-registry", revocation_create_registry), + web.get("/revocation/registry/{id}", get_current_registry), + web.get("/revocation/registry/{id}/tails-file", get_tails_file), + web.patch("/revocation/registry/{id}", update_registry), + web.post("/revocation/registry/{id}/publish", publish_registry), + ] + ) diff --git a/aries_cloudagent/utils/__init__.py b/aries_cloudagent/utils/__init__.py index e69de29bb2..825b157e2e 100644 --- a/aries_cloudagent/utils/__init__.py +++ b/aries_cloudagent/utils/__init__.py @@ -0,0 +1 @@ +sentinel = object() diff --git a/aries_cloudagent/utils/http.py b/aries_cloudagent/utils/http.py index a32b17c17a..7680e1cc1b 100644 --- a/aries_cloudagent/utils/http.py +++ b/aries_cloudagent/utils/http.py @@ -13,6 +13,51 @@ class FetchError(BaseError): """Error raised when an HTTP fetch fails.""" +async def fetch_stream( + url: str, + *, + headers: dict = None, + retry: bool = True, + max_attempts: int = 5, + interval: float = 1.0, + backoff: float = 0.25, + request_timeout: float = 10.0, + connector: BaseConnector = None, + session: ClientSession = None +): + """Fetch from an HTTP server with automatic retries and timeouts. + + Args: + url: the address to fetch + headers: an optional dict of headers to send + retry: flag to retry the fetch + max_attempts: the maximum number of attempts to make + interval: the interval between retries, in seconds + backoff: the backoff interval, in seconds + request_timeout: the HTTP request timeout, in seconds + connector: an optional existing BaseConnector + session: a shared ClientSession + json: flag to parse the result as JSON + + """ + limit = max_attempts if retry else 1 + if not session: + session = ClientSession(connector=connector, connector_owner=(not connector)) + async with session: + async for attempt in RepeatSequence(limit, interval, backoff): + try: + async with attempt.timeout(request_timeout): + response: ClientResponse = await session.get(url, headers=headers) + if response.status < 200 or response.status >= 300: + raise ClientError( + f"Bad response from server: {response.status}" + ) + return response.content + except (ClientError, asyncio.TimeoutError) as e: + if attempt.final: + raise FetchError("Exceeded maximum fetch attempts") from e + + async def fetch( url: str, *, diff --git a/aries_cloudagent/utils/temp.py b/aries_cloudagent/utils/temp.py new file mode 100644 index 0000000000..35ffeb8d79 --- /dev/null +++ b/aries_cloudagent/utils/temp.py @@ -0,0 +1,12 @@ +"""Temp file utilities.""" + +import tempfile + +TEMP_DIRS = {} + + +def get_temp_dir(category: str) -> str: + """Accessor for the temp directory.""" + if category not in TEMP_DIRS: + TEMP_DIRS[category] = tempfile.TemporaryDirectory(category) + return TEMP_DIRS[category].name diff --git a/aries_cloudagent/verifier/base.py b/aries_cloudagent/verifier/base.py index 7ca4892f85..29ce6f98c7 100644 --- a/aries_cloudagent/verifier/base.py +++ b/aries_cloudagent/verifier/base.py @@ -1,9 +1,9 @@ """Base Verifier class.""" -from abc import ABC +from abc import ABC, ABCMeta, abstractmethod -class BaseVerifier(ABC): +class BaseVerifier(ABC, metaclass=ABCMeta): """Base class for verifier.""" def __repr__(self) -> str: @@ -15,3 +15,26 @@ def __repr__(self) -> str: """ return "<{}>".format(self.__class__.__name__) + + @abstractmethod + def verify_presentation( + self, + presentation_request, + presentation, + schemas, + credential_definitions, + rev_reg_defs, + rev_reg_entries, + ): + """ + Verify a presentation. + + Args: + presentation_request: Presentation request data + presentation: Presentation data + schemas: Schema data + credential_definitions: credential definition data + rev_reg_defs: revocation registry definitions + rev_reg_entries: revocation registry entries + """ + pass diff --git a/aries_cloudagent/verifier/indy.py b/aries_cloudagent/verifier/indy.py index f18642bc5f..2add44beac 100644 --- a/aries_cloudagent/verifier/indy.py +++ b/aries_cloudagent/verifier/indy.py @@ -151,7 +151,13 @@ def pre_verify(pres_req: dict, pres: dict) -> (PreVerifyResult, str): return (PreVerifyResult.OK, None) async def verify_presentation( - self, presentation_request, presentation, schemas, credential_definitions + self, + presentation_request, + presentation, + schemas, + credential_definitions, + rev_reg_defs, + rev_reg_entries, ) -> bool: """ Verify a presentation. @@ -161,6 +167,8 @@ async def verify_presentation( presentation: Presentation data schemas: Schema data credential_definitions: credential definition data + rev_reg_defs: revocation registry definitions + rev_reg_entries: revocation registry entries """ (pv_result, pv_msg) = self.pre_verify(presentation_request, presentation) @@ -177,8 +185,8 @@ async def verify_presentation( json.dumps(presentation), json.dumps(schemas), json.dumps(credential_definitions), - json.dumps({}), # no revocation - json.dumps({}), + json.dumps(rev_reg_defs), + json.dumps(rev_reg_entries), ) except IndyError: LOGGER.exception( diff --git a/aries_cloudagent/verifier/tests/test_indy.py b/aries_cloudagent/verifier/tests/test_indy.py index 3c4ee58c06..87bd3bb6df 100644 --- a/aries_cloudagent/verifier/tests/test_indy.py +++ b/aries_cloudagent/verifier/tests/test_indy.py @@ -313,6 +313,8 @@ async def test_verify_presentation(self, mock_verify): "presentation", "schemas", "credential_definitions", + "rev_reg_defs", + "rev_reg_entries", ) mock_verify.assert_called_once_with( @@ -320,8 +322,8 @@ async def test_verify_presentation(self, mock_verify): json.dumps("presentation"), json.dumps("schemas"), json.dumps("credential_definitions"), - json.dumps({}), - json.dumps({}), + json.dumps("rev_reg_defs"), + json.dumps("rev_reg_entries"), ) assert verified == "val" @@ -331,7 +333,12 @@ async def test_check_encoding_attr(self, mock_verify): mock_verify.return_value = True verifier = IndyVerifier("wallet") verified = await verifier.verify_presentation( - INDY_PROOF_REQ_NAME, INDY_PROOF_NAME, "schemas", "credential_definitions" + INDY_PROOF_REQ_NAME, + INDY_PROOF_NAME, + "schemas", + "credential_definitions", + "rev_reg_defs", + "rev_reg_entries", ) mock_verify.assert_called_once_with( @@ -339,8 +346,8 @@ async def test_check_encoding_attr(self, mock_verify): json.dumps(INDY_PROOF_NAME), json.dumps("schemas"), json.dumps("credential_definitions"), - json.dumps({}), - json.dumps({}), + json.dumps("rev_reg_defs"), + json.dumps("rev_reg_entries"), ) assert verified == True @@ -356,7 +363,12 @@ async def test_check_encoding_attr_tamper_raw(self, mock_verify): ] = "Mock chicken" verified = await verifier.verify_presentation( - INDY_PROOF_REQ_NAME, INDY_PROOF_X, "schemas", "credential_definitions" + INDY_PROOF_REQ_NAME, + INDY_PROOF_X, + "schemas", + "credential_definitions", + "rev_reg_defs", + "rev_reg_entries", ) mock_verify.assert_not_called() @@ -374,7 +386,12 @@ async def test_check_encoding_attr_tamper_encoded(self, mock_verify): ] = "1234567890" verified = await verifier.verify_presentation( - INDY_PROOF_REQ_NAME, INDY_PROOF_X, "schemas", "credential_definitions" + INDY_PROOF_REQ_NAME, + INDY_PROOF_X, + "schemas", + "credential_definitions", + "rev_reg_defs", + "rev_reg_entries", ) mock_verify.assert_not_called() @@ -390,6 +407,8 @@ async def test_check_pred_names(self, mock_verify): INDY_PROOF_PRED_NAMES, "schemas", "credential_definitions", + "rev_reg_defs", + "rev_reg_entries", ) mock_verify.assert_called_once_with( @@ -397,8 +416,8 @@ async def test_check_pred_names(self, mock_verify): json.dumps(INDY_PROOF_PRED_NAMES), json.dumps("schemas"), json.dumps("credential_definitions"), - json.dumps({}), - json.dumps({}), + json.dumps("rev_reg_defs"), + json.dumps("rev_reg_entries"), ) assert verified == True @@ -414,7 +433,12 @@ async def test_check_pred_names_tamper_pred_value(self, mock_verify): ]["value"] = 0 verified = await verifier.verify_presentation( - INDY_PROOF_REQ_PRED_NAMES, INDY_PROOF_X, "schemas", "credential_definitions" + INDY_PROOF_REQ_PRED_NAMES, + INDY_PROOF_X, + "schemas", + "credential_definitions", + "rev_reg_defs", + "rev_reg_entries", ) mock_verify.assert_not_called() @@ -430,7 +454,12 @@ async def test_check_pred_names_tamper_pred_req_attr(self, mock_verify): INDY_PROOF_REQ_X["requested_predicates"]["18_busid_GE_uuid"]["name"] = "dummy" verified = await verifier.verify_presentation( - INDY_PROOF_REQ_X, INDY_PROOF_PRED_NAMES, "schemas", "credential_definitions" + INDY_PROOF_REQ_X, + INDY_PROOF_PRED_NAMES, + "schemas", + "credential_definitions", + "rev_reg_defs", + "rev_reg_entries", ) mock_verify.assert_not_called() @@ -448,7 +477,12 @@ async def test_check_pred_names_tamper_attr_groups(self, mock_verify): ] = INDY_PROOF_X["requested_proof"]["revealed_attr_groups"].pop("18_uuid") verified = await verifier.verify_presentation( - INDY_PROOF_REQ_PRED_NAMES, INDY_PROOF_X, "schemas", "credential_definitions" + INDY_PROOF_REQ_PRED_NAMES, + INDY_PROOF_X, + "schemas", + "credential_definitions", + "rev_reg_defs", + "rev_reg_entries", ) mock_verify.assert_not_called() diff --git a/bin/aca-py b/bin/aca-py index 6f37514a44..183a112886 100755 --- a/bin/aca-py +++ b/bin/aca-py @@ -3,9 +3,15 @@ import os import sys + ENABLE_PTVSD = os.getenv("ENABLE_PTVSD", "").lower() ENABLE_PTVSD = ENABLE_PTVSD and ENABLE_PTVSD not in ("false", "0") +ENABLE_PYDEVD_PYCHARM = os.getenv("ENABLE_PYDEVD_PYCHARM", "").lower() +ENABLE_PYDEVD_PYCHARM = ENABLE_PYDEVD_PYCHARM and ENABLE_PYDEVD_PYCHARM not in ("false", "0") +PYDEVD_PYCHARM_HOST = os.getenv("PYDEVD_PYCHARM_HOST", "localhost") +PYDEVD_PYCHARM_AGENT_PORT = int(os.getenv("PYDEVD_PYCHARM_AGENT_PORT", 5001)) + # --debug-vs to use microsoft's visual studio remote debugger if ENABLE_PTVSD or "--debug" in sys.argv: try: @@ -20,6 +26,17 @@ if ENABLE_PTVSD or "--debug" in sys.argv: print("ptvsd library was not found") +if ENABLE_PYDEVD_PYCHARM or "--debug-pycharm" in sys.argv: + try: + import pydevd_pycharm + + print(f"aca-py remote debugging to {PYDEVD_PYCHARM_HOST}:{PYDEVD_PYCHARM_AGENT_PORT}") + pydevd_pycharm.settrace(host=PYDEVD_PYCHARM_HOST, port=PYDEVD_PYCHARM_AGENT_PORT, + stdoutToServer=True, stderrToServer=True, suspend=False) + except ImportError: + print("pydevd_pycharm library was not found") + + from aries_cloudagent.commands import run_command # noqa if len(sys.argv) > 1 and sys.argv[1] and sys.argv[1][0] != "-": diff --git a/conftest.py b/conftest.py index a46dd600bc..cd6106642c 100644 --- a/conftest.py +++ b/conftest.py @@ -32,6 +32,7 @@ def pytest_sessionstart(session): modules[package_name] = mock.MagicMock() for mod in [ "anoncreds", + "blob_storage", "crypto", "did", "error", diff --git a/demo/run_demo b/demo/run_demo index 1fb51b5ceb..df75cb5ede 100755 --- a/demo/run_demo +++ b/demo/run_demo @@ -10,11 +10,33 @@ ARGS="" for i in "$@" do + if [ ! -z "$SKIP" ]; then + SKIP="" + continue + fi case $i in --events) EVENTS=1 continue ;; + --debug-ptvsd) + ENABLE_PTVSD=1 + continue + ;; + --debug-pycharm) + ENABLE_PYDEVD_PYCHARM=1 + continue + ;; + --debug-pycharm-controller-port) + PYDEVD_PYCHARM_CONTROLLER_PORT=$2 + SKIP=1 + continue + ;; + --debug-pycharm-agent-port) + PYDEVD_PYCHARM_AGENT_PORT=$2 + SKIP=1 + continue + ;; --timing) if [ ! -d "../logs" ]; then mkdir ../logs && chmod -R uga+rws ../logs @@ -89,6 +111,10 @@ if ! [ -z "$EVENTS" ]; then DOCKER_ENV="${DOCKER_ENV} -e EVENTS=1" fi +if ! [[ -z "${ENABLE_PYDEVD_PYCHARM}" ]]; then + DOCKER_ENV="${DOCKER_ENV} -e ENABLE_PYDEVD_PYCHARM=${ENABLE_PYDEVD_PYCHARM} -e PYDEVD_PYCHARM_CONTROLLER_PORT=${PYDEVD_PYCHARM_CONTROLLER_PORT} -e PYDEVD_PYCHARM_AGENT_PORT=${PYDEVD_PYCHARM_AGENT_PORT}" +fi + # on Windows, docker run needs to be prefixed by winpty if [ "$OSTYPE" = "msys" ]; then DOCKER="winpty docker" diff --git a/demo/runners/alice.py b/demo/runners/alice.py index f5ab30f326..f93507b06a 100644 --- a/demo/runners/alice.py +++ b/demo/runners/alice.py @@ -123,7 +123,7 @@ async def handle_present_proof(self, message): f"/present-proof/records/{presentation_exchange_id}/credentials" ) if credentials: - for row in credentials: + for row in sorted(credentials, key=lambda c: int(c["cred_info"]["attrs"]["timestamp"]), reverse=True): for referent in row["presentation_referents"]: if referent not in credentials_by_reft: credentials_by_reft[referent] = row @@ -293,6 +293,21 @@ async def main(start_port: int, no_auto: bool = False, show_timing: bool = False ) args = parser.parse_args() + ENABLE_PYDEVD_PYCHARM = os.getenv("ENABLE_PYDEVD_PYCHARM", "").lower() + ENABLE_PYDEVD_PYCHARM = ENABLE_PYDEVD_PYCHARM and ENABLE_PYDEVD_PYCHARM not in ("false", "0") + PYDEVD_PYCHARM_HOST = os.getenv("PYDEVD_PYCHARM_HOST", "localhost") + PYDEVD_PYCHARM_CONTROLLER_PORT = int(os.getenv("PYDEVD_PYCHARM_CONTROLLER_PORT", 5001)) + + if ENABLE_PYDEVD_PYCHARM: + try: + import pydevd_pycharm + + print(f"Alice remote debugging to {PYDEVD_PYCHARM_HOST}:{PYDEVD_PYCHARM_CONTROLLER_PORT}") + pydevd_pycharm.settrace(host=PYDEVD_PYCHARM_HOST, port=PYDEVD_PYCHARM_CONTROLLER_PORT, + stdoutToServer=True, stderrToServer=True, suspend=False) + except ImportError: + print("pydevd_pycharm library was not found") + require_indy() try: diff --git a/demo/runners/faber.py b/demo/runners/faber.py index 0cdc6ce492..8b71fb3de7 100644 --- a/demo/runners/faber.py +++ b/demo/runners/faber.py @@ -4,9 +4,12 @@ import os import random import sys +import time from uuid import uuid4 +from aiohttp import ClientError + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # noqa from runners.support.agent import DemoAgent, default_genesis_txns @@ -86,13 +89,18 @@ async def handle_issue_credential(self, message): {"name": n, "value": v} for (n, v) in cred_attrs.items() ], } - await self.admin_POST( - f"/issue-credential/records/{credential_exchange_id}/issue", - { - "comment": f"Issuing credential, exchange {credential_exchange_id}", - "credential_preview": cred_preview, - }, - ) + try: + await self.admin_POST( + f"/issue-credential/records/{credential_exchange_id}/issue", + { + "comment": ( + f"Issuing credential, exchange {credential_exchange_id}" + ), + "credential_preview": cred_preview, + }, + ) + except ClientError: + pass async def handle_present_proof(self, message): state = message["state"] @@ -118,7 +126,12 @@ async def handle_basicmessages(self, message): self.log("Received message:", message["content"]) -async def main(start_port: int, no_auto: bool = False, show_timing: bool = False): +async def main( + start_port: int, + no_auto: bool = False, + revocation: bool = False, + show_timing: bool = False, +): genesis = await default_genesis_txns() if not genesis: @@ -159,15 +172,31 @@ async def main(start_port: int, no_auto: bool = False, show_timing: bool = False _, # schema id credential_definition_id, ) = await agent.register_schema_and_creddef( - "degree schema", version, ["name", "date", "degree", "age"] + "degree schema", + version, + ["name", "date", "degree", "age", "timestamp"], + support_revocation=revocation, ) + if revocation: + with log_timer("Publish revocation registry duration:"): + log_status( + "#5/6 Create and publish the revocation registry on the ledger" + ) + revocation_registry_id = await ( + agent.create_and_publish_revocation_registry( + credential_definition_id, 2 + ) + ) + else: + revocation_registry_id = None + # TODO add an additional credential for Student ID with log_timer("Generate invitation duration:"): # Generate an invitation log_status( - "#5 Create a connection to alice and print out the invite details" + "#7 Create a connection to alice and print out the invite details" ) connection = await agent.admin_POST("/connections/create-invitation") @@ -180,10 +209,11 @@ async def main(start_port: int, no_auto: bool = False, show_timing: bool = False log_msg("Waiting for connection...") await agent.detect_connection() - async for option in prompt_loop( - "(1) Issue Credential, (2) Send Proof Request, " - + "(3) Send Message (X) Exit? [1/2/3/X] " - ): + options = "(1) Issue Credential (2) Send Proof Request (3) Send Message" + if revocation: + options += " (4) Revoke Credential (5) Add Revocation Registry" + options += " (X) Exit? [1/2/3/X] " + async for option in prompt_loop(options): if option is None or option in "xX": break @@ -196,6 +226,7 @@ async def main(start_port: int, no_auto: bool = False, show_timing: bool = False "date": "2018-05-28", "degree": "Maths", "age": "24", + "timestamp": str(int(time.time())), } cred_preview = { @@ -209,7 +240,9 @@ async def main(start_port: int, no_auto: bool = False, show_timing: bool = False "connection_id": agent.connection_id, "cred_def_id": credential_definition_id, "comment": f"Offer on cred def id {credential_definition_id}", + "auto_remove": False, "credential_preview": cred_preview, + "revoc_reg_id": revocation_registry_id, } await agent.admin_POST("/issue-credential/send-offer", offer_request) @@ -220,18 +253,28 @@ async def main(start_port: int, no_auto: bool = False, show_timing: bool = False req_attrs = [ {"name": "name", "restrictions": [{"issuer_did": agent.did}]}, {"name": "date", "restrictions": [{"issuer_did": agent.did}]}, - {"name": "degree", "restrictions": [{"issuer_did": agent.did}]}, - # include the following to test self-attested attributes - #{"name": "self_attested_thing"}, ] + if revocation: + req_attrs.append( + { + "name": "degree", + "restrictions": [{"issuer_did": agent.did}], + "non_revoked": {"to": int(time.time() - 1)}, + }, + ) + else: + req_attrs.append( + {"name": "degree", "restrictions": [{"issuer_did": agent.did}]} + ) + req_attrs.append({"name": "self_attested_thing"},) req_preds = [ - # include the following to test zero-knowledge proofs - #{ - # "name": "age", - # "p_type": ">=", - # "p_value": 18, - # "restrictions": [{"issuer_did": agent.did}], - #} + # test zero-knowledge proofs + { + "name": "age", + "p_type": ">=", + "p_value": 18, + "restrictions": [{"issuer_did": agent.did}], + } ] indy_proof_request = { "name": "Proof of Education", @@ -245,6 +288,8 @@ async def main(start_port: int, no_auto: bool = False, show_timing: bool = False for req_pred in req_preds }, } + if revocation: + indy_proof_request["non_revoked"] = {"to": int(time.time())} proof_request_web_request = { "connection_id": agent.connection_id, "proof_request": indy_proof_request, @@ -258,6 +303,18 @@ async def main(start_port: int, no_auto: bool = False, show_timing: bool = False await agent.admin_POST( f"/connections/{agent.connection_id}/send-message", {"content": msg} ) + elif option == "4" and revocation: + revoking_cred_id = await prompt("Enter credential exchange id: ") + await agent.admin_POST( + f"/issue-credential/records/{revoking_cred_id}/revoke" + ) + elif option == "5" and revocation: + log_status("#19 Add another revocation registry") + revocation_registry_id = await ( + agent.create_and_publish_revocation_registry( + credential_definition_id, 2 + ) + ) if show_timing: timing = await agent.fetch_timing() @@ -293,16 +350,47 @@ async def main(start_port: int, no_auto: bool = False, show_timing: bool = False metavar=(""), help="Choose the starting port number to listen on", ) + parser.add_argument( + "--revocation", action="store_true", help="Enable credential revocation" + ) parser.add_argument( "--timing", action="store_true", help="Enable timing information" ) args = parser.parse_args() + ENABLE_PYDEVD_PYCHARM = os.getenv("ENABLE_PYDEVD_PYCHARM", "").lower() + ENABLE_PYDEVD_PYCHARM = ENABLE_PYDEVD_PYCHARM and ENABLE_PYDEVD_PYCHARM not in ( + "false", + "0", + ) + PYDEVD_PYCHARM_HOST = os.getenv("PYDEVD_PYCHARM_HOST", "localhost") + PYDEVD_PYCHARM_CONTROLLER_PORT = int( + os.getenv("PYDEVD_PYCHARM_CONTROLLER_PORT", 5001) + ) + + if ENABLE_PYDEVD_PYCHARM: + try: + import pydevd_pycharm + + print( + "Faber remote debugging to " + f"{PYDEVD_PYCHARM_HOST}:{PYDEVD_PYCHARM_CONTROLLER_PORT}" + ) + pydevd_pycharm.settrace( + host=PYDEVD_PYCHARM_HOST, + port=PYDEVD_PYCHARM_CONTROLLER_PORT, + stdoutToServer=True, + stderrToServer=True, + suspend=False, + ) + except ImportError: + print("pydevd_pycharm library was not found") + require_indy() try: asyncio.get_event_loop().run_until_complete( - main(args.port, args.no_auto, args.timing) + main(args.port, args.no_auto, args.revocation, args.timing) ) except KeyboardInterrupt: os._exit(1) diff --git a/demo/runners/performance.py b/demo/runners/performance.py index d929881e6f..f5838c4ced 100644 --- a/demo/runners/performance.py +++ b/demo/runners/performance.py @@ -28,6 +28,7 @@ def __init__( self._connection_ready = None self.credential_state = {} self.credential_event = asyncio.Event() + self.cred_ex_ids = set() self.ping_state = {} self.ping_event = asyncio.Event() self.sent_pings = set() @@ -77,6 +78,7 @@ async def handle_connections(self, payload): async def handle_issue_credential(self, payload): cred_id = payload["credential_exchange_id"] self.credential_state[cred_id] = payload["state"] + self.cred_ex_ids.add(cred_id) self.credential_event.set() async def handle_ping(self, payload): @@ -156,10 +158,9 @@ def __init__(self, port: int, **kwargs): super().__init__("Faber", port, **kwargs) self.schema_id = None self.credential_definition_id = None - self.extra_args = ["--monitor-ping"] - self.timing_log = "logs/faber_perf.log" + self.revocation_registry_id = None - async def publish_defs(self): + async def publish_defs(self, support_revocation: bool = False): # create a schema self.log("Publishing test schema") version = format( @@ -177,7 +178,10 @@ async def publish_defs(self): # create a cred def for the schema self.log("Publishing test credential definition") - credential_definition_body = {"schema_id": self.schema_id} + credential_definition_body = { + "schema_id": self.schema_id, + "support_revocation": support_revocation, + } credential_definition_response = await self.admin_POST( "/credential-definitions", credential_definition_body ) @@ -186,7 +190,20 @@ async def publish_defs(self): ] self.log(f"Credential Definition ID: {self.credential_definition_id}") - async def send_credential(self, cred_attrs: dict, comment: str = None): + # create revocation registry + if support_revocation: + revoc_body = { + "credential_definition_id": self.credential_definition_id, + } + revoc_response = await self.admin_POST( + "/revocation/create-registry", revoc_body + ) + self.revocation_registry_id = revoc_response["result"]["revoc_reg_id"] + self.log(f"Revocation Registry ID: {self.revocation_registry_id}") + + async def send_credential( + self, cred_attrs: dict, comment: str = None, auto_remove: bool = True + ): cred_preview = { "attributes": [{"name": n, "value": v} for (n, v) in cred_attrs.items()] } @@ -197,9 +214,14 @@ async def send_credential(self, cred_attrs: dict, comment: str = None): "cred_def_id": self.credential_definition_id, "credential_proposal": cred_preview, "comment": comment, + "auto_remove": auto_remove, + "revoc_reg_id": self.revocation_registry_id, }, ) + async def revoke_credential(self, cred_ex_id: str): + await self.admin_POST(f"/issue-credential/records/{cred_ex_id}/revoke") + class RoutingAgent(BaseAgent): def __init__(self, port: int, **kwargs): @@ -213,6 +235,7 @@ async def main( show_timing: bool = False, routing: bool = False, issue_count: int = 300, + revoc: bool = False, ): genesis = await default_genesis_txns() @@ -249,7 +272,7 @@ async def main( if not ping_only: with log_timer("Publish duration:"): - await faber.publish_defs() + await faber.publish_defs(revoc) # await alice.set_tag_policy(faber.credential_definition_id, ["name"]) with log_timer("Connect duration:"): @@ -294,7 +317,7 @@ async def send_credential(index: int): "age": "24", } asyncio.ensure_future( - faber.send_credential(attributes, comment) + faber.send_credential(attributes, comment, not revoc) ).add_done_callback(done_send) async def check_received_creds(agent, issue_count, pb): @@ -406,6 +429,11 @@ async def check_received_pings(agent, issue_count, pb): for line in faber.format_postgres_stats(): faber.log(line) + cred_id = next(iter(faber.cred_ex_ids)) + if revoc: + print("Revoking credential", cred_id) + await faber.revoke_credential(cred_id) + if show_timing: timing = await alice.fetch_timing() if timing: diff --git a/demo/runners/support/agent.py b/demo/runners/support/agent.py index 8e845133eb..2d41326d47 100644 --- a/demo/runners/support/agent.py +++ b/demo/runners/support/agent.py @@ -6,6 +6,8 @@ import os import random import subprocess +import hashlib +import base58 from timeit import default_timer from aiohttp import ( @@ -152,7 +154,7 @@ def __init__( self.did = None self.wallet_stats = [] - async def register_schema_and_creddef(self, schema_name, version, schema_attrs): + async def register_schema_and_creddef(self, schema_name, version, schema_attrs, support_revocation: bool = False): # Create a schema schema_body = { "schema_name": schema_name, @@ -165,7 +167,10 @@ async def register_schema_and_creddef(self, schema_name, version, schema_attrs): log_msg("Schema ID:", schema_id) # Create a cred def for the schema - credential_definition_body = {"schema_id": schema_id} + credential_definition_body = { + "schema_id": schema_id, + "support_revocation": support_revocation + } credential_definition_response = await self.admin_POST( "/credential-definitions", credential_definition_body ) @@ -173,8 +178,46 @@ async def register_schema_and_creddef(self, schema_name, version, schema_attrs): "credential_definition_id" ] log_msg("Cred def ID:", credential_definition_id) + return schema_id, credential_definition_id + + async def create_and_publish_revocation_registry(self, credential_def_id, max_cred_num): + revoc_response = await self.admin_POST( + "/revocation/create-registry", + { + "credential_definition_id": credential_def_id, + "max_cred_num": max_cred_num + } + ) + revocation_registry_id = revoc_response["result"]["revoc_reg_id"] + tails_hash = revoc_response["result"]["tails_hash"] + + # get the tail file from "GET /revocation/registry/{id}/tails-file" + tails_file = await self.admin_GET_FILE(f"/revocation/registry/{revocation_registry_id}/tails-file") + hasher = hashlib.sha256() + hasher.update(tails_file) + my_tails_hash = base58.b58encode(hasher.digest()).decode("utf-8") + log_msg(f"Revocation Registry ID: {revocation_registry_id}") + assert tails_hash == my_tails_hash + + # Real app should publish tail file somewhere and update the revocation registry with the URI. + # But for the demo, assume the agent's admin end-points are accessible to the other agents + # Update the revocation registry with the public URL to the tail file + tails_file_url = f"{self.admin_url}/revocation/registry/{revocation_registry_id}/tails-file" + revoc_updated_response = await self.admin_PATCH( + f"/revocation/registry/{revocation_registry_id}", + { + "tails_public_uri": tails_file_url + } + ) + tails_public_uri = revoc_updated_response["result"]["tails_public_uri"] + log_msg(f"Revocation Registry Tail File URL: {tails_public_uri}") + assert tails_public_uri == tails_file_url - return (schema_id, credential_definition_id) + revoc_publish_response = await self.admin_POST( + f"/revocation/registry/{revocation_registry_id}/publish" + ) + + return revoc_publish_response["result"]["revoc_reg_id"] def get_agent_args(self): result = [ @@ -410,6 +453,23 @@ async def admin_POST( self.log(f"Error during POST {path}: {str(e)}") raise + async def admin_PATCH(self, path, data=None, text=False, params=None) -> ClientResponse: + try: + return await self.admin_request("PATCH", path, data, text, params) + except ClientError as e: + self.log(f"Error during PATCH {path}: {str(e)}") + raise + + async def admin_GET_FILE(self, path, params=None) -> bytes: + try: + params = {k: v for (k, v) in (params or {}).items() if v is not None} + resp = await self.client_session.request("GET", self.admin_url + path, params=params) + resp.raise_for_status() + return await resp.read() + except ClientError as e: + self.log(f"Error during GET FILE {path}: {str(e)}") + raise + async def detect_process(self): async def fetch_status(url: str, timeout: float): text = None diff --git a/docker/Dockerfile.demo b/docker/Dockerfile.demo index 2808533f03..8d17602556 100644 --- a/docker/Dockerfile.demo +++ b/docker/Dockerfile.demo @@ -1,6 +1,8 @@ FROM bcgovimages/von-image:py36-1.14-1 ENV ENABLE_PTVSD 0 +ENV ENABLE_PYDEVD_PYCHARM 0 +ENV PYDEVD_PYCHARM_HOST "host.docker.internal" # Add and install Indy Agent code ADD requirements*.txt ./ diff --git a/docs/GettingStartedAriesDev/CredentialRevocation.md b/docs/GettingStartedAriesDev/CredentialRevocation.md new file mode 100644 index 0000000000..92f0babe05 --- /dev/null +++ b/docs/GettingStartedAriesDev/CredentialRevocation.md @@ -0,0 +1,111 @@ +These are the ACA-py steps and APIs involved to support credential revocation. + +0. Publish credential definition + ``` + POST /credential-definitions + { + "schema_id": schema_id, + "support_revocation": true + } + Response: + { + "credential_definition_id": "credential_definition_id" + } + ``` + +0. Create (but not publish yet) Revocation registry + ``` + POST /revocation/create-registry, + { + "credential_definition_id": "credential_definition_id", + "max_cred_num": size_of_revocation_registry + } + Response: + { + "revoc_reg_id": "revocation_registry_id", + "tails_hash": hash_of_tails_file, + "cred_def_id": "credential_definition_id", + ... + } + ``` + +0. Get the tail file from agent + ``` + Get /revocation/registry/{revocation_registry_id}/tails-file + + Response: stream down a binary file: + content-type: application/octet-stream + ... + ``` +0. Upload the tails file to a publicly accessible location +0. Update the tails file public URI to agent + ``` + PATCH /revocation/registry/{revocation_registry_id} + { + "tails_public_uri": + } + ``` +0. Publish the revocation registry and first entry to the ledger + ``` + POST /revocation/registry/{revocation_registry_id}/publish + ``` + +0. Issue credential + ``` + POST /issue-credential/send-offer + { + "cred_def_id": credential_definition_id, + "revoc_reg_id": revocation_registry_id + "auto_remove": False, # We need the credential exchange record when revoking + ... + } + Response + { + "credential_exchange_id": credential_exchange_id + } + ``` +0. Revoking credential + ``` + POST /issue-credential/records/{credential_exchange_id}/revoke + ``` + +0. When asking for proof, specify the timespan when the credential is NOT revoked + ``` + POST /present-proof/send-request + { + "connection_id": ..., + "proof_request": { + "requested_attributes": [ + { + "name": ... + "restrictions": ..., + ... + "non_revoked": # Optional, override the global one when specified + { + "from": # Optional, default is 0 + "to": + } + }, + ... + ], + "requested_predicates": [ + { + "name": ... + ... + "non_revoked": # Optional, override the global one when specified + { + "from": # Optional, default is 0 + "to": + } + }, + ... + ], + "non_revoked": # Optional, only check revocation if specified + { + "from": # Optional, default is 0 + "to": + } + } + } + ``` + \ No newline at end of file diff --git a/docs/GettingStartedAriesDev/README.md b/docs/GettingStartedAriesDev/README.md index 6d6e838cf1..a857af1295 100644 --- a/docs/GettingStartedAriesDev/README.md +++ b/docs/GettingStartedAriesDev/README.md @@ -20,5 +20,6 @@ Note that in the guidance we have here, we include not only the links to look at * [Deeper Dive: DIDcomm Message Routing and Encryption](RoutingEncryption.md) * [Deeper Dive: Routing Example](AriesRoutingExample.md) * To Do: [Deeper Dive: Running and Connecting to an Indy Network](ConnectIndyNetwork.md) +* [Steps and APIs to support credential revocation with Aries agent](CredentialRevocation.md) Want to help with this guide? Please add issues or submit a pull request to improve the document. Point out things that are missing, things to improve and especially things that are wrong. diff --git a/requirements.dev.txt b/requirements.dev.txt index a2c335f469..48c9cd3b08 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -16,3 +16,4 @@ sphinx-rtd-theme>=0.4.3 ptvsd==4.3.2 pydevd==1.5.1 +pydevd-pycharm~=193.6015.39 \ No newline at end of file