diff --git a/aries_cloudagent/anoncreds/events.py b/aries_cloudagent/anoncreds/events.py new file mode 100644 index 0000000000..b87e13b411 --- /dev/null +++ b/aries_cloudagent/anoncreds/events.py @@ -0,0 +1,104 @@ +"""Events fired by AnonCreds interface.""" + +import re +from typing import NamedTuple + +from ..core.event_bus import Event +from .models.anoncreds_revocation import RevRegDef + + +CRED_DEF_FINISHED_EVENT = "anoncreds::credential-definition::finished" +REV_REG_DEF_FINISHED_EVENT = "anoncreds::revocation-registry-definition::finished" +REV_LIST_FINISHED_EVENT = "anoncreds::revocation-list::finished" + +CRED_DEF_FINISHED_PATTERN = re.compile("anoncreds::credential-definition::finished") +REV_REG_DEF_FINISHED_PATTERN = re.compile( + "anoncreds::revocation-registry-definition::finished" +) +REV_LIST_FINISHED_PATTERN = re.compile("anoncreds::revocation-list::finished") + + +class CredDefFinishedPayload(NamedTuple): + """Payload of cred def finished event.""" + + schema_id: str + cred_def_id: str + issuer_id: str + support_revocation: bool + max_cred_num: int + + +class CredDefFinishedEvent(Event): + """Event for cred def finished.""" + + def __init__( + self, + payload: CredDefFinishedPayload, + ): + self._topic = CRED_DEF_FINISHED_EVENT + self._payload = payload + + @classmethod + def with_payload( + cls, + schema_id: str, + cred_def_id: str, + issuer_id: str, + support_revocation: bool, + max_cred_num: int, + ): + payload = CredDefFinishedPayload( + schema_id, cred_def_id, issuer_id, support_revocation, max_cred_num + ) + return cls(payload) + + @property + def payload(self) -> CredDefFinishedPayload: + """Return payload.""" + return self._payload + + +class RevRegDefFinishedPayload(NamedTuple): + """Payload of rev reg def finished event.""" + + rev_reg_def_id: str + rev_reg_def: RevRegDef + + +class RevRegDefFinishedEvent(Event): + """Event for rev reg def finished.""" + + def __init__(self, payload: RevRegDefFinishedPayload): + self._topic = REV_REG_DEF_FINISHED_EVENT + self._payload = payload + + @classmethod + def with_payload( + cls, + rev_reg_def_id: str, + rev_reg_def: RevRegDef, + ): + payload = RevRegDefFinishedPayload(rev_reg_def_id, rev_reg_def) + return cls(payload) + + @property + def payload(self) -> RevRegDefFinishedPayload: + """Return payload.""" + return self._payload + + +class RevListFinishedPayload(NamedTuple): + """Payload of rev list finished event.""" + + +class RevListFinishedEvent(Event): + """Event for rev list finished.""" + + def __init__(self, payload: RevListFinishedPayload): + self._topic = REV_LIST_FINISHED_EVENT + self._payload = payload + + @property + def payload(self) -> RevListFinishedPayload: + """Return payload.""" + return self._payload diff --git a/aries_cloudagent/anoncreds/issuer.py b/aries_cloudagent/anoncreds/issuer.py index 7f2004fd75..ef0e024f48 100644 --- a/aries_cloudagent/anoncreds/issuer.py +++ b/aries_cloudagent/anoncreds/issuer.py @@ -1,10 +1,13 @@ """anoncreds-rs issuer implementation.""" import asyncio +import json import logging from time import time from typing import Optional, Sequence +from aries_askar import AskarError + from anoncreds import ( AnoncredsError, Credential, @@ -12,12 +15,13 @@ CredentialOffer, Schema, ) -from aries_askar import AskarError from ..askar.profile import AskarProfile, AskarProfileSession from ..core.error import BaseError +from ..core.event_bus import Event, EventBus from ..core.profile import Profile from .base import AnonCredsSchemaAlreadyExists +from .events import CredDefFinishedEvent from .models.anoncreds_cred_def import CredDef, CredDefResult from .models.anoncreds_schema import AnonCredsSchema, SchemaResult, SchemaState from .registry import AnonCredsRegistry @@ -26,6 +30,7 @@ DEFAULT_CRED_DEF_TAG = "default" DEFAULT_SIGNATURE_TYPE = "CL" +DEFAULT_MAX_CRED_NUM = 1000 CATEGORY_SCHEMA = "schema" CATEGORY_CRED_DEF = "credential_def" CATEGORY_CRED_DEF_PRIVATE = "credential_def_private" @@ -91,6 +96,11 @@ def profile(self) -> AskarProfile: return self._profile + async def notify(self, event: Event): + """Accessor for the event bus instance.""" + event_bus = self.profile.inject(EventBus) + await event_bus.notify(self._profile, event) + async def _finish_registration( self, txn: AskarProfileSession, category: str, job_id: str, registered_id: str ): @@ -113,6 +123,7 @@ async def _finish_registration( tags=tags, ) await txn.handle.remove(category, job_id) + return entry async def _store_schema( self, @@ -291,6 +302,12 @@ async def create_and_register_credential_definition( options = options or {} support_revocation = options.get("support_revocation", False) + if not isinstance(support_revocation, bool): + raise ValueError("support_revocation must be a boolean") + + max_cred_num = options.get("max_cred_num", DEFAULT_MAX_CRED_NUM) + if not isinstance(max_cred_num, int): + raise ValueError("max_cred_num must be an integer") try: # Create the cred def @@ -342,6 +359,11 @@ async def create_and_register_credential_definition( "schema_version": schema_result.schema.version, "state": result.credential_definition_state.state, "epoch": str(int(time())), + # TODO We need to keep track of these but tags probably + # isn't ideal. This suggests that a full record object + # is necessary for non-private values + "support_revocation": json.dumps(support_revocation), + "max_cred_num": str(max_cred_num), }, ) await txn.handle.insert( @@ -353,6 +375,12 @@ async def create_and_register_credential_definition( CATEGORY_CRED_DEF_KEY_PROOF, ident, key_proof.to_json_buffer() ) await txn.commit() + if result.credential_definition_state.state == STATE_FINISHED: + await self.notify( + CredDefFinishedEvent.with_payload( + schema_id, ident, issuer_id, support_revocation, max_cred_num + ) + ) except AskarError as err: raise AnonCredsIssuerError("Error storing credential definition") from err @@ -361,7 +389,13 @@ async def create_and_register_credential_definition( async def finish_cred_def(self, job_id: str, cred_def_id: str): """Finish a cred def.""" async with self.profile.transaction() as txn: - await self._finish_registration(txn, CATEGORY_CRED_DEF, job_id, cred_def_id) + entry = await self._finish_registration( + txn, CATEGORY_CRED_DEF, job_id, cred_def_id + ) + cred_def = CredDef.from_json(entry.value) + support_revocation = json.loads(entry.tags["support_revocation"]) + max_cred_num = int(entry.tags["max_cred_num"]) + await self._finish_registration( txn, CATEGORY_CRED_DEF_PRIVATE, job_id, cred_def_id ) @@ -370,6 +404,16 @@ async def finish_cred_def(self, job_id: str, cred_def_id: str): ) await txn.commit() + await self.notify( + CredDefFinishedEvent.with_payload( + schema_id=cred_def.schema_id, + cred_def_id=cred_def_id, + issuer_id=cred_def.issuer_id, + support_revocation=support_revocation, + max_cred_num=max_cred_num, + ) + ) + async def get_created_credential_definitions( self, issuer_id: Optional[str] = None, diff --git a/aries_cloudagent/anoncreds/revocation.py b/aries_cloudagent/anoncreds/revocation.py index 94d77b1360..da61d581d7 100644 --- a/aries_cloudagent/anoncreds/revocation.py +++ b/aries_cloudagent/anoncreds/revocation.py @@ -11,6 +11,10 @@ from typing import List, NamedTuple, Optional, Sequence, Tuple from urllib.parse import urlparse +from aries_askar.error import AskarError +import base58 +from requests import RequestException, Session + from anoncreds import ( AnoncredsError, Credential, @@ -18,15 +22,13 @@ RevocationRegistryDefinition, RevocationStatusList, ) -from aries_askar.error import AskarError -import base58 -from requests import RequestException, Session - from ..askar.profile import AskarProfile, AskarProfileSession from ..core.error import BaseError +from ..core.event_bus import Event, EventBus from ..core.profile import Profile, ProfileSession from ..tails.base import BaseTailsServer +from .events import RevRegDefFinishedEvent from .issuer import ( AnonCredsIssuer, CATEGORY_CRED_DEF, @@ -89,7 +91,10 @@ def profile(self) -> AskarProfile: return self._profile - # Revocation artifact management + async def notify(self, event: Event): + """Emit an event on the event bus.""" + event_bus = self.profile.inject(EventBus) + await event_bus.notify(self.profile, event) async def _finish_registration( self, @@ -123,6 +128,7 @@ async def _finish_registration( tags=tags, ) await txn.handle.remove(category, job_id) + return entry async def create_and_register_revocation_registry_definition( self, @@ -219,6 +225,11 @@ async def create_and_register_revocation_registry_definition( rev_reg_def_private.to_json_buffer(), ) await txn.commit() + + if result.revocation_registry_definition_state.state == STATE_FINISHED: + await self.notify( + RevRegDefFinishedEvent.with_payload(ident, rev_reg_def) + ) except AskarError as err: raise AnonCredsRevocationError( "Error saving new revocation registry" @@ -231,9 +242,10 @@ async def finish_revocation_registry_definition( ): """Mark a rev reg def as finished.""" async with self.profile.transaction() as txn: - await self._finish_registration( + entry = await self._finish_registration( txn, CATEGORY_REV_REG_DEF, job_id, rev_reg_def_id, state=STATE_FINISHED ) + rev_reg_def = RevRegDef.from_json(entry.value) await self._finish_registration( txn, CATEGORY_REV_REG_DEF_PRIVATE, @@ -242,6 +254,10 @@ async def finish_revocation_registry_definition( ) await txn.commit() + await self.notify( + RevRegDefFinishedEvent.with_payload(rev_reg_def_id, rev_reg_def) + ) + async def get_created_revocation_registry_definitions( self, cred_def_id: Optional[str] = None, diff --git a/aries_cloudagent/anoncreds/revocation_setup.py b/aries_cloudagent/anoncreds/revocation_setup.py new file mode 100644 index 0000000000..272387bc79 --- /dev/null +++ b/aries_cloudagent/anoncreds/revocation_setup.py @@ -0,0 +1,85 @@ +"""Automated setup process for AnonCreds credential definitions with revocation.""" + +from abc import ABC, abstractmethod +from ..anoncreds.revocation import AnonCredsRevocation +from ..core.profile import Profile +from ..core.event_bus import EventBus +from .events import ( + CRED_DEF_FINISHED_PATTERN, + REV_REG_DEF_FINISHED_PATTERN, + REV_LIST_FINISHED_PATTERN, + CredDefFinishedEvent, + RevRegDefFinishedEvent, + RevListFinishedEvent, +) + + +class AnonCredsRevocationSetupManager(ABC): + """Base class for automated setup of revocation.""" + + @abstractmethod + def register_events(self, event_bus: EventBus): + """Setup the manager.""" + + +class DefaultRevocationSetup(AnonCredsRevocationSetupManager): + """Manager for automated setup of revocation support. + + This manager models a state machine for the revocation setup process where + the transitions are triggered by the `finished` event of the previous + artifact. The state machine is as follows: + + [*] --> Cred Def + Cred Def --> Rev Reg Def + Rev Reg Def --> Rev List + Rev List --> [*] + + This implementation of an AnonCredsRevocationSetupManager will create two + revocation registries for each credential definition supporting revocation; + one that is active and one that is pending. When the active registry fills, + the pending registry will be activated and a new pending registry will be + created. This will continue indefinitely. + + This hot-swap approach to revocation registry management allows for + issuance operations to be performed without a delay for registry + creation. + """ + + REGISTRY_TYPE = "CL_ACCUM" + INITIAL_REGISTRY_COUNT = 2 + + def __init__(self): + """Init manager.""" + + def register_events(self, event_bus: EventBus): + """Register event listeners.""" + event_bus.subscribe(CRED_DEF_FINISHED_PATTERN, self.on_cred_def) + event_bus.subscribe(REV_REG_DEF_FINISHED_PATTERN, self.on_rev_reg_def) + event_bus.subscribe(REV_LIST_FINISHED_PATTERN, self.on_rev_list) + + async def on_cred_def(self, profile: Profile, event: CredDefFinishedEvent): + """Handle cred def finished.""" + payload = event.payload + if payload.support_revocation: + revoc = AnonCredsRevocation(profile) + for registry_count in range(self.INITIAL_REGISTRY_COUNT): + await revoc.create_and_register_revocation_registry_definition( + issuer_id=payload.issuer_id, + cred_def_id=payload.cred_def_id, + registry_type=self.REGISTRY_TYPE, + max_cred_num=payload.max_cred_num, + tag=str(registry_count), + ) + + async def on_rev_reg_def(self, profile: Profile, event: RevRegDefFinishedEvent): + """Handle rev reg def finished.""" + revoc = AnonCredsRevocation(profile) + await revoc.upload_tails_file(event.payload.rev_reg_def) + await revoc.create_and_register_revocation_list(event.payload.rev_reg_def_id) + + if event.payload.rev_reg_def.tag == str(0): + # Mark the first registry as active + await revoc.set_active_registry(event.payload.rev_reg_def_id) + + async def on_rev_list(self, profile: Profile, event: RevListFinishedEvent): + """Handle rev list finished.""" diff --git a/aries_cloudagent/anoncreds/routes.py b/aries_cloudagent/anoncreds/routes.py index ea1e54cf8d..038a9555bb 100644 --- a/aries_cloudagent/anoncreds/routes.py +++ b/aries_cloudagent/anoncreds/routes.py @@ -14,6 +14,7 @@ from ..admin.request_context import AdminRequestContext from ..askar.profile import AskarProfile +from ..core.event_bus import EventBus from ..messaging.models.openapi import OpenAPISchema from ..messaging.valid import UUIDFour from ..revocation.error import RevocationError, RevocationNotSupportedError @@ -37,6 +38,7 @@ ) from .registry import AnonCredsRegistry from .revocation import AnonCredsRevocation, AnonCredsRevocationError +from .revocation_setup import DefaultRevocationSetup LOGGER = logging.getLogger(__name__) @@ -593,6 +595,13 @@ async def register(app: web.Application): ) +def register_events(event_bus: EventBus): + """Register events.""" + # TODO Make this pluggable? + setup_manager = DefaultRevocationSetup() + setup_manager.register_events(event_bus) + + def post_process_routes(app: web.Application): """Amend swagger API."""