From 232827f2faa98fac9c73698633db1d0e73c19841 Mon Sep 17 00:00:00 2001 From: Daniel Bluhm Date: Thu, 22 Jun 2023 10:57:22 -0400 Subject: [PATCH] feat: add anoncreds interface And use it in Issue Credential v2 and Present Proof v2 Signed-off-by: Daniel Bluhm --- .github/workflows/tests.yml | 1 + aries_cloudagent/anoncreds/__init__.py | 42 + aries_cloudagent/anoncreds/base.py | 179 +++ .../anoncreds/default/__init__.py | 0 .../anoncreds/default/did_indy/__init__.py | 0 .../anoncreds/default/did_indy/registry.py | 112 ++ .../anoncreds/default/did_indy/routes.py | 1 + .../anoncreds/default/did_web/__init__.py | 0 .../anoncreds/default/did_web/registry.py | 107 ++ .../anoncreds/default/did_web/routes.py | 1 + .../default/did_web/tests/__init__.py | 0 .../default/did_web/tests/test_registry.py | 37 + .../anoncreds/default/legacy_indy/__init__.py | 0 .../anoncreds/default/legacy_indy/registry.py | 739 +++++++++++ .../anoncreds/default/legacy_indy/routes.py | 1 + .../default/legacy_indy/tests/__init__.py | 0 .../legacy_indy/tests/test_registry.py | 54 + aries_cloudagent/anoncreds/holder.py | 604 +++++++++ aries_cloudagent/anoncreds/issuer.py | 559 ++++++++ aries_cloudagent/anoncreds/models/__init__.py | 0 .../anoncreds/models/anoncreds_cred_def.py | 323 +++++ .../anoncreds/models/anoncreds_revocation.py | 426 +++++++ .../anoncreds/models/anoncreds_schema.py | 199 +++ aries_cloudagent/anoncreds/registry.py | 176 +++ aries_cloudagent/anoncreds/revocation.py | 1124 +++++++++++++++++ aries_cloudagent/anoncreds/routes.py | 608 +++++++++ aries_cloudagent/anoncreds/tests/__init__.py | 0 .../anoncreds/tests/test_routes.py | 16 + aries_cloudagent/anoncreds/util.py | 39 + aries_cloudagent/anoncreds/verifier.py | 503 ++++++++ aries_cloudagent/askar/profile.py | 30 +- aries_cloudagent/config/default_context.py | 10 +- aries_cloudagent/config/injection_context.py | 2 +- aries_cloudagent/core/conductor.py | 10 +- aries_cloudagent/ledger/base.py | 270 ++-- aries_cloudagent/ledger/error.py | 25 + .../v2_0/formats/indy/handler.py | 174 +-- .../v2_0/formats/indy/tests/test_handler.py | 57 +- .../v2_0/handlers/cred_issue_handler.py | 4 +- .../v2_0/handlers/cred_offer_handler.py | 4 +- .../v2_0/handlers/cred_proposal_handler.py | 4 +- .../v2_0/handlers/cred_request_handler.py | 4 +- .../handlers/tests/test_cred_issue_handler.py | 2 +- .../handlers/tests/test_cred_offer_handler.py | 2 +- .../tests/test_cred_proposal_handler.py | 2 +- .../tests/test_cred_request_handler.py | 2 +- .../protocols/issue_credential/v2_0/routes.py | 16 +- .../v2_0/tests/test_manager.py | 4 +- .../v2_0/tests/test_routes.py | 2 +- .../present_proof/indy/pres_exch_handler.py | 328 +++-- .../v2_0/formats/indy/handler.py | 20 +- .../v2_0/handlers/pres_request_handler.py | 4 +- .../tests/test_pres_request_handler.py | 18 +- .../v2_0/messages/tests/test_pres_format.py | 2 +- .../v2_0/messages/tests/test_pres_proposal.py | 2 +- .../v2_0/messages/tests/test_pres_request.py | 2 +- .../v2_0/models/tests/test_record.py | 2 +- .../protocols/present_proof/v2_0/routes.py | 10 +- .../present_proof/v2_0/tests/test_manager.py | 34 +- .../present_proof/v2_0/tests/test_routes.py | 58 +- aries_cloudagent/revocation/manager.py | 181 ++- .../models/issuer_cred_rev_record.py | 6 - aries_cloudagent/revocation/recover.py | 2 + .../revocation/tests/test_manager.py | 30 +- .../tails/anoncreds_tails_server.py | 64 + aries_cloudagent/tails/base.py | 9 +- aries_cloudagent/tails/indy_tails_server.py | 10 +- demo/run_bdd | 5 +- docker/Dockerfile.indy | 4 +- docker/Dockerfile.run | 18 +- docker/Dockerfile.test | 18 +- requirements.anoncreds.txt | 1 + scripts/run_docker | 4 +- setup.py | 1 + 74 files changed, 6546 insertions(+), 762 deletions(-) create mode 100644 aries_cloudagent/anoncreds/__init__.py create mode 100644 aries_cloudagent/anoncreds/base.py create mode 100644 aries_cloudagent/anoncreds/default/__init__.py create mode 100644 aries_cloudagent/anoncreds/default/did_indy/__init__.py create mode 100644 aries_cloudagent/anoncreds/default/did_indy/registry.py create mode 100644 aries_cloudagent/anoncreds/default/did_indy/routes.py create mode 100644 aries_cloudagent/anoncreds/default/did_web/__init__.py create mode 100644 aries_cloudagent/anoncreds/default/did_web/registry.py create mode 100644 aries_cloudagent/anoncreds/default/did_web/routes.py create mode 100644 aries_cloudagent/anoncreds/default/did_web/tests/__init__.py create mode 100644 aries_cloudagent/anoncreds/default/did_web/tests/test_registry.py create mode 100644 aries_cloudagent/anoncreds/default/legacy_indy/__init__.py create mode 100644 aries_cloudagent/anoncreds/default/legacy_indy/registry.py create mode 100644 aries_cloudagent/anoncreds/default/legacy_indy/routes.py create mode 100644 aries_cloudagent/anoncreds/default/legacy_indy/tests/__init__.py create mode 100644 aries_cloudagent/anoncreds/default/legacy_indy/tests/test_registry.py create mode 100644 aries_cloudagent/anoncreds/holder.py create mode 100644 aries_cloudagent/anoncreds/issuer.py create mode 100644 aries_cloudagent/anoncreds/models/__init__.py create mode 100644 aries_cloudagent/anoncreds/models/anoncreds_cred_def.py create mode 100644 aries_cloudagent/anoncreds/models/anoncreds_revocation.py create mode 100644 aries_cloudagent/anoncreds/models/anoncreds_schema.py create mode 100644 aries_cloudagent/anoncreds/registry.py create mode 100644 aries_cloudagent/anoncreds/revocation.py create mode 100644 aries_cloudagent/anoncreds/routes.py create mode 100644 aries_cloudagent/anoncreds/tests/__init__.py create mode 100644 aries_cloudagent/anoncreds/tests/test_routes.py create mode 100644 aries_cloudagent/anoncreds/util.py create mode 100644 aries_cloudagent/anoncreds/verifier.py create mode 100644 aries_cloudagent/tails/anoncreds_tails_server.py create mode 100644 requirements.anoncreds.txt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 919d1e4f21..48952dcd3c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,6 +29,7 @@ jobs: -r requirements.txt \ -r requirements.askar.txt \ -r requirements.bbs.txt \ + -r requirements.anoncreds.txt \ -r requirements.dev.txt - name: Tests run: | diff --git a/aries_cloudagent/anoncreds/__init__.py b/aries_cloudagent/anoncreds/__init__.py new file mode 100644 index 0000000000..f18f0bad83 --- /dev/null +++ b/aries_cloudagent/anoncreds/__init__.py @@ -0,0 +1,42 @@ +import logging + +from ..config.injection_context import InjectionContext +from ..config.provider import ClassProvider + +from .registry import AnonCredsRegistry + +LOGGER = logging.getLogger(__name__) + + +async def setup(context: InjectionContext): + """Set up default resolvers.""" + registry = context.inject_or(AnonCredsRegistry) + if not registry: + LOGGER.warning("No AnonCredsRegistry instance found in context") + return + + indy_registry = ClassProvider( + "aries_cloudagent.anoncreds.default.did_indy.registry.DIDIndyRegistry", + # supported_identifiers=[], + # method_name="did:indy", + ).provide(context.settings, context.injector) + await indy_registry.setup(context) + registry.register(indy_registry) + + web_registry = ClassProvider( + "aries_cloudagent.anoncreds.default.did_web.registry.DIDWebRegistry", + # supported_identifiers=[], + # method_name="did:web", + ).provide(context.settings, context.injector) + await web_registry.setup(context) + registry.register(web_registry) + + legacy_indy_registry = ClassProvider( + "aries_cloudagent.anoncreds.default.legacy_indy.registry.LegacyIndyRegistry", + # supported_identifiers=[], + # method_name="", + ).provide(context.settings, context.injector) + await legacy_indy_registry.setup(context) + registry.register(legacy_indy_registry) + + # TODO: add context.settings diff --git a/aries_cloudagent/anoncreds/base.py b/aries_cloudagent/anoncreds/base.py new file mode 100644 index 0000000000..bb75c11511 --- /dev/null +++ b/aries_cloudagent/anoncreds/base.py @@ -0,0 +1,179 @@ +"""Base Registry.""" +from abc import ABC, abstractmethod +from typing import Generic, Optional, Pattern, Sequence, TypeVar + +from ..config.injection_context import InjectionContext +from ..core.error import BaseError +from ..core.profile import Profile +from .models.anoncreds_cred_def import ( + CredDef, + CredDefResult, + GetCredDefResult, +) +from .models.anoncreds_revocation import ( + GetRevListResult, + GetRevRegDefResult, + RevRegDef, + RevRegDefResult, + RevList, + RevListResult, +) +from .models.anoncreds_schema import AnonCredsSchema, GetSchemaResult, SchemaResult + + +T = TypeVar("T") + + +class BaseAnonCredsError(BaseError): + """Base error class for AnonCreds.""" + + +class AnonCredsObjectNotFound(BaseAnonCredsError): + """Raised when object is not found in resolver.""" + + def __init__( + self, message: Optional[str] = None, resolution_metadata: Optional[dict] = None + ): + """Constructor.""" + super().__init__(message, resolution_metadata) + self.resolution_metadata = resolution_metadata + + +class AnonCredsRegistrationError(BaseAnonCredsError): + """Raised when registering an AnonCreds object fails.""" + + +class AnonCredsObjectAlreadyExists(AnonCredsRegistrationError, Generic[T]): + """Raised when an AnonCreds object already exists.""" + + def __init__( + self, + message: str, + obj_id: str, + obj: T = None, + *args, + **kwargs, + ): + super().__init__(message, obj_id, obj, *args, **kwargs) + self._message = message + self.obj_id = obj_id + self.obj = obj + + @property + def message(self): + return f"{self._message}: {self.obj_id}, {self.obj}" + + +class AnonCredsSchemaAlreadyExists(AnonCredsObjectAlreadyExists[AnonCredsSchema]): + """Raised when a schema already exists.""" + + @property + def schema_id(self): + """Get Schema Id.""" + return self.obj_id + + @property + def schema(self): + """Get Schema.""" + return self.obj + + +class AnonCredsResolutionError(BaseAnonCredsError): + """Raised when resolving an AnonCreds object fails.""" + + +class BaseAnonCredsHandler(ABC): + """Base Anon Creds Handler.""" + + @property + @abstractmethod + def supported_identifiers_regex(self) -> Pattern: + """Regex to match supported identifiers.""" + + async def supports(self, identifier: str) -> bool: + """Determine whether this registry supports the given identifier.""" + return bool(self.supported_identifiers_regex.match(identifier)) + + @abstractmethod + async def setup(self, context: InjectionContext): + """Class Setup method.""" + + +class BaseAnonCredsResolver(BaseAnonCredsHandler): + """Base Anon Creds Resolver.""" + + @abstractmethod + async def get_schema(self, profile: Profile, schema_id: str) -> GetSchemaResult: + """Get a schema from the registry.""" + + @abstractmethod + async def get_credential_definition( + self, profile: Profile, credential_definition_id: str + ) -> GetCredDefResult: + """Get a credential definition from the registry.""" + + @abstractmethod + async def get_revocation_registry_definition( + self, profile: Profile, revocation_registry_id: str + ) -> GetRevRegDefResult: + """Get a revocation registry definition from the registry.""" + + @abstractmethod + async def get_revocation_list( + self, profile: Profile, revocation_registry_id: str, timestamp: int + ) -> GetRevListResult: + """Get a revocation list from the registry.""" + + +class BaseAnonCredsRegistrar(BaseAnonCredsHandler): + """Base Anon Creds Registrar.""" + + @abstractmethod + async def register_schema( + self, + profile: Profile, + schema: AnonCredsSchema, + options: Optional[dict] = None, + ) -> SchemaResult: + """Register a schema on the registry.""" + + @abstractmethod + async def register_credential_definition( + self, + profile: Profile, + schema: GetSchemaResult, + credential_definition: CredDef, + options: Optional[dict] = None, + ) -> CredDefResult: + """Register a credential definition on the registry.""" + + @abstractmethod + async def register_revocation_registry_definition( + self, + profile: Profile, + revocation_registry_definition: RevRegDef, + options: Optional[dict] = None, + ) -> RevRegDefResult: + """Register a revocation registry definition on the registry.""" + + @abstractmethod + async def register_revocation_list( + self, + profile: Profile, + rev_reg_def: RevRegDef, + rev_list: RevList, + options: Optional[dict] = None, + ) -> RevListResult: + """Register a revocation list on the registry.""" + + @abstractmethod + async def update_revocation_list( + self, + profile: Profile, + rev_reg_def: RevRegDef, + prev_list: RevList, + curr_list: RevList, + revoked: Sequence[int], + options: Optional[dict] = None, + ) -> RevListResult: + """Update a revocation list on the registry.""" diff --git a/aries_cloudagent/anoncreds/default/__init__.py b/aries_cloudagent/anoncreds/default/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/anoncreds/default/did_indy/__init__.py b/aries_cloudagent/anoncreds/default/did_indy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/anoncreds/default/did_indy/registry.py b/aries_cloudagent/anoncreds/default/did_indy/registry.py new file mode 100644 index 0000000000..1032560644 --- /dev/null +++ b/aries_cloudagent/anoncreds/default/did_indy/registry.py @@ -0,0 +1,112 @@ +"""DID Indy Registry""" +import logging +import re +from typing import Optional, Pattern, Sequence + +from ....config.injection_context import InjectionContext +from ....core.profile import Profile +from ...models.anoncreds_cred_def import ( + CredDef, + CredDefResult, + GetCredDefResult, +) +from ...models.anoncreds_revocation import ( + GetRevListResult, + GetRevRegDefResult, + RevRegDef, + RevRegDefResult, + RevList, + RevListResult, +) +from ...models.anoncreds_schema import AnonCredsSchema, GetSchemaResult, SchemaResult +from ...base import BaseAnonCredsRegistrar, BaseAnonCredsResolver + +LOGGER = logging.getLogger(__name__) + + +class DIDIndyRegistry(BaseAnonCredsResolver, BaseAnonCredsRegistrar): + """DIDIndyRegistry""" + + def __init__(self): + self._supported_identifiers_regex = re.compile(r"^did:indy:.*$") + + @property + def supported_identifiers_regex(self) -> Pattern: + return self._supported_identifiers_regex + # TODO: fix regex (too general) + + async def setup(self, context: InjectionContext): + """Setup.""" + print("Successfully registered DIDIndyRegistry") + + async def get_schema(self, profile: Profile, schema_id: str) -> GetSchemaResult: + """Get a schema from the registry.""" + raise NotImplementedError() + + async def register_schema( + self, + profile: Profile, + schema: AnonCredsSchema, + options: Optional[dict], + ) -> SchemaResult: + """Register a schema on the registry.""" + raise NotImplementedError() + + async def get_credential_definition( + self, profile: Profile, credential_definition_id: str + ) -> GetCredDefResult: + """Get a credential definition from the registry.""" + raise NotImplementedError() + + async def register_credential_definition( + self, + profile: Profile, + schema: GetSchemaResult, + credential_definition: CredDef, + options: Optional[dict] = None, + ) -> CredDefResult: + """Register a credential definition on the registry.""" + raise NotImplementedError() + + async def get_revocation_registry_definition( + self, profile: Profile, revocation_registry_id: str + ) -> GetRevRegDefResult: + """Get a revocation registry definition from the registry.""" + raise NotImplementedError() + + async def register_revocation_registry_definition( + self, + profile: Profile, + revocation_registry_definition: RevRegDef, + options: Optional[dict] = None, + ) -> RevRegDefResult: + """Register a revocation registry definition on the registry.""" + raise NotImplementedError() + + async def get_revocation_list( + self, profile: Profile, revocation_registry_id: str, timestamp: int + ) -> GetRevListResult: + """Get a revocation list from the registry.""" + raise NotImplementedError() + + async def register_revocation_list( + self, + profile: Profile, + rev_reg_def: RevRegDef, + rev_list: RevList, + options: Optional[dict] = None, + ) -> RevListResult: + """Register a revocation list on the registry.""" + raise NotImplementedError() + + async def update_revocation_list( + self, + profile: Profile, + rev_reg_def: RevRegDef, + prev_list: RevList, + curr_list: RevList, + revoked: Sequence[int], + options: Optional[dict] = None, + ) -> RevListResult: + """Update a revocation list on the registry.""" + raise NotImplementedError() diff --git a/aries_cloudagent/anoncreds/default/did_indy/routes.py b/aries_cloudagent/anoncreds/default/did_indy/routes.py new file mode 100644 index 0000000000..f38bfd6623 --- /dev/null +++ b/aries_cloudagent/anoncreds/default/did_indy/routes.py @@ -0,0 +1 @@ +"""Routes for DID Indy Registry""" diff --git a/aries_cloudagent/anoncreds/default/did_web/__init__.py b/aries_cloudagent/anoncreds/default/did_web/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/anoncreds/default/did_web/registry.py b/aries_cloudagent/anoncreds/default/did_web/registry.py new file mode 100644 index 0000000000..fb2999c256 --- /dev/null +++ b/aries_cloudagent/anoncreds/default/did_web/registry.py @@ -0,0 +1,107 @@ +"""DID Web Registry""" +import re +from typing import Optional, Pattern, Sequence + +from ....config.injection_context import InjectionContext +from ....core.profile import Profile +from ...base import BaseAnonCredsRegistrar, BaseAnonCredsResolver +from ...models.anoncreds_cred_def import CredDef, CredDefResult, GetCredDefResult +from ...models.anoncreds_revocation import ( + GetRevListResult, + GetRevRegDefResult, + RevList, + RevListResult, + RevRegDef, + RevRegDefResult, +) +from ...models.anoncreds_schema import AnonCredsSchema, GetSchemaResult, SchemaResult + + +class DIDWebRegistry(BaseAnonCredsResolver, BaseAnonCredsRegistrar): + """DIDWebRegistry""" + + def __init__(self): + self._supported_identifiers_regex = re.compile( + r"^did:web:[a-z0-9]+(?:\.[a-z0-9]+)*(?::\d+)?(?:\/[^#\s]*)?(?:#.*)?\s*$" + ) + + @property + def supported_identifiers_regex(self) -> Pattern: + return self._supported_identifiers_regex + # TODO: fix regex (too general) + + async def setup(self, context: InjectionContext): + """Setup.""" + print("Successfully registered DIDWebRegistry") + + async def get_schema(self, profile, schema_id: str) -> GetSchemaResult: + """Get a schema from the registry.""" + raise NotImplementedError() + + async def register_schema( + self, + profile: Profile, + schema: AnonCredsSchema, + options: Optional[dict] = None, + ) -> SchemaResult: + """Register a schema on the registry.""" + raise NotImplementedError() + + async def get_credential_definition( + self, profile: Profile, credential_definition_id: str + ) -> GetCredDefResult: + """Get a credential definition from the registry.""" + raise NotImplementedError() + + async def register_credential_definition( + self, + profile: Profile, + schema: GetSchemaResult, + credential_definition: CredDef, + options: Optional[dict] = None, + ) -> CredDefResult: + """Register a credential definition on the registry.""" + raise NotImplementedError() + + async def get_revocation_registry_definition( + self, profile: Profile, revocation_registry_id: str + ) -> GetRevRegDefResult: + """Get a revocation registry definition from the registry.""" + raise NotImplementedError() + + async def register_revocation_registry_definition( + self, + profile: Profile, + revocation_registry_definition: RevRegDef, + options: Optional[dict] = None, + ) -> RevRegDefResult: + """Register a revocation registry definition on the registry.""" + raise NotImplementedError() + + async def get_revocation_list( + self, profile: Profile, revocation_registry_id: str, timestamp: int + ) -> GetRevListResult: + """Get a revocation list from the registry.""" + raise NotImplementedError() + + async def register_revocation_list( + self, + profile: Profile, + rev_reg_def: RevRegDef, + rev_list: RevList, + options: Optional[dict] = None, + ) -> RevListResult: + """Register a revocation list on the registry.""" + raise NotImplementedError() + + async def update_revocation_list( + self, + profile: Profile, + rev_reg_def: RevRegDef, + prev_list: RevList, + curr_list: RevList, + revoked: Sequence[int], + options: Optional[dict] = None, + ) -> RevListResult: + """Update a revocation list on the registry.""" + raise NotImplementedError() diff --git a/aries_cloudagent/anoncreds/default/did_web/routes.py b/aries_cloudagent/anoncreds/default/did_web/routes.py new file mode 100644 index 0000000000..0900c1440c --- /dev/null +++ b/aries_cloudagent/anoncreds/default/did_web/routes.py @@ -0,0 +1 @@ +"""Routes for DID Web Registry""" diff --git a/aries_cloudagent/anoncreds/default/did_web/tests/__init__.py b/aries_cloudagent/anoncreds/default/did_web/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/anoncreds/default/did_web/tests/test_registry.py b/aries_cloudagent/anoncreds/default/did_web/tests/test_registry.py new file mode 100644 index 0000000000..5a06d8d879 --- /dev/null +++ b/aries_cloudagent/anoncreds/default/did_web/tests/test_registry.py @@ -0,0 +1,37 @@ +"""Test DIDWebRegistry.""" + +import pytest +import re +from ..registry import DIDWebRegistry + +DID_WEB = re.compile( + r"^did:web:[a-z0-9]+(?:\.[a-z0-9]+)*(?::\d+)?(?:\/[^#\s]*)?(?:#.*)?\s*$" +) + + +TEST_WED_DID_0 = "did:web:example.com/anoncreds/v0/SCHEMA/asdf" +TEST_WED_DID_1 = "did:web:example.com" +TEST_WED_DID_2 = "did:web:sub.example.com" +TEST_WED_DID_3 = "did:web:example.com:8080" +TEST_WED_DID_4 = "did:web:sub.example.com/path/to/resource" +TEST_WED_DID_5 = "did:web:example.com/path/to/resource#fragment" + + +@pytest.fixture +def registry(): + """Registry fixture""" + yield DIDWebRegistry() + + +class TestLegacyIndyRegistry: + @pytest.mark.asyncio + async def test_supported_did_regex(self, registry: DIDWebRegistry): + """Test the supported_did_regex.""" + + assert registry.supported_identifiers_regex == DID_WEB + assert bool(registry.supported_identifiers_regex.match(TEST_WED_DID_0)) + assert bool(registry.supported_identifiers_regex.match(TEST_WED_DID_1)) + assert bool(registry.supported_identifiers_regex.match(TEST_WED_DID_2)) + assert bool(registry.supported_identifiers_regex.match(TEST_WED_DID_3)) + assert bool(registry.supported_identifiers_regex.match(TEST_WED_DID_4)) + assert bool(registry.supported_identifiers_regex.match(TEST_WED_DID_5)) diff --git a/aries_cloudagent/anoncreds/default/legacy_indy/__init__.py b/aries_cloudagent/anoncreds/default/legacy_indy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/anoncreds/default/legacy_indy/registry.py b/aries_cloudagent/anoncreds/default/legacy_indy/registry.py new file mode 100644 index 0000000000..1a4f9b2b97 --- /dev/null +++ b/aries_cloudagent/anoncreds/default/legacy_indy/registry.py @@ -0,0 +1,739 @@ +"""Legacy Indy Registry""" +from asyncio import shield +import json +import logging +import re +from typing import List, Optional, Pattern, Sequence, Tuple + +from ....cache.base import BaseCache +from ....config.injection_context import InjectionContext +from ....core.profile import Profile +from ....ledger.base import BaseLedger +from ....ledger.error import ( + LedgerError, + LedgerObjectAlreadyExistsError, + LedgerTransactionError, +) +from ....ledger.merkel_validation.constants import GET_SCHEMA +from ....ledger.multiple_ledger.ledger_requests_executor import ( + GET_CRED_DEF, + IndyLedgerRequestsExecutor, +) +from ....multitenant.base import BaseMultitenantManager +from ....revocation.models.issuer_cred_rev_record import IssuerCredRevRecord +from ....revocation.recover import generate_ledger_rrrecovery_txn +from ...base import ( + AnonCredsObjectAlreadyExists, + AnonCredsObjectNotFound, + AnonCredsRegistrationError, + AnonCredsResolutionError, + AnonCredsSchemaAlreadyExists, + BaseAnonCredsRegistrar, + BaseAnonCredsResolver, +) +from ...issuer import AnonCredsIssuer, AnonCredsIssuerError +from ...models.anoncreds_cred_def import ( + CredDef, + CredDefResult, + CredDefState, + CredDefValue, + GetCredDefResult, +) +from ...models.anoncreds_revocation import ( + GetRevRegDefResult, + GetRevListResult, + RevRegDef, + RevRegDefResult, + RevRegDefState, + RevList, + RevListResult, + RevListState, + RevRegDefValue, +) +from ...models.anoncreds_schema import ( + AnonCredsSchema, + GetSchemaResult, + SchemaResult, + SchemaState, +) +from base58 import alphabet + +LOGGER = logging.getLogger(__name__) + +DEFAULT_CRED_DEF_TAG = "default" +DEFAULT_SIGNATURE_TYPE = "CL" + + +class LegacyIndyRegistry(BaseAnonCredsResolver, BaseAnonCredsRegistrar): + """LegacyIndyRegistry""" + + def __init__(self): + B58 = alphabet if isinstance(alphabet, str) else alphabet.decode("ascii") + INDY_DID = rf"^(did:sov:)?[{B58}]{{21,22}}$" + INDY_SCHEMA_ID = rf"^[{B58}]{{21,22}}:2:.+:[0-9.]+$" + INDY_CRED_DEF_ID = ( + rf"^([{B58}]{{21,22}})" # issuer DID + f":3" # cred def id marker + f":CL" # sig alg + rf":(([1-9][0-9]*)|([{B58}]{{21,22}}:2:.+:[0-9.]+))" # schema txn / id + f":(.+)?$" # tag + ) + INDY_REV_REG_DEF_ID = ( + rf"^([{B58}]{{21,22}}):4:" + rf"([{B58}]{{21,22}}):3:" + rf"CL:(([1-9][0-9]*)|([{B58}]{{21,22}}:2:.+:[0-9.]+))(:.+)?:" + rf"CL_ACCUM:(.+$)" + ) + self._supported_identifiers_regex = re.compile( + rf"{INDY_DID}|{INDY_SCHEMA_ID}|{INDY_CRED_DEF_ID}|{INDY_REV_REG_DEF_ID}" + ) + + @property + def supported_identifiers_regex(self) -> Pattern: + return self._supported_identifiers_regex + + async def setup(self, context: InjectionContext): + """Setup.""" + print("Successfully registered LegacyIndyRegistry") + + @staticmethod + def make_schema_id(schema: AnonCredsSchema) -> str: + """Derive the ID for a schema.""" + return f"{schema.issuer_id}:2:{schema.name}:{schema.version}" + + @staticmethod + def make_cred_def_id( + schema: GetSchemaResult, + cred_def: CredDef, + ) -> str: + """Derive the ID for a credential definition.""" + signature_type = cred_def.type or DEFAULT_SIGNATURE_TYPE + tag = cred_def.tag or DEFAULT_CRED_DEF_TAG + + try: + seq_no = str(schema.schema_metadata["seqNo"]) + except KeyError as err: + raise AnonCredsRegistrationError( + "Legacy Indy only supports schemas from Legacy Indy" + ) from err + + return f"{cred_def.issuer_id}:3:{signature_type}:{seq_no}:{tag}" + + @staticmethod + def make_rev_reg_def_id(rev_reg_def: RevRegDef) -> str: + """Derive the ID for a revocation registry definition.""" + return ( + f"{rev_reg_def.issuer_id}:4:{rev_reg_def.cred_def_id}:" + f"{rev_reg_def.type}:{rev_reg_def.tag}" + ) + + async def get_schema(self, profile: Profile, schema_id: str) -> GetSchemaResult: + """Get a schema from the registry.""" + + multitenant_mgr = profile.inject_or(BaseMultitenantManager) + if multitenant_mgr: + ledger_exec_inst = IndyLedgerRequestsExecutor(profile) + else: + ledger_exec_inst = profile.inject(IndyLedgerRequestsExecutor) + ledger_id, ledger = await ledger_exec_inst.get_ledger_for_identifier( + schema_id, + txn_record_type=GET_SCHEMA, + ) + + if not ledger: + reason = "No ledger available" + if not profile.settings.get_value("wallet.type"): + reason += ": missing wallet-type?" + raise AnonCredsResolutionError(reason) + + async with ledger: + try: + schema = await ledger.get_schema(schema_id) + if schema is None: + raise AnonCredsObjectNotFound( + f"Credential definition not found: {schema_id}", + {"ledger_id": ledger_id}, + ) + + anonscreds_schema = AnonCredsSchema( + issuer_id=schema["id"].split(":")[0], + attr_names=schema["attrNames"], + name=schema["name"], + version=schema["ver"], + ) + result = GetSchemaResult( + schema=anonscreds_schema, + schema_id=schema["id"], + resolution_metadata={"ledger_id": ledger_id}, + schema_metadata={"seqNo": schema["seqNo"]}, + ) + except LedgerError as err: + raise AnonCredsResolutionError("Failed to retrieve schema") from err + + return result + + async def register_schema( + self, + profile: Profile, + schema: AnonCredsSchema, + options: Optional[dict] = None, + ) -> SchemaResult: + """Register a schema on the registry.""" + + schema_id = self.make_schema_id(schema) + + # Assume endorser role on the network, no option for 3rd-party endorser + ledger = profile.inject_or(BaseLedger) + if not ledger: + reason = "No ledger available" + if not profile.settings.get_value("wallet.type"): + # TODO is this warning necessary? + reason += ": missing wallet-type?" + raise AnonCredsRegistrationError(reason) + + # Translate schema into format expected by Indy + LOGGER.debug("Registering schema: %s", schema_id) + indy_schema = { + "ver": "1.0", + "id": schema_id, + "name": schema.name, + "version": schema.version, + "attrNames": schema.attr_names, + "seqNo": None, + } + LOGGER.debug("schema value: %s", indy_schema) + + async with ledger: + try: + seq_no = await shield(ledger.send_schema(schema_id, indy_schema)) + except LedgerObjectAlreadyExistsError as err: + indy_schema = err.obj + schema = AnonCredsSchema( + name=indy_schema["name"], + version=indy_schema["version"], + attr_names=indy_schema["attrNames"], + issuer_id=indy_schema["id"].split(":")[0], + ) + raise AnonCredsSchemaAlreadyExists(err.message, err.obj_id, schema) + except (AnonCredsIssuerError, LedgerError) as err: + raise AnonCredsRegistrationError("Failed to register schema") from err + + return SchemaResult( + job_id=None, + schema_state=SchemaState( + state=SchemaState.STATE_FINISHED, + schema_id=schema_id, + schema=schema, + ), + registration_metadata={}, + schema_metadata={"seqNo": seq_no}, + ) + + async def get_credential_definition( + self, profile: Profile, cred_def_id: str + ) -> GetCredDefResult: + """Get a credential definition from the registry.""" + + async with profile.session() as session: + multitenant_mgr = session.inject_or(BaseMultitenantManager) + if multitenant_mgr: + ledger_exec_inst = IndyLedgerRequestsExecutor(profile) + else: + ledger_exec_inst = session.inject(IndyLedgerRequestsExecutor) + + ledger_id, ledger = await ledger_exec_inst.get_ledger_for_identifier( + cred_def_id, + txn_record_type=GET_CRED_DEF, + ) + if not ledger: + reason = "No ledger available" + if not profile.settings.get_value("wallet.type"): + reason += ": missing wallet-type?" + raise AnonCredsResolutionError(reason) + + async with ledger: + cred_def = await ledger.get_credential_definition(cred_def_id) + + if cred_def is None: + raise AnonCredsObjectNotFound( + f"Credential definition not found: {cred_def_id}", + {"ledger_id": ledger_id}, + ) + + cred_def_value = CredDefValue.deserialize(cred_def["value"]) + anoncreds_credential_definition = CredDef( + issuer_id=cred_def["id"].split(":")[0], + schema_id=cred_def["schemaId"], + type=cred_def["type"], + tag=cred_def["tag"], + value=cred_def_value, + ) + anoncreds_registry_get_credential_definition = GetCredDefResult( + credential_definition=anoncreds_credential_definition, + credential_definition_id=cred_def["id"], + resolution_metadata={}, + credential_definition_metadata={}, + ) + return anoncreds_registry_get_credential_definition + + async def register_credential_definition( + self, + profile: Profile, + schema: GetSchemaResult, + credential_definition: CredDef, + options: Optional[dict] = None, + ) -> CredDefResult: + """Register a credential definition on the registry.""" + + cred_def_id = self.make_cred_def_id(schema, credential_definition) + + ledger = profile.inject_or(BaseLedger) + if not ledger: + reason = "No ledger available" + if not profile.settings.get_value("wallet.type"): + reason += ": missing wallet-type?" + raise AnonCredsRegistrationError(reason) + + # Check if in wallet but not on ledger + issuer = AnonCredsIssuer(profile) + if await issuer.credential_definition_in_wallet(cred_def_id): + try: + await self.get_credential_definition(profile, cred_def_id) + except AnonCredsObjectNotFound as err: + raise AnonCredsRegistrationError( + f"Credential definition with id {cred_def_id} already " + "exists in wallet but not on the ledger" + ) from err + + # Translate anoncreds object to indy object + LOGGER.debug("Registering credential definition: %s", cred_def_id) + indy_cred_def = { + "id": cred_def_id, + "schemaId": str(schema.schema_metadata["seqNo"]), + "tag": credential_definition.tag, + "type": credential_definition.type, + "value": credential_definition.value.serialize(), + "ver": "1.0", + } + LOGGER.debug("Cred def value: %s", indy_cred_def) + + try: + async with ledger: + seq_no = await shield( + ledger.send_credential_definition( + credential_definition.schema_id, + cred_def_id, + indy_cred_def, + write_ledger=True, + endorser_did=credential_definition.issuer_id, + ) + ) + except LedgerObjectAlreadyExistsError as err: + if await issuer.credential_definition_in_wallet(cred_def_id): + raise AnonCredsObjectAlreadyExists( + f"Credential definition with id {cred_def_id} " + "already exists in wallet and on ledger.", + cred_def_id, + ) from err + else: + raise AnonCredsObjectAlreadyExists( + f"Credential definition {cred_def_id} is on " + f"ledger but not in wallet {profile.name}", + cred_def_id, + ) from err + except (AnonCredsIssuerError, LedgerError) as err: + raise AnonCredsRegistrationError( + "Failed to register credential definition" + ) from err + + return CredDefResult( + job_id=None, + credential_definition_state=CredDefState( + state=CredDefState.STATE_FINISHED, + credential_definition_id=cred_def_id, + credential_definition=credential_definition, + ), + registration_metadata={}, + credential_definition_metadata={"seqNo": seq_no, **(options or {})}, + ) + + async def get_revocation_registry_definition( + self, profile: Profile, rev_reg_def_id: str + ) -> GetRevRegDefResult: + """Get a revocation registry definition from the registry.""" + async with profile.session() as session: + multitenant_mgr = session.inject_or(BaseMultitenantManager) + if multitenant_mgr: + ledger_exec_inst = IndyLedgerRequestsExecutor(profile) + else: + ledger_exec_inst = session.inject(IndyLedgerRequestsExecutor) + + ledger_id, ledger = await ledger_exec_inst.get_ledger_for_identifier( + rev_reg_def_id, + txn_record_type=GET_CRED_DEF, + ) + if not ledger: + reason = "No ledger available" + if not profile.settings.get_value("wallet.type"): + reason += ": missing wallet-type?" + raise AnonCredsResolutionError(reason) + + async with ledger: + rev_reg_def = await ledger.get_revoc_reg_def(rev_reg_def_id) + + if rev_reg_def is None: + raise AnonCredsObjectNotFound( + f"Revocation registry definition not found: {rev_reg_def_id}", + {"ledger_id": ledger_id}, + ) + + LOGGER.debug("Retrieved revocation registry definition: %s", rev_reg_def) + rev_reg_def_value = RevRegDefValue.deserialize(rev_reg_def["value"]) + anoncreds_rev_reg_def = RevRegDef( + issuer_id=rev_reg_def["id"].split(":")[0], + cred_def_id=rev_reg_def["credDefId"], + type=rev_reg_def["revocDefType"], + value=rev_reg_def_value, + tag=rev_reg_def["tag"], + ) + result = GetRevRegDefResult( + revocation_registry=anoncreds_rev_reg_def, + revocation_registry_id=rev_reg_def["id"], + resolution_metadata={}, + revocation_registry_metadata={}, + ) + + return result + + async def register_revocation_registry_definition( + self, + profile: Profile, + revocation_registry_definition: RevRegDef, + options: Optional[dict] = None, + ) -> RevRegDefResult: + """Register a revocation registry definition on the registry.""" + + rev_reg_def_id = self.make_rev_reg_def_id(revocation_registry_definition) + + try: + # Translate anoncreds object to indy object + indy_rev_reg_def = { + "ver": "1.0", + "id": rev_reg_def_id, + "revocDefType": revocation_registry_definition.type, + "credDefId": revocation_registry_definition.cred_def_id, + "tag": revocation_registry_definition.tag, + "value": { + "issuanceType": "ISSUANCE_BY_DEFAULT", + "maxCredNum": revocation_registry_definition.value.max_cred_num, + "publicKeys": revocation_registry_definition.value.public_keys, + "tailsHash": revocation_registry_definition.value.tails_hash, + "tailsLocation": revocation_registry_definition.value.tails_location, + }, + } + + ledger = profile.inject(BaseLedger) + async with ledger: + resp = await ledger.send_revoc_reg_def( + indy_rev_reg_def, + revocation_registry_definition.issuer_id, + ) + seq_no = resp["result"]["txnMetadata"]["seqNo"] + except LedgerError as err: + raise AnonCredsRegistrationError() from err + + return RevRegDefResult( + job_id=None, + revocation_registry_definition_state=RevRegDefState( + state=RevRegDefState.STATE_FINISHED, + revocation_registry_definition_id=rev_reg_def_id, + revocation_registry_definition=revocation_registry_definition, + ), + registration_metadata={}, + revocation_registry_definition_metadata={"seqNo": seq_no}, + ) + + async def _get_or_fetch_rev_reg_def_max_cred_num( + self, profile: Profile, ledger: BaseLedger, rev_reg_def_id: str + ) -> int: + """Retrieve max cred num for a rev reg def. + + The value is retrieved from cache or from the ledger if necessary. + The issuer could retrieve this value from the wallet but this info + must also be known to the holder. + """ + cache = profile.inject(BaseCache) + cache_key = f"anoncreds::legacy_indy::rev_reg_max_cred_num::{rev_reg_def_id}" + + if cache: + max_cred_num = await cache.get(cache_key) + if max_cred_num: + return max_cred_num + + rev_reg_def = await ledger.get_revoc_reg_def(rev_reg_def_id) + max_cred_num = rev_reg_def["value"]["maxCredNum"] + + if cache: + await cache.set(cache_key, max_cred_num) + + return max_cred_num + + def _indexes_to_bit_array(self, indexes: List[int], size: int) -> List[int]: + """Turn a sequence of indexes into a full state bit array.""" + return [1 if index in indexes else 0 for index in range(1, size + 1)] + + async def get_revocation_list( + self, profile: Profile, rev_reg_def_id: str, timestamp: int + ) -> GetRevListResult: + """Get a revocation list from the registry.""" + async with profile.session() as session: + multitenant_mgr = session.inject_or(BaseMultitenantManager) + if multitenant_mgr: + ledger_exec_inst = IndyLedgerRequestsExecutor(profile) + else: + ledger_exec_inst = session.inject(IndyLedgerRequestsExecutor) + + ledger_id, ledger = await ledger_exec_inst.get_ledger_for_identifier( + rev_reg_def_id, + txn_record_type=GET_CRED_DEF, + ) + if not ledger: + reason = "No ledger available" + if not profile.settings.get_value("wallet.type"): + reason += ": missing wallet-type?" + raise AnonCredsResolutionError(reason) + + async with ledger: + delta, timestamp = await ledger.get_revoc_reg_delta( + rev_reg_def_id, timestamp_to=timestamp + ) + + if delta is None: + raise AnonCredsObjectNotFound( + f"Revocation list not found for rev reg def: {rev_reg_def_id}", + {"ledger_id": ledger_id}, + ) + + LOGGER.debug("Retrieved delta: %s", delta) + max_cred_num = await self._get_or_fetch_rev_reg_def_max_cred_num( + profile, ledger, rev_reg_def_id + ) + revocation_list_from_indexes = self._indexes_to_bit_array( + delta["value"]["revoked"], max_cred_num + ) + LOGGER.debug( + "Index list to full state bit array: %s", revocation_list_from_indexes + ) + rev_list = RevList( + issuer_id=rev_reg_def_id.split(":")[0], + rev_reg_def_id=rev_reg_def_id, + revocation_list=revocation_list_from_indexes, + current_accumulator=delta["value"]["accum"], + timestamp=timestamp, + ) + result = GetRevListResult( + revocation_list=rev_list, + resolution_metadata={}, + revocation_registry_metadata={}, + ) + + return result + + async def _revoc_reg_entry_with_fix( + self, + profile: Profile, + rev_list: RevList, + rev_reg_def_type: str, + entry: dict, + ) -> dict: + """Send a revocation registry entry to the ledger with fixes if needed""" + # TODO Handle multitenancy and multi-ledger (like in get cred def) + ledger = profile.inject(BaseLedger) + + try: + async with ledger: + rev_entry_res = await ledger.send_revoc_reg_entry( + rev_list.rev_reg_def_id, + rev_reg_def_type, + entry, + rev_list.issuer_id, + write_ledger=True, + endorser_did=None, + ) + except LedgerTransactionError as err: + if "InvalidClientRequest" in err.roll_up: + # ... if the ledger write fails (with "InvalidClientRequest") + # e.g. aries_cloudagent.ledger.error.LedgerTransactionError: + # Ledger rejected transaction request: client request invalid: + # InvalidClientRequest(...) + # In this scenario we try to post a correction + LOGGER.warn("Retry ledger update/fix due to error") + LOGGER.warn(err) + (_, _, res) = await self.fix_ledger_entry( + profile, + rev_list, + True, + ledger.genesis_txns, + ) + rev_entry_res = {"result": res} + LOGGER.warn("Ledger update/fix applied") + elif "InvalidClientTaaAcceptanceError" in err.roll_up: + # if no write access (with "InvalidClientTaaAcceptanceError") + # e.g. aries_cloudagent.ledger.error.LedgerTransactionError: + # Ledger rejected transaction request: client request invalid: + # InvalidClientTaaAcceptanceError(...) + LOGGER.exception("Ledger update failed due to TAA issue") + raise AnonCredsRegistrationError( + "Ledger update failed due to TAA Issue" + ) from err + else: + # not sure what happened, raise an error + LOGGER.exception("Ledger update failed due to unknown issue") + raise AnonCredsRegistrationError( + "Ledger update failed due to unknown issue" + ) from err + + return rev_entry_res + + async def register_revocation_list( + self, + profile: Profile, + rev_reg_def: RevRegDef, + rev_list: RevList, + options: Optional[dict] = None, + ) -> RevListResult: + """Register a revocation list on the registry.""" + rev_reg_entry = {"ver": "1.0", "value": {"accum": rev_list.current_accumulator}} + + rev_entry_res = await self._revoc_reg_entry_with_fix( + profile, rev_list, rev_reg_def.type, rev_reg_entry + ) + + return RevListResult( + job_id=None, + revocation_list_state=RevListState( + state=RevListState.STATE_FINISHED, + revocation_list=rev_list, + ), + registration_metadata={}, + revocation_list_metadata={ + "seqNo": rev_entry_res["result"]["txnMetadata"]["seqNo"], + }, + ) + + async def update_revocation_list( + self, + profile: Profile, + rev_reg_def: RevRegDef, + prev_list: RevList, + curr_list: RevList, + revoked: Sequence[int], + options: Optional[dict] = None, + ) -> RevListResult: + """Update a revocation list.""" + newly_revoked_indices = [ + # Remember: Indices in Indy are 1-based + index # + 1 TODO This needs to be offset! Commented for testing + for index in revoked + ] + rev_reg_entry = { + "ver": "1.0", + "value": { + "accum": curr_list.current_accumulator, + "prevAccum": prev_list.current_accumulator, + "revoked": newly_revoked_indices, + }, + } + + rev_entry_res = await self._revoc_reg_entry_with_fix( + profile, curr_list, rev_reg_def.type, rev_reg_entry + ) + + return RevListResult( + job_id=None, + revocation_list_state=RevListState( + state=RevListState.STATE_FINISHED, + revocation_list=curr_list, + ), + registration_metadata={}, + revocation_list_metadata={ + "seqNo": rev_entry_res["result"]["txnMetadata"]["seqNo"], + }, + ) + + async def fix_ledger_entry( + self, + profile: Profile, + rev_list: RevList, + apply_ledger_update: bool, + genesis_transactions: str, + ) -> Tuple[dict, dict, dict]: + """Fix the ledger entry to match wallet-recorded credentials.""" + # get rev reg delta (revocations published to ledger) + ledger = profile.inject(BaseLedger) + async with ledger: + (rev_reg_delta, _) = await ledger.get_revoc_reg_delta( + rev_list.rev_reg_def_id + ) + + # get rev reg records from wallet (revocations and list) + recs = [] + rec_count = 0 + accum_count = 0 + recovery_txn = {} + applied_txn = {} + async with profile.session() as session: + recs = await IssuerCredRevRecord.query_by_ids( + session, rev_reg_id=rev_list.rev_reg_def_id + ) + + revoked_ids = [] + for rec in recs: + if rec.state == IssuerCredRevRecord.STATE_REVOKED: + revoked_ids.append(int(rec.cred_rev_id)) + if int(rec.cred_rev_id) not in rev_reg_delta["value"]["revoked"]: + # await rec.set_state(session, IssuerCredRevRecord.STATE_ISSUED) + rec_count += 1 + + LOGGER.debug(">>> fixed entry recs count = %s", rec_count) + LOGGER.debug( + ">>> rev_list.revocation_list: %s", + rev_list.revocation_list, + ) + LOGGER.debug( + '>>> rev_reg_delta.get("value"): %s', rev_reg_delta.get("value") + ) + + # if we had any revocation discrepencies, check the accumulator value + if rec_count > 0: + if (rev_list.current_accumulator and rev_reg_delta.get("value")) and ( + rev_list.current_accumulator != rev_reg_delta["value"]["accum"] + ): + # self.revoc_reg_entry = rev_reg_delta["value"] + # await self.save(session) + accum_count += 1 + + calculated_txn = await generate_ledger_rrrecovery_txn( + genesis_transactions, + rev_list.rev_reg_def_id, + revoked_ids, + ) + recovery_txn = json.loads(calculated_txn.to_json()) + + LOGGER.debug(">>> apply_ledger_update = %s", apply_ledger_update) + if apply_ledger_update: + ledger = session.inject_or(BaseLedger) + if not ledger: + reason = "No ledger available" + if not session.context.settings.get_value("wallet.type"): + reason += ": missing wallet-type?" + raise LedgerError(reason=reason) + + async with ledger: + ledger_response = await ledger.send_revoc_reg_entry( + rev_list.rev_reg_def_id, "CL_ACCUM", recovery_txn + ) + + applied_txn = ledger_response["result"] + + return (rev_reg_delta, recovery_txn, applied_txn) diff --git a/aries_cloudagent/anoncreds/default/legacy_indy/routes.py b/aries_cloudagent/anoncreds/default/legacy_indy/routes.py new file mode 100644 index 0000000000..759ee608c8 --- /dev/null +++ b/aries_cloudagent/anoncreds/default/legacy_indy/routes.py @@ -0,0 +1 @@ +"""Routes for Legacy Indy Registry""" diff --git a/aries_cloudagent/anoncreds/default/legacy_indy/tests/__init__.py b/aries_cloudagent/anoncreds/default/legacy_indy/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/anoncreds/default/legacy_indy/tests/test_registry.py b/aries_cloudagent/anoncreds/default/legacy_indy/tests/test_registry.py new file mode 100644 index 0000000000..a97f485a34 --- /dev/null +++ b/aries_cloudagent/anoncreds/default/legacy_indy/tests/test_registry.py @@ -0,0 +1,54 @@ +"""Test LegacyIndyRegistry.""" + +import pytest +import re +from ..registry import LegacyIndyRegistry +from base58 import alphabet + +B58 = alphabet if isinstance(alphabet, str) else alphabet.decode("ascii") +INDY_DID = rf"^(did:sov:)?[{B58}]{{21,22}}$" +INDY_SCHEMA_ID = rf"^[{B58}]{{21,22}}:2:.+:[0-9.]+$" +INDY_CRED_DEF_ID = ( + rf"^([{B58}]{{21,22}})" # issuer DID + f":3" # cred def id marker + f":CL" # sig alg + rf":(([1-9][0-9]*)|([{B58}]{{21,22}}:2:.+:[0-9.]+))" # schema txn / id + f":(.+)?$" # tag +) +INDY_REV_REG_DEF_ID = ( + rf"^([{B58}]{{21,22}}):4:" + rf"([{B58}]{{21,22}}):3:" + rf"CL:(([1-9][0-9]*)|([{B58}]{{21,22}}:2:.+:[0-9.]+))(:.+)?:" + rf"CL_ACCUM:(.+$)" +) +SUPPORTED_ID_REGEX = re.compile( + rf"{INDY_DID}|{INDY_SCHEMA_ID}|{INDY_CRED_DEF_ID}|{INDY_REV_REG_DEF_ID}" +) + +TEST_INDY_DID = "WgWxqztrNooG92RXvxSTWv" +TEST_INDY_DID_1 = "did:sov:WgWxqztrNooG92RXvxSTWv" +TEST_INDY_SCHEMA_ID = "WgWxqztrNooG92RXvxSTWv:2:schema_name:1.0" +TEST_INDY_CRED_DEF_ID = "WgWxqztrNooG92RXvxSTWv:3:CL:20:tag" +TEST_INDY_REV_REG_DEF_ID = ( + "WgWxqztrNooG92RXvxSTWv:4:WgWxqztrNooG92RXvxSTWv:3:CL:20:tag:CL_ACCUM:0" +) + + +@pytest.fixture +def registry(): + """Registry fixture""" + yield LegacyIndyRegistry() + + +class TestLegacyIndyRegistry: + @pytest.mark.asyncio + async def test_supported_did_regex(self, registry: LegacyIndyRegistry): + """Test the supported_did_regex.""" + + assert registry.supported_identifiers_regex == SUPPORTED_ID_REGEX + assert bool(registry.supported_identifiers_regex.match(TEST_INDY_DID)) + assert bool(registry.supported_identifiers_regex.match(TEST_INDY_DID_1)) + assert bool(registry.supported_identifiers_regex.match(TEST_INDY_SCHEMA_ID)) + assert bool( + registry.supported_identifiers_regex.match(TEST_INDY_REV_REG_DEF_ID) + ) diff --git a/aries_cloudagent/anoncreds/holder.py b/aries_cloudagent/anoncreds/holder.py new file mode 100644 index 0000000000..7c2abd32ee --- /dev/null +++ b/aries_cloudagent/anoncreds/holder.py @@ -0,0 +1,604 @@ +"""Indy holder implementation.""" + +import asyncio +import json +import logging +import re +from typing import Dict, Optional, Sequence, Tuple, Union +import uuid + +from anoncreds import ( + AnoncredsError, + Credential, + CredentialRequest, + CredentialRevocationState, + PresentCredentials, + Presentation, +) +from anoncreds.bindings import create_link_secret +from aries_askar import AskarError, AskarErrorCode + +from ..anoncreds.models.anoncreds_schema import AnonCredsSchema +from ..askar.profile import AskarProfile +from ..core.error import BaseError +from ..core.profile import Profile +from ..ledger.base import BaseLedger +from ..wallet.error import WalletNotFoundError +from .models.anoncreds_cred_def import CredDef + +LOGGER = logging.getLogger(__name__) + +CATEGORY_CREDENTIAL = "credential" +CATEGORY_MASTER_SECRET = "master_secret" + + +def _make_cred_info(cred_id, cred: Credential): + cred_info = cred.to_dict() # not secure! + rev_info = cred_info["signature"]["r_credential"] + return { + "referent": cred_id, + "schema_id": cred_info["schema_id"], + "cred_def_id": cred_info["cred_def_id"], + "rev_reg_id": cred_info["rev_reg_id"], + "cred_rev_id": str(rev_info["i"]) if rev_info else None, + "attrs": {name: val["raw"] for (name, val) in cred_info["values"].items()}, + } + + +def _normalize_attr_name(name: str) -> str: + return name.replace(" ", "") + + +class AnonCredsHolderError(BaseError): + """Base class for holder exceptions.""" + + +class AnonCredsHolder: + """AnonCreds holder class.""" + + MASTER_SECRET_ID = "default" + + def __init__(self, profile: Profile): + """ + Initialize an AnonCredsHolder instance. + + Args: + profile: The active profile instance + + """ + self._profile = profile + + @property + def profile(self) -> AskarProfile: + """Accessor for the profile instance.""" + if not isinstance(self._profile, AskarProfile): + raise ValueError("AnonCreds interface requires Askar") + + return self._profile + + async def get_master_secret(self) -> str: + """Get or create the default master secret.""" + + while True: + async with self.profile.session() as session: + try: + record = await session.handle.fetch( + CATEGORY_MASTER_SECRET, AnonCredsHolder.MASTER_SECRET_ID + ) + except AskarError as err: + raise AnonCredsHolderError("Error fetching master secret") from err + if record: + try: + # TODO should be able to use raw_value but memoryview + # isn't accepted by cred.process + secret = record.value.decode("ascii") + except AnoncredsError as err: + raise AnonCredsHolderError( + "Error loading master secret" + ) from err + break + else: + try: + secret = create_link_secret() + except AnoncredsError as err: + raise AnonCredsHolderError( + "Error creating master secret" + ) from err + try: + await session.handle.insert( + CATEGORY_MASTER_SECRET, + AnonCredsHolder.MASTER_SECRET_ID, + secret, + ) + except AskarError as err: + if err.code != AskarErrorCode.DUPLICATE: + raise AnonCredsHolderError( + "Error saving master secret" + ) from err + # else: lost race to create record, retry + else: + break + return secret + + async def create_credential_request( + self, credential_offer: dict, credential_definition: CredDef, holder_did: str + ) -> Tuple[str, str]: + """ + Create a credential request for the given credential offer. + + 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 tuple of the credential request and credential request metadata + + """ + try: + secret = await self.get_master_secret() + ( + cred_req, + cred_req_metadata, + ) = await asyncio.get_event_loop().run_in_executor( + None, + CredentialRequest.create, + None, + holder_did, + credential_definition.to_native(), + secret, + AnonCredsHolder.MASTER_SECRET_ID, + credential_offer, + ) + except AnoncredsError as err: + raise AnonCredsHolderError("Error creating credential request") from err + cred_req_json, cred_req_metadata_json = ( + cred_req.to_json(), + cred_req_metadata.to_json(), + ) + + LOGGER.debug( + "Created credential request. " + "credential_request_json=%s credential_request_metadata_json=%s", + cred_req_json, + cred_req_metadata_json, + ) + + return cred_req_json, cred_req_metadata_json + + async def store_credential( + self, + credential_definition: dict, + credential_data: dict, + credential_request_metadata: dict, + credential_attr_mime_types: dict = None, + credential_id: str = None, + rev_reg_def: dict = None, + ) -> str: + """ + 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: revocation registry definition in json + + Returns: + the ID of the stored credential + + """ + try: + secret = await self.get_master_secret() + cred = Credential.load(credential_data) + cred_recvd = await asyncio.get_event_loop().run_in_executor( + None, + cred.process, + credential_request_metadata, + secret, + credential_definition, + rev_reg_def, + ) + except AnoncredsError as err: + raise AnonCredsHolderError("Error processing received credential") from err + + schema_id = cred_recvd.schema_id + schema_id_parts = re.match(r"^(\w+):2:([^:]+):([^:]+)$", schema_id) + if not schema_id_parts: + raise AnonCredsHolderError( + f"Error parsing credential schema ID: {schema_id}" + ) + cred_def_id = cred_recvd.cred_def_id + cdef_id_parts = re.match(r"^(\w+):3:CL:([^:]+):([^:]+)$", cred_def_id) + if not cdef_id_parts: + raise AnonCredsHolderError( + f"Error parsing credential definition ID: {cred_def_id}" + ) + + credential_id = credential_id or str(uuid.uuid4()) + tags = { + "schema_id": schema_id, + "schema_issuer_did": schema_id_parts[1], + "schema_name": schema_id_parts[2], + "schema_version": schema_id_parts[3], + "issuer_did": cdef_id_parts[1], + "cred_def_id": cred_def_id, + "rev_reg_id": cred_recvd.rev_reg_id or "None", + } + + # FIXME - sdk has some special handling for fully qualified DIDs here + + mime_types = {} + for k, attr_value in credential_data["values"].items(): + attr_name = _normalize_attr_name(k) + # tags[f"attr::{attr_name}::marker"] = "1" + tags[f"attr::{attr_name}::value"] = attr_value["raw"] + if credential_attr_mime_types and k in credential_attr_mime_types: + mime_types[k] = credential_attr_mime_types[k] + + try: + async with self.profile.transaction() as txn: + await txn.handle.insert( + CATEGORY_CREDENTIAL, + credential_id, + cred_recvd.to_json_buffer(), + tags=tags, + ) + if mime_types: + await txn.handle.insert( + AnonCredsHolder.RECORD_TYPE_MIME_TYPES, + credential_id, + value_json=mime_types, + ) + await txn.commit() + except AskarError as err: + raise AnonCredsHolderError("Error storing credential") from err + + return credential_id + + async def get_credentials(self, start: int, count: int, wql: dict): + """ + Get credentials stored in the wallet. + + Args: + start: Starting index + count: Number of records to return + wql: wql query dict + + """ + + result = [] + + try: + rows = self.profile.store.scan( + CATEGORY_CREDENTIAL, + wql, + start, + count, + self.profile.settings.get("wallet.askar_profile"), + ) + async for row in rows: + cred = Credential.load(row.raw_value) + result.append(_make_cred_info(row.name, cred)) + except AskarError as err: + raise AnonCredsHolderError("Error retrieving credentials") from err + except AnoncredsError as err: + raise AnonCredsHolderError("Error loading stored credential") from err + + return result + + async def get_credentials_for_presentation_request_by_referent( + self, + presentation_request: dict, + referents: Sequence[str], + start: int, + count: int, + extra_query: Optional[dict] = None, + ): + """ + Get credentials stored in the wallet. + + Args: + presentation_request: Valid presentation request from issuer + referents: Presentation request referents to use to search for creds + start: Starting index + count: Maximum number of records to return + extra_query: wql query dict + + """ + + if not referents: + referents = ( + *presentation_request["requested_attributes"], + *presentation_request["requested_predicates"], + ) + + creds = {} + + for reft in referents: + names = set() + if reft in presentation_request["requested_attributes"]: + attr = presentation_request["requested_attributes"][reft] + if "name" in attr: + names.add(_normalize_attr_name(attr["name"])) + elif "names" in attr: + names.update(_normalize_attr_name(name) for name in attr["names"]) + # for name in names: + # tag_filter[f"attr::{_normalize_attr_name(name)}::marker"] = "1" + restr = attr.get("restrictions") + elif reft in presentation_request["requested_predicates"]: + pred = presentation_request["requested_predicates"][reft] + if "name" in pred: + names.add(_normalize_attr_name(pred["name"])) + # tag_filter[f"attr::{_normalize_attr_name(name)}::marker"] = "1" + restr = pred.get("restrictions") + else: + raise AnonCredsHolderError( + f"Unknown presentation request referent: {reft}" + ) + + tag_filter = {"$exist": list(f"attr::{name}::value" for name in names)} + if restr: + # FIXME check if restr is a list or dict? validate WQL format + tag_filter = {"$and": [tag_filter] + restr} + if extra_query: + tag_filter = {"$and": [tag_filter, extra_query]} + + rows = self.profile.store.scan( + CATEGORY_CREDENTIAL, + tag_filter, + start, + count, + self.profile.settings.get("wallet.askar_profile"), + ) + async for row in rows: + if row.name in creds: + creds[row.name]["presentation_referents"].add(reft) + else: + cred_info = _make_cred_info( + row.name, Credential.load(row.raw_value) + ) + creds[row.name] = { + "cred_info": cred_info, + "interval": presentation_request.get("non_revoked"), + "presentation_referents": {reft}, + } + + for cred in creds.values(): + cred["presentation_referents"] = list(cred["presentation_referents"]) + + return list(creds.values()) + + async def get_credential(self, credential_id: str) -> str: + """ + Get a credential stored in the wallet. + + Args: + credential_id: Credential id to retrieve + + """ + cred = await self._get_credential(credential_id) + return json.dumps(_make_cred_info(credential_id, cred)) + + async def _get_credential(self, credential_id: str) -> Credential: + """Get an unencoded Credential instance from the store.""" + try: + async with self.profile.session() as session: + cred = await session.handle.fetch(CATEGORY_CREDENTIAL, credential_id) + except AskarError as err: + raise AnonCredsHolderError("Error retrieving credential") from err + + if not cred: + raise WalletNotFoundError( + f"Credential {credential_id} not found in wallet {self.profile.name}" + ) + + try: + return Credential.load(cred.raw_value) + except AnoncredsError as err: + raise AnonCredsHolderError("Error loading requested credential") from err + + async def credential_revoked( + self, ledger: BaseLedger, credential_id: str, fro: int = None, to: int = None + ) -> bool: + """ + Check ledger for revocation status of credential by cred id. + + Args: + credential_id: Credential id to check + + """ + cred = await self._get_credential(credential_id) + rev_reg_id = cred.rev_reg_id + + # TODO Use anoncreds registry + if rev_reg_id: + cred_rev_id = cred.rev_reg_index + (rev_reg_delta, _) = await ledger.get_revoc_reg_delta( + rev_reg_id, + fro, + to, + ) + return cred_rev_id in rev_reg_delta["value"].get("revoked", []) + else: + return False + + async def delete_credential(self, credential_id: str): + """ + Remove a credential stored in the wallet. + + Args: + credential_id: Credential id to remove + + """ + try: + async with self.profile.session() as session: + await session.handle.remove(CATEGORY_CREDENTIAL, credential_id) + await session.handle.remove( + AnonCredsHolder.RECORD_TYPE_MIME_TYPES, credential_id + ) + except AskarError as err: + if err.code == AskarErrorCode.NOT_FOUND: + pass + else: + raise AnonCredsHolderError("Error deleting credential") from err + + 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) + + """ + try: + async with self.profile.session() as session: + mime_types_record = await session.handle.fetch( + AnonCredsHolder.RECORD_TYPE_MIME_TYPES, + credential_id, + ) + except AskarError as err: + raise AnonCredsHolderError( + "Error retrieving credential mime types" + ) from err + values = mime_types_record and mime_types_record.value_json + if values: + return values.get(attr) if attr else values + + async def create_presentation( + self, + presentation_request: dict, + requested_credentials: dict, + schemas: Dict[str, AnonCredsSchema], + credential_definitions: Dict[str, CredDef], + rev_states: dict = None, + ) -> str: + """ + 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 credential definitions JSON + rev_states: Indy format revocation states JSON + + """ + + creds: Dict[str, Credential] = {} + + def get_rev_state(cred_id: str, detail: dict): + cred = creds[cred_id] + rev_reg_id = cred.rev_reg_id + timestamp = detail.get("timestamp") if rev_reg_id else None + rev_state = None + if timestamp: + if not rev_states or rev_reg_id not in rev_states: + raise AnonCredsHolderError( + f"No revocation states provided for credential '{cred_id}' " + f"with rev_reg_id '{rev_reg_id}'" + ) + rev_state = rev_states[rev_reg_id].get(timestamp) + if not rev_state: + raise AnonCredsHolderError( + f"No revocation states provided for credential '{cred_id}' " + f"with rev_reg_id '{rev_reg_id}' at timestamp {timestamp}" + ) + return timestamp, rev_state + + self_attest = requested_credentials.get("self_attested_attributes") or {} + present_creds = PresentCredentials() + req_attrs = requested_credentials.get("requested_attributes") or {} + for reft, detail in req_attrs.items(): + cred_id = detail["cred_id"] + if cred_id not in creds: + # NOTE: could be optimized if multiple creds are requested + creds[cred_id] = await self._get_credential(cred_id) + timestamp, rev_state = get_rev_state(cred_id, detail) + present_creds.add_attributes( + creds[cred_id], + reft, + reveal=detail["revealed"], + timestamp=timestamp, + rev_state=rev_state, + ) + req_preds = requested_credentials.get("requested_predicates") or {} + for reft, detail in req_preds.items(): + cred_id = detail["cred_id"] + if cred_id not in creds: + # NOTE: could be optimized if multiple creds are requested + creds[cred_id] = await self._get_credential(cred_id) + timestamp, rev_state = get_rev_state(cred_id, detail) + present_creds.add_predicates( + creds[cred_id], + reft, + timestamp=timestamp, + rev_state=rev_state, + ) + + try: + secret = await self.get_master_secret() + presentation = await asyncio.get_event_loop().run_in_executor( + None, + Presentation.create, + presentation_request, + present_creds, + self_attest, + secret, + { + schema_id: schema.to_native() + for schema_id, schema in schemas.items() + }, + { + cred_def_id: cred_def.to_native() + for cred_def_id, cred_def in credential_definitions.items() + }, + ) + except AnoncredsError as err: + raise AnonCredsHolderError("Error creating presentation") from err + + return presentation.to_json() + + async def create_revocation_state( + self, + cred_rev_id: str, + rev_reg_def: dict, + rev_list: dict, + tails_file_path: str, + ) -> str: + """ + Create current revocation state for a received credential. + + Args: + cred_rev_id: credential revocation id in revocation registry + rev_reg_def: revocation registry definition + rev_reg_delta: revocation delta + timestamp: delta timestamp + + Returns: + the revocation state + + """ + + try: + rev_state = await asyncio.get_event_loop().run_in_executor( + None, + CredentialRevocationState.create, + rev_reg_def, + rev_list, + int(cred_rev_id), + tails_file_path, + ) + except AnoncredsError as err: + raise AnonCredsHolderError("Error creating revocation state") from err + return rev_state.to_json() diff --git a/aries_cloudagent/anoncreds/issuer.py b/aries_cloudagent/anoncreds/issuer.py new file mode 100644 index 0000000000..7f2004fd75 --- /dev/null +++ b/aries_cloudagent/anoncreds/issuer.py @@ -0,0 +1,559 @@ +"""anoncreds-rs issuer implementation.""" + +import asyncio +import logging +from time import time +from typing import Optional, Sequence + +from anoncreds import ( + AnoncredsError, + Credential, + CredentialDefinition, + CredentialOffer, + Schema, +) +from aries_askar import AskarError + +from ..askar.profile import AskarProfile, AskarProfileSession +from ..core.error import BaseError +from ..core.profile import Profile +from .base import AnonCredsSchemaAlreadyExists +from .models.anoncreds_cred_def import CredDef, CredDefResult +from .models.anoncreds_schema import AnonCredsSchema, SchemaResult, SchemaState +from .registry import AnonCredsRegistry + +LOGGER = logging.getLogger(__name__) + +DEFAULT_CRED_DEF_TAG = "default" +DEFAULT_SIGNATURE_TYPE = "CL" +CATEGORY_SCHEMA = "schema" +CATEGORY_CRED_DEF = "credential_def" +CATEGORY_CRED_DEF_PRIVATE = "credential_def_private" +CATEGORY_CRED_DEF_KEY_PROOF = "credential_def_key_proof" +STATE_FINISHED = "finished" + +EVENT_PREFIX = "acapy::anoncreds::" +EVENT_SCHEMA = EVENT_PREFIX + CATEGORY_SCHEMA +EVENT_CRED_DEF = EVENT_PREFIX + CATEGORY_CRED_DEF +EVENT_FINISHED_SUFFIX = "::" + STATE_FINISHED + + +class AnonCredsIssuerError(BaseError): + """Generic issuer error.""" + + +class AnonCredsIssuer: + """AnonCreds issuer class. + + This class provides methods for creating and registering AnonCreds objects + needed to issue credentials. It also provides methods for storing and + retrieving local representations of these objects from the wallet. + + A general pattern is followed when creating and registering objects: + + 1. Create the object locally + 2. Register the object with the anoncreds registry + 3. Store the object in the wallet + + The wallet storage is used to keep track of the state of the object. + + If the object is fully registered immediately after sending to the registry + (state of `finished`), the object is saved to the wallet with an id + matching the id returned from the registry. + + If the object is not fully registered but pending (state of `wait`), the + object is saved to the wallet with an id matching the job id returned from + the registry. + + If the object fails to register (state of `failed`), the object is saved to + the wallet with an id matching the job id returned from the registry. + + When an object finishes registration after being in a pending state (moving + from state `wait` to state `finished`), the wallet entry matching the job id + is removed and an entry matching the registered id is added. + """ + + def __init__(self, profile: Profile): + """ + Initialize an AnonCredsIssuer instance. + + Args: + profile: The active profile instance + + """ + self._profile = profile + + @property + def profile(self) -> AskarProfile: + """Accessor for the profile instance.""" + if not isinstance(self._profile, AskarProfile): + raise ValueError("AnonCreds interface requires Askar") + + return self._profile + + async def _finish_registration( + self, txn: AskarProfileSession, category: str, job_id: str, registered_id: str + ): + entry = await txn.handle.fetch( + category, + job_id, + for_update=True, + ) + if not entry: + raise AnonCredsIssuerError( + f"{category} with job id {job_id} could not be found" + ) + + tags = entry.tags + tags["state"] = STATE_FINISHED + await txn.handle.insert( + category, + registered_id, + value=entry.value, + tags=tags, + ) + await txn.handle.remove(category, job_id) + + async def _store_schema( + self, + result: SchemaResult, + ): + """Store schema after reaching finished state.""" + ident = result.schema_state.schema_id or result.job_id + if not ident: + raise ValueError("Schema id or job id must be set") + + try: + async with self.profile.session() as session: + await session.handle.insert( + CATEGORY_SCHEMA, + ident, + result.schema_state.schema.to_json(), + { + "name": result.schema_state.schema.name, + "version": result.schema_state.schema.version, + "issuer_id": result.schema_state.schema.issuer_id, + "state": result.schema_state.state, + }, + ) + except AskarError as err: + raise AnonCredsIssuerError("Error storing schema") from err + + async def create_and_register_schema( + self, + issuer_id: str, + name: str, + version: str, + attr_names: Sequence[str], + options: Optional[dict] = None, + ) -> SchemaResult: + """ + Create a new credential schema and store it in the wallet. + + Args: + issuer_id: the DID issuing the credential definition + name: the schema name + version: the schema version + attr_names: a sequence of schema attribute names + + Returns: + A SchemaResult instance + + """ + # Check if record of a similar schema already exists in our records + async with self.profile.session() as session: + # TODO scan? + schemas = await session.handle.fetch_all( + CATEGORY_SCHEMA, + { + "name": name, + "version": version, + "issuer_id": issuer_id, + }, + limit=1, + ) + if schemas: + raise AnonCredsSchemaAlreadyExists( + f"Schema with {name}: {version} " f"already exists for {issuer_id}", + schemas[0].name, + AnonCredsSchema.deserialize(schemas[0].value_json), + ) + + schema = Schema.create(name, version, issuer_id, attr_names) + try: + anoncreds_registry = self.profile.inject(AnonCredsRegistry) + schema_result = await anoncreds_registry.register_schema( + self.profile, + AnonCredsSchema.from_native(schema), + options, + ) + + await self._store_schema(schema_result) + + return schema_result + + except AnonCredsSchemaAlreadyExists as err: + # If we find that we've previously written a schema that looks like + # this one before but that schema is not in our wallet, add it to + # the wallet so we can return from our get schema calls + await self._store_schema( + SchemaResult( + job_id=None, + schema_state=SchemaState( + state=SchemaState.STATE_FINISHED, + schema_id=err.schema_id, + schema=err.schema, + ), + ) + ) + raise AnonCredsIssuerError( + "Schema already exists but was not in wallet; stored in wallet" + ) from err + except AnoncredsError as err: + raise AnonCredsIssuerError("Error creating schema") from err + + async def finish_schema(self, job_id: str, schema_id: str): + """Mark a schema as finished.""" + async with self.profile.transaction() as txn: + await self._finish_registration(txn, CATEGORY_SCHEMA, job_id, schema_id) + await txn.commit() + + async def get_created_schemas( + self, + name: Optional[str] = None, + version: Optional[str] = None, + issuer_id: Optional[str] = None, + ) -> Sequence[str]: + """Retrieve IDs of schemas previously created.""" + async with self.profile.session() as session: + # TODO limit? scan? + schemas = await session.handle.fetch_all( + CATEGORY_SCHEMA, + { + key: value + for key, value in { + "name": name, + "version": version, + "issuer_id": issuer_id, + "state": STATE_FINISHED, + }.items() + if value is not None + }, + ) + # entry.name was stored as the schema's ID + return [entry.name for entry in schemas] + + async def credential_definition_in_wallet( + self, credential_definition_id: str + ) -> bool: + """ + Check whether a given credential definition ID is present in the wallet. + + Args: + credential_definition_id: The credential definition ID to check + """ + try: + async with self.profile.session() as session: + return ( + await session.handle.fetch( + CATEGORY_CRED_DEF_PRIVATE, credential_definition_id + ) + ) is not None + except AskarError as err: + raise AnonCredsIssuerError( + "Error checking for credential definition" + ) from err + + async def create_and_register_credential_definition( + self, + issuer_id: str, + schema_id: str, + tag: Optional[str] = None, + signature_type: Optional[str] = None, + options: Optional[dict] = None, + ) -> CredDefResult: + """ + Create a new credential definition and store it in the wallet. + + Args: + issuer_id: the ID of the issuer creating the credential definition + schema_id: the schema ID for the credential definition + tag: the tag to use for the credential definition + signature_type: the signature type to use for the credential definition + options: any additional options to use when creating the credential definition + + Returns: + CredDefResult: the result of the credential definition creation + + """ + anoncreds_registry = self.profile.inject(AnonCredsRegistry) + schema_result = await anoncreds_registry.get_schema(self.profile, schema_id) + + options = options or {} + support_revocation = options.get("support_revocation", False) + + try: + # Create the cred def + ( + cred_def, + cred_def_private, + key_proof, + ) = await asyncio.get_event_loop().run_in_executor( + None, + lambda: CredentialDefinition.create( + schema_id, + schema_result.schema.serialize(), + issuer_id, + tag or DEFAULT_CRED_DEF_TAG, + signature_type or DEFAULT_SIGNATURE_TYPE, + support_revocation=support_revocation, + ), + ) + cred_def_json = cred_def.to_json() + + # Register the cred def + result = await anoncreds_registry.register_credential_definition( + self.profile, + schema_result, + CredDef.from_native(cred_def), + options, + ) + except AnoncredsError as err: + raise AnonCredsIssuerError("Error creating credential definition") from err + + # Store the cred def and it's components + ident = ( + result.credential_definition_state.credential_definition_id or result.job_id + ) + if not ident: + raise AnonCredsIssuerError("cred def id or job id required") + + try: + async with self.profile.transaction() as txn: + await txn.handle.insert( + CATEGORY_CRED_DEF, + ident, + cred_def_json, + tags={ + "schema_id": schema_id, + "schema_issuer_id": schema_result.schema.issuer_id, + "issuer_id": issuer_id, + "schema_name": schema_result.schema.name, + "schema_version": schema_result.schema.version, + "state": result.credential_definition_state.state, + "epoch": str(int(time())), + }, + ) + await txn.handle.insert( + CATEGORY_CRED_DEF_PRIVATE, + ident, + cred_def_private.to_json_buffer(), + ) + await txn.handle.insert( + CATEGORY_CRED_DEF_KEY_PROOF, ident, key_proof.to_json_buffer() + ) + await txn.commit() + except AskarError as err: + raise AnonCredsIssuerError("Error storing credential definition") from err + + return result + + 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) + await self._finish_registration( + txn, CATEGORY_CRED_DEF_PRIVATE, job_id, cred_def_id + ) + await self._finish_registration( + txn, CATEGORY_CRED_DEF_KEY_PROOF, job_id, cred_def_id + ) + await txn.commit() + + async def get_created_credential_definitions( + self, + issuer_id: Optional[str] = None, + schema_issuer_id: Optional[str] = None, + schema_id: Optional[str] = None, + schema_name: Optional[str] = None, + schema_version: Optional[str] = None, + epoch: Optional[str] = None, + ) -> Sequence[str]: + """Retrieve IDs of credential definitions previously created.""" + async with self.profile.session() as session: + # TODO limit? scan? + credential_definition_entries = await session.handle.fetch_all( + CATEGORY_CRED_DEF, + { + key: value + for key, value in { + "issuer_id": issuer_id, + "schema_issuer_id": schema_issuer_id, + "schema_id": schema_id, + "schema_name": schema_name, + "schema_version": schema_version, + "epoch": epoch, + "state": STATE_FINISHED, + }.items() + if value is not None + }, + ) + # entry.name is cred def id when state == finished + return [entry.name for entry in credential_definition_entries] + + async def match_created_credential_definitions( + self, + cred_def_id: Optional[str] = None, + issuer_id: Optional[str] = None, + schema_issuer_id: Optional[str] = None, + schema_id: Optional[str] = None, + schema_name: Optional[str] = None, + schema_version: Optional[str] = None, + epoch: Optional[str] = None, + ) -> Optional[str]: + """Return cred def id of most recent matching cred def.""" + async with self.profile.session() as session: + # TODO limit? scan? + if cred_def_id: + cred_def_entry = await session.handle.fetch( + CATEGORY_CRED_DEF, cred_def_id + ) + else: + credential_definition_entries = await session.handle.fetch_all( + CATEGORY_CRED_DEF, + { + key: value + for key, value in { + "issuer_id": issuer_id, + "schema_issuer_id": schema_issuer_id, + "schema_id": schema_id, + "schema_name": schema_name, + "schema_version": schema_version, + "state": STATE_FINISHED, + "epoch": epoch, + }.items() + if value is not None + }, + ) + cred_def_entry = max( + [entry for entry in credential_definition_entries], + key=lambda r: int(r.tags["epoch"]), + ) + + if cred_def_entry: + return cred_def_entry.name + + return None + + async def cred_def_supports_revocation(self, cred_def_id: str) -> bool: + """Return whether a credential definition supports revocation.""" + anoncreds_registry = self.profile.inject(AnonCredsRegistry) + cred_def_result = await anoncreds_registry.get_credential_definition( + self.profile, cred_def_id + ) + return cred_def_result.credential_definition.value.revocation is not None + + async def create_credential_offer(self, credential_definition_id: str) -> str: + """ + Create a credential offer for the given credential definition id. + + Args: + credential_definition_id: The credential definition to create an offer for + + Returns: + The new credential offer + + """ + try: + async with self.profile.session() as session: + cred_def = await session.handle.fetch( + CATEGORY_CRED_DEF, credential_definition_id + ) + key_proof = await session.handle.fetch( + CATEGORY_CRED_DEF_KEY_PROOF, credential_definition_id + ) + except AskarError as err: + raise AnonCredsIssuerError( + "Error retrieving credential definition" + ) from err + if not cred_def or not key_proof: + raise AnonCredsIssuerError( + "Credential definition not found for credential offer" + ) + try: + # The tag holds the full name of the schema, + # as opposed to just the sequence number + schema_id = cred_def.tags.get("schema_id") + cred_def = CredentialDefinition.load(cred_def.raw_value) + + credential_offer = CredentialOffer.create( + schema_id or cred_def.schema_id, + credential_definition_id, + key_proof.raw_value, + ) + except AnoncredsError as err: + raise AnonCredsIssuerError("Error creating credential offer") from err + + return credential_offer.to_json() + + async def create_credential( + self, + credential_offer: dict, + credential_request: dict, + credential_values: dict, + ) -> str: + anoncreds_registry = self.profile.inject(AnonCredsRegistry) + schema_id = credential_offer["schema_id"] + schema_result = await anoncreds_registry.get_schema(self.profile, schema_id) + cred_def_id = credential_offer["cred_def_id"] + schema_attributes = schema_result.schema_value.attr_names + + try: + async with self.profile.session() as session: + cred_def = await session.handle.fetch(CATEGORY_CRED_DEF, cred_def_id) + cred_def_private = await session.handle.fetch( + CATEGORY_CRED_DEF_PRIVATE, cred_def_id + ) + except AskarError as err: + raise AnonCredsIssuerError( + "Error retrieving credential definition" + ) from err + + if not cred_def or not cred_def_private: + raise AnonCredsIssuerError( + "Credential definition not found for credential issuance" + ) + + raw_values = {} + for attribute in schema_attributes: + # Ensure every attribute present in schema to be set. + # Extraneous attribute names are ignored. + try: + credential_value = credential_values[attribute] + except KeyError: + raise AnonCredsIssuerError( + "Provided credential values are missing a value " + f"for the schema attribute '{attribute}'" + ) + + raw_values[attribute] = str(credential_value) + + try: + credential = await asyncio.get_event_loop().run_in_executor( + None, + lambda: Credential.create( + cred_def.raw_value, + cred_def_private.raw_value, + credential_offer, + credential_request, + raw_values, + None, + None, + None, + None, + ), + ) + except AnoncredsError as err: + raise AnonCredsIssuerError("Error creating credential") from err + + return credential.to_json() diff --git a/aries_cloudagent/anoncreds/models/__init__.py b/aries_cloudagent/anoncreds/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/anoncreds/models/anoncreds_cred_def.py b/aries_cloudagent/anoncreds/models/anoncreds_cred_def.py new file mode 100644 index 0000000000..1a5c729225 --- /dev/null +++ b/aries_cloudagent/anoncreds/models/anoncreds_cred_def.py @@ -0,0 +1,323 @@ +"""Anoncreds cred def OpenAPI validators""" +from typing import Optional +from typing_extensions import Literal + +from anoncreds import CredentialDefinition +from marshmallow import EXCLUDE, fields +from marshmallow.validate import OneOf + +from ...messaging.models.base import BaseModel, BaseModelSchema +from ...messaging.valid import NUM_STR_WHOLE + + +class CredDefValuePrimary(BaseModel): + """PrimarySchema""" + + class Meta: + """PrimarySchema metadata.""" + + schema_class = "CredDefValuePrimarySchema" + + def __init__(self, n: str, s: str, r: dict, rctxt: str, z: str, **kwargs): + super().__init__(**kwargs) + self.n = n + self.s = s + self.r = r + self.rctxt = rctxt + self.z = z + + +class CredDefValuePrimarySchema(BaseModelSchema): + """Cred def value primary schema.""" + + class Meta: + """CredDefValuePrimarySchema metadata.""" + + model_class = CredDefValuePrimary + unknown = EXCLUDE + + n = fields.Str(**NUM_STR_WHOLE) + s = fields.Str(**NUM_STR_WHOLE) + r = fields.Dict() + rctxt = fields.Str(**NUM_STR_WHOLE) + z = fields.Str(**NUM_STR_WHOLE) + + +class CredDefValueRevocation(BaseModel): + """Cred def value revocation.""" + + class Meta: + """CredDefValueRevocation metadata.""" + + schema_class = "CredDefValueRevocationSchema" + + def __init__( + self, + g: str, + g_dash: str, + h: str, + h0: str, + h1: str, + h2: str, + htilde: str, + h_cap: str, + u: str, + pk: str, + y: str, + ): + self.g = g + self.g_dash = g_dash + self.h = h + self.h0 = h0 + self.h1 = h1 + self.h2 = h2 + self.htilde = htilde + self.h_cap = h_cap + self.u = u + self.pk = pk + self.y = y + + +class CredDefValueRevocationSchema(BaseModelSchema): + """Cred def value revocation schema.""" + + class Meta: + model_class = CredDefValueRevocation + unknown = EXCLUDE + + g = fields.Str(example="1 1F14F&ECB578F 2 095E45DDF417D") + g_dash = fields.Str(example="1 1D64716fCDC00C 1 0C781960FA66E3D3 2 095E45DDF417D") + h = fields.Str(example="1 16675DAE54BFAE8 2 095E45DD417D") + h0 = fields.Str(example="1 21E5EF9476EAF18 2 095E45DDF417D") + h1 = fields.Str(example="1 236D1D99236090 2 095E45DDF417D") + h2 = fields.Str(example="1 1C3AE8D1F1E277 2 095E45DDF417D") + htilde = fields.Str(example="1 1D8549E8C0F8 2 095E45DDF417D") + h_cap = fields.Str(example="1 1B2A32CF3167 1 2490FEBF6EE55 1 0000000000000000") + u = fields.Str(example="1 0C430AAB2B4710 1 1CB3A0932EE7E 1 0000000000000000") + pk = fields.Str(example="1 142CD5E5A7DC 1 153885BD903312 2 095E45DDF417D") + y = fields.Str(example="1 153558BD903312 2 095E45DDF417D 1 0000000000000000") + + +class CredDefValue(BaseModel): + """Cred def value.""" + + class Meta: + """CredDefValue metadata.""" + + schema_class = "CredDefValueSchema" + + def __init__( + self, + primary: CredDefValuePrimary, + revocation: Optional[CredDefValueRevocation] = None, + **kwargs, + ): + super().__init__(**kwargs) + self.primary = primary + self.revocation = revocation + + +class CredDefValueSchema(BaseModelSchema): + """Cred def value schema.""" + + class Meta: + """CredDefValueSchema metadata.""" + + model_class = CredDefValue + unknown = EXCLUDE + + primary = fields.Nested( + CredDefValuePrimarySchema(), + description="Primary value for credential definition", + ) + revocation = fields.Nested( + CredDefValueRevocationSchema(), + description="Revocation value for credential definition", + required=False, + ) + + +class CredDef(BaseModel): + """AnonCredsCredDef""" + + class Meta: + """AnonCredsCredDef metadata.""" + + schema_class = "CredDefSchema" + + def __init__( + self, + issuer_id: str, + schema_id: str, + type: Literal["CL"], + tag: str, + value: CredDefValue, + **kwargs, + ): + super().__init__(**kwargs) + self.issuer_id = issuer_id + self.schema_id = schema_id + self.type = type + self.tag = tag + self.value = value + + @classmethod + def from_native(cls, cred_def: CredentialDefinition): + """Convert a native credential definition to a CredDef object.""" + return cls.deserialize(cred_def.to_json()) + + def to_native(self): + """Convert to native anoncreds credential definition.""" + return CredentialDefinition.load(self.serialize()) + + +class CredDefSchema(BaseModelSchema): + """CredDefSchema.""" + + class Meta: + """CredDefSchema metadata.""" + + model_class = CredDef + unknown = EXCLUDE + + issuer_id = fields.Str( + description="Issuer Identifier of the credential definition or schema", + data_key="issuerId", + ) + schema_id = fields.Str(data_key="schemaId", description="Schema identifier") + type = fields.Str(validate=OneOf(["CL"])) + tag = fields.Str( + description="""The tag value passed in by the Issuer to + an AnonCred's Credential Definition create and store implementation.""" + ) + value = fields.Nested(CredDefValueSchema()) + + +class CredDefState(BaseModel): + """CredDefState.""" + + STATE_FINISHED = "finished" + STATE_FAILED = "failed" + STATE_ACTION = "action" + STATE_WAIT = "wait" + + class Meta: + """CredDefState metadata.""" + + schema_class = "CredDefStateSchema" + + def __init__( + self, + state: str, + credential_definition_id: Optional[str], + credential_definition: CredDef, + ): + self.state = state + self.credential_definition_id = credential_definition_id + self.credential_definition = credential_definition + + +class CredDefStateSchema(BaseModelSchema): + """CredDefStateSchema.""" + + class Meta: + """CredDefStateSchema metadata.""" + + model_class = CredDefState + unknown = EXCLUDE + + state = fields.Str( + validate=OneOf( + [ + CredDefState.STATE_FINISHED, + CredDefState.STATE_FAILED, + CredDefState.STATE_ACTION, + CredDefState.STATE_WAIT, + ] + ) + ) + credential_definition_id = fields.Str( + description="credential definition id", allow_none=True + ) + credential_definition = fields.Nested( + CredDefSchema(), description="credential definition" + ) + + +class CredDefResult(BaseModel): + """Cred def result.""" + + class Meta: + """CredDefResult metadata.""" + + schema_class = "CredDefResultSchema" + + def __init__( + self, + job_id: Optional[str], + credential_definition_state: CredDefState, + registration_metadata: dict, + credential_definition_metadata: dict, + **kwargs, + ): + super().__init__(**kwargs) + self.job_id = job_id + self.credential_definition_state = credential_definition_state + self.registration_metadata = registration_metadata + self.credential_definition_metadata = credential_definition_metadata + + +class CredDefResultSchema(BaseModelSchema): + """Cred def result schema.""" + + class Meta: + """CredDefResultSchema metadata.""" + + model_class = CredDefResult + unknown = EXCLUDE + + job_id = fields.Str() + credential_definition_state = fields.Nested(CredDefStateSchema()) + registration_metadata = fields.Dict() + # For indy, credential_definition_metadata will contain the seqNo + credential_definition_metadata = fields.Dict() + + +class GetCredDefResult(BaseModel): + """Get cred def result.""" + + class Meta: + """AnonCredsRegistryGetCredDef metadata.""" + + schema_class = "GetCredDefResultSchema" + + def __init__( + self, + credential_definition_id: str, + credential_definition: CredDef, + resolution_metadata: dict, + credential_definition_metadata: dict, + **kwargs, + ): + super().__init__(**kwargs) + self.credential_definition_id = credential_definition_id + self.credential_definition = credential_definition + self.resolution_metadata = resolution_metadata + self.credential_definition_metadata = credential_definition_metadata + + +class GetCredDefResultSchema(BaseModelSchema): + """GetCredDefResultSchema.""" + + class Meta: + """GetCredDefResultSchema metadata.""" + + model_class = GetCredDefResult + unknown = EXCLUDE + + credential_definition_id = fields.Str(description="credential definition id") + credential_definition = fields.Nested( + CredDefSchema(), description="credential definition" + ) + resolution_metadata = fields.Dict() + credential_definitions_metadata = fields.Dict() diff --git a/aries_cloudagent/anoncreds/models/anoncreds_revocation.py b/aries_cloudagent/anoncreds/models/anoncreds_revocation.py new file mode 100644 index 0000000000..e3b6b628c5 --- /dev/null +++ b/aries_cloudagent/anoncreds/models/anoncreds_revocation.py @@ -0,0 +1,426 @@ +"""Anoncreds cred def OpenAPI validators""" +from typing import Any, Dict, List, Optional +from typing_extensions import Literal + +from anoncreds import RevocationRegistryDefinition, RevocationStatusList +from marshmallow import EXCLUDE, fields +from marshmallow.validate import OneOf + +from ...messaging.models.base import BaseModel, BaseModelSchema + + +class RevRegDefValue(BaseModel): + """RevRegDefValue model.""" + + class Meta: + """RevRegDefValue metadata.""" + + schema_class = "RevRegDefValueSchema" + + def __init__( + self, + public_keys: dict, + max_cred_num: int, + tails_location: str, + tails_hash: str, + **kwargs, + ): + super().__init__(**kwargs) + self.public_keys = public_keys + self.max_cred_num = max_cred_num + self.tails_location = tails_location + self.tails_hash = tails_hash + + +class RevRegDefValueSchema(BaseModelSchema): + """RevRegDefValue schema.""" + + class Meta: + """RevRegDefValueSchema metadata.""" + + model_class = RevRegDefValue + unknown = EXCLUDE + + public_keys = fields.Dict(data_key="publicKeys") + max_cred_num = fields.Int(data_key="maxCredNum") + tails_location = fields.Str(data_key="tailsLocation") + tails_hash = fields.Str(data_key="tailsHash") + + +class RevRegDef(BaseModel): + """RevRegDef""" + + class Meta: + """RevRegDef metadata.""" + + schema_class = "RevRegDefSchema" + + def __init__( + self, + issuer_id: str, + type: Literal["CL_ACCUM"], + cred_def_id: str, + tag: str, + value: RevRegDefValue, + **kwargs, + ): + super().__init__(**kwargs) + self.issuer_id = issuer_id + self.type = type + self.cred_def_id = cred_def_id + self.tag = tag + self.value = value + + @classmethod + def from_native(cls, rev_reg_def: RevocationRegistryDefinition): + """Convert a native revocation registry definition to a RevRegDef object.""" + return cls.deserialize(rev_reg_def.to_json()) + + def to_native(self): + """Convert to native anoncreds revocation registry definition.""" + return RevocationRegistryDefinition.load(self.serialize()) + + +class RevRegDefSchema(BaseModelSchema): + """RevRegDefSchema.""" + + class Meta: + """RevRegDefSchema metadata.""" + + model_class = RevRegDef + unknown = EXCLUDE + + issuer_id = fields.Str( + description="Issuer Identifier of the credential definition or schema", + data_key="issuerId", + ) + type = fields.Str(data_key="revocDefType") + cred_def_id = fields.Str( + description="Credential definition identifier", + data_key="credDefId", + ) + tag = fields.Str(description="tag for the revocation registry definition") + value = fields.Nested(RevRegDefValueSchema()) + + +class RevRegDefState(BaseModel): + """RevRegDefState.""" + + STATE_FINISHED = "finished" + STATE_FAILED = "failed" + STATE_ACTION = "action" + STATE_WAIT = "wait" + + class Meta: + """RevRegDefState metadata.""" + + schema_class = "RevRegDefStateSchema" + + def __init__( + self, + state: str, + revocation_registry_definition_id: str, + revocation_registry_definition: RevRegDef, + ): + self.state = state + self.revocation_registry_definition_id = revocation_registry_definition_id + self.revocation_registry_definition = revocation_registry_definition + + +class RevRegDefStateSchema(BaseModelSchema): + """RevRegDefStateSchema.""" + + class Meta: + """RevRegDefStateSchema metadata.""" + + model_class = RevRegDefState + unknown = EXCLUDE + + state = fields.Str( + validate=OneOf( + [ + RevRegDefState.STATE_FINISHED, + RevRegDefState.STATE_FAILED, + RevRegDefState.STATE_ACTION, + RevRegDefState.STATE_WAIT, + ] + ) + ) + revocation_registry_definition_id = fields.Str( + description="revocation registry definition id" + ) + revocation_registry_definition = fields.Nested( + RevRegDefSchema(), description="revocation registry definition" + ) + + +class RevRegDefResult(BaseModel): + """Cred def result.""" + + class Meta: + """RevRegDefResult metadata.""" + + schema_class = "RevRegDefResultSchema" + + def __init__( + self, + job_id: Optional[str], + revocation_registry_definition_state: RevRegDefState, + registration_metadata: dict, + revocation_registry_definition_metadata: dict, + **kwargs, + ): + super().__init__(**kwargs) + self.job_id = job_id + self.revocation_registry_definition_state = revocation_registry_definition_state + self.registration_metadata = registration_metadata + self.revocation_registry_definition_metadata = ( + revocation_registry_definition_metadata + ) + + @property + def rev_reg_def_id(self): + return ( + self.revocation_registry_definition_state.revocation_registry_definition_id + ) + + @property + def rev_reg_def(self): + return self.revocation_registry_definition_state.revocation_registry_definition + + +class RevRegDefResultSchema(BaseModelSchema): + """Cred def result schema.""" + + class Meta: + """RevRegDefResultSchema metadata.""" + + model_class = RevRegDefResult + unknown = EXCLUDE + + job_id = fields.Str() + revocation_registry_definition_state = fields.Nested(RevRegDefStateSchema()) + registration_metadata = fields.Dict() + # For indy, revocation_registry_definition_metadata will contain the seqNo + revocation_registry_definition_metadata = fields.Dict() + + +class GetRevRegDefResult(BaseModel): + """GetRevRegDefResult""" + + class Meta: + """GetRevRegDefResult metadata.""" + + schema_class = "GetRevRegDefResultSchema" + + def __init__( + self, + revocation_registry: RevRegDef, + revocation_registry_id: str, + resolution_metadata: Dict[str, Any], + revocation_registry_metadata: Dict[str, Any], + **kwargs, + ): + super().__init__(**kwargs) + self.revocation_registry = revocation_registry + self.revocation_registry_id = revocation_registry_id + self.resolution_metadata = resolution_metadata + self.revocation_registry_metadata = revocation_registry_metadata + + +class GetRevRegDefResultSchema(BaseModelSchema): + class Meta: + """GetRevRegDefResultSchema metadata.""" + + model_class = GetRevRegDefResult + unknown = EXCLUDE + + revocation_registry = fields.Nested(RevRegDefSchema()) + revocation_registry_id = fields.Str() + resolution_metadata = fields.Dict() + revocation_registry_metadata = fields.Dict() + + +class RevList(BaseModel): + """RevList.""" + + class Meta: + """RevList metadata.""" + + schema_class = "RevListSchema" + + def __init__( + self, + issuer_id: str, + rev_reg_def_id: str, + revocation_list: List[int], + current_accumulator: str, + timestamp: Optional[int] = None, + **kwargs, + ): + super().__init__(**kwargs) + self.issuer_id = issuer_id + self.rev_reg_def_id = rev_reg_def_id + self.revocation_list = revocation_list + self.current_accumulator = current_accumulator + self.timestamp = timestamp + + @classmethod + def from_native(cls, rev_list: RevocationStatusList): + """Convert from native revocation list.""" + return cls.deserialize(rev_list.to_json()) + + def to_native(self): + """Convert to native revocation list.""" + return RevocationStatusList.load(self.serialize()) + + +class RevListSchema(BaseModelSchema): + """RevListSchema.""" + + class Meta: + """RevListSchema metadata.""" + + model_class = RevList + unknown = EXCLUDE + + issuer_id = fields.Str( + description="Issuer Identifier of the credential definition or schema", + data_key="issuerId", + ) + rev_reg_def_id = fields.Str( + description="", + data_key="revRegDefId", + ) + revocation_list = fields.List( + fields.Int(), + description="Bit list representing revoked credentials", + data_key="revocationList", + ) + current_accumulator = fields.Str(data_key="currentAccumulator") + timestamp = fields.Int( + description="Timestamp at which revocation list is applicable", + required=False, + ) + + +class RevListState(BaseModel): + """RevListState.""" + + STATE_FINISHED = "finished" + STATE_FAILED = "failed" + STATE_ACTION = "action" + STATE_WAIT = "wait" + + class Meta: + """RevListState metadata.""" + + schema_class = "RevListStateSchema" + + def __init__( + self, + state: str, + revocation_list: RevList, + ): + self.state = state + self.revocation_list = revocation_list + + +class RevListStateSchema(BaseModelSchema): + """RevListStateSchema.""" + + class Meta: + """RevListStateSchema metadata.""" + + model_class = RevListState + unknown = EXCLUDE + + state = fields.Str( + validate=OneOf( + [ + RevListState.STATE_FINISHED, + RevListState.STATE_FAILED, + RevListState.STATE_ACTION, + RevListState.STATE_WAIT, + ] + ) + ) + revocation_list = fields.Nested(RevListSchema(), description="revocation list") + + +class RevListResult(BaseModel): + """Cred def result.""" + + class Meta: + """RevListResult metadata.""" + + schema_class = "RevListResultSchema" + + def __init__( + self, + job_id: Optional[str], + revocation_list_state: RevListState, + registration_metadata: dict, + revocation_list_metadata: dict, + **kwargs, + ): + super().__init__(**kwargs) + self.job_id = job_id + self.revocation_list_state = revocation_list_state + self.registration_metadata = registration_metadata + self.revocation_list_metadata = revocation_list_metadata + + @property + def rev_reg_def_id(self): + return self.revocation_list_state.revocation_list.rev_reg_def_id + + +class RevListResultSchema(BaseModelSchema): + """Cred def result schema.""" + + class Meta: + """RevListResultSchema metadata.""" + + model_class = RevListResult + unknown = EXCLUDE + + job_id = fields.Str() + revocation_list_state = fields.Nested(RevListStateSchema()) + registration_metadata = fields.Dict() + # For indy, revocation_list_metadata will contain the seqNo + revocation_list_metadata = fields.Dict() + + +class GetRevListResult(BaseModel): + """GetRevListResult""" + + class Meta: + """GetRevListResult metadata.""" + + schema_class = "GetRevListResultSchema" + + def __init__( + self, + revocation_list: RevList, + resolution_metadata: Dict[str, Any], + revocation_registry_metadata: Dict[str, Any], + **kwargs, + ): + super().__init__(**kwargs) + self.revocation_list = revocation_list + self.resolution_metadata = resolution_metadata + self.revocation_registry_metadata = revocation_registry_metadata + + +class GetRevListResultSchema(BaseModelSchema): + """GetRevListResultSchema""" + + class Meta: + """GetRevListResultSchema metadata.""" + + model_class = GetRevListResult + unknown = EXCLUDE + + revocation_list = fields.Nested(RevListSchema) + resolution_metadata = fields.Str() + revocation_registry_metadata = fields.Dict() diff --git a/aries_cloudagent/anoncreds/models/anoncreds_schema.py b/aries_cloudagent/anoncreds/models/anoncreds_schema.py new file mode 100644 index 0000000000..0acf806829 --- /dev/null +++ b/aries_cloudagent/anoncreds/models/anoncreds_schema.py @@ -0,0 +1,199 @@ +"""Anoncreds Schema OpenAPI validators""" + +from typing import Any, Dict, List, Optional + +from marshmallow import EXCLUDE, fields +from marshmallow.validate import OneOf + +from anoncreds import Schema + +from ...messaging.models.base import BaseModel, BaseModelSchema + + +class AnonCredsSchema(BaseModel): + """An AnonCreds Schema object.""" + + class Meta: + """AnonCredsSchema metadata.""" + + schema_class = "AnonCredsSchemaSchema" + + def __init__( + self, issuer_id: str, attr_names: List[str], name: str, version: str, **kwargs + ): + super().__init__(**kwargs) + self.issuer_id = issuer_id + self.attr_names = attr_names + self.name = name + self.version = version + + @classmethod + def from_native(cls, schema: Schema) -> "AnonCredsSchema": + """Convert from native object.""" + return cls.deserialize(schema.to_dict()) + + def to_native(self): + """Convert to native object.""" + return Schema.load(self.serialize()) + + +class AnonCredsSchemaSchema(BaseModelSchema): + """Marshmallow schema for anoncreds schema.""" + + class Meta: + """AnonCredsSchemaSchema metadata.""" + + model_class = AnonCredsSchema + unknown = EXCLUDE + + issuer_id = fields.Str( + description="Issuer Identifier of the credential definition or schema", + data_key="issuerId", + ) + attr_names = fields.List( + fields.Str( + description="Attribute name", + example="score", + ), + description="Schema attribute names", + data_key="attrNames", + ) + name = fields.Str( + description="Schema name", + ) + version = fields.Str(description="Schema version") + + +class GetSchemaResult(BaseModel): + """Result of resolving a schema.""" + + class Meta: + """GetSchemaResult metadata.""" + + schema_class = "GetSchemaResultSchema" + + def __init__( + self, + schema: AnonCredsSchema, + schema_id: str, + resolution_metadata: Dict[str, Any], + schema_metadata: Dict[str, Any], + **kwargs + ): + super().__init__(**kwargs) + self.schema_value = schema + self.schema_id = schema_id + self.resolution_metadata = resolution_metadata + self.schema_metadata = schema_metadata + + @property + def schema(self) -> AnonCredsSchema: + """Alias for schema_value. + + `schema` can't be used directly due to a limitation of marshmallow. + """ + return self.schema_value + + +class GetSchemaResultSchema(BaseModelSchema): + """Parameters and validators for schema create query.""" + + class Meta: + """GetSchemaResultSchema metadata.""" + + model_class = GetSchemaResult + unknown = EXCLUDE + + schema_value = fields.Nested(AnonCredsSchemaSchema(), data_key="schema") + schema_id = fields.Str(data_key="schemaId", description="Schema identifier") + resolution_metadata = fields.Dict() + schema_metadata = fields.Dict() + + +class SchemaState(BaseModel): + """Model representing the state of a schema after beginning registration.""" + + STATE_FINISHED = "finished" + STATE_FAILED = "failed" + STATE_ACTION = "action" + STATE_WAIT = "wait" + + class Meta: + """SchemaState metadata.""" + + schema_class = "SchemaStateSchema" + + def __init__(self, state: str, schema_id: str, schema: AnonCredsSchema, **kwargs): + """Initialize a new SchemaState.""" + super().__init__(**kwargs) + self.state = state + self.schema_id = schema_id + self.schema_value = schema + + @property + def schema(self) -> AnonCredsSchema: + """Alias to schema_value. + + `schema` can't be used directly due to limitations of marshmallow. + """ + return self.schema_value + + +class SchemaStateSchema(BaseModelSchema): + """Parameters and validators for schema state.""" + + class Meta: + """SchemaStateSchema metadata.""" + + model_class = SchemaState + + state = fields.Str( + validate=OneOf( + [ + SchemaState.STATE_FINISHED, + SchemaState.STATE_FAILED, + SchemaState.STATE_ACTION, + SchemaState.STATE_WAIT, + ] + ) + ) + schema_id = fields.Str(description="Schema identifier") + schema_value = fields.Nested(AnonCredsSchemaSchema(), data_key="schema") + + +class SchemaResult(BaseModel): + """Result of registering a schema.""" + + class Meta: + """SchemaResult metadata.""" + + schema_class = "SchemaResultSchema" + + def __init__( + self, + job_id: Optional[str], + schema_state: SchemaState, + registration_metadata: Optional[dict] = None, + schema_metadata: Optional[dict] = None, + **kwargs + ): + super().__init__(**kwargs) + self.job_id = job_id + self.schema_state = schema_state + self.registration_metadata = registration_metadata + self.schema_metadata = schema_metadata + + +class SchemaResultSchema(BaseModelSchema): + """Parameters and validators for schema state.""" + + class Meta: + """SchemaResultSchema metadata.""" + + model_class = SchemaResult + + job_id = fields.Str() + schema_state = fields.Nested(SchemaStateSchema()) + registration_metadata = fields.Dict() + # For indy, schema_metadata will contain the seqNo + schema_metadata = fields.Dict() diff --git a/aries_cloudagent/anoncreds/registry.py b/aries_cloudagent/anoncreds/registry.py new file mode 100644 index 0000000000..486a7e48aa --- /dev/null +++ b/aries_cloudagent/anoncreds/registry.py @@ -0,0 +1,176 @@ +"""AnonCreds Registry""" +import logging +from typing import List, Optional, Sequence + + +from ..core.profile import Profile +from .models.anoncreds_cred_def import ( + CredDef, + CredDefResult, + GetCredDefResult, +) +from .models.anoncreds_revocation import ( + GetRevListResult, + GetRevRegDefResult, + RevRegDef, + RevRegDefResult, + RevList, + RevListResult, +) +from .models.anoncreds_schema import AnonCredsSchema, GetSchemaResult, SchemaResult +from .base import ( + AnonCredsRegistrationError, + AnonCredsResolutionError, + BaseAnonCredsHandler, + BaseAnonCredsRegistrar, + BaseAnonCredsResolver, +) + +LOGGER = logging.getLogger(__name__) + + +class AnonCredsRegistry: + """AnonCredsRegistry""" + + def __init__(self, registries: Optional[List[BaseAnonCredsHandler]] = None): + """Create DID Resolver.""" + self.resolvers = [] + self.registrars = [] + if registries: + for registry in registries: + self.register(registry) + + def register(self, registry: BaseAnonCredsHandler): + """Register a new registry.""" + if isinstance(registry, BaseAnonCredsResolver): + self.resolvers.append(registry) + if isinstance(registry, BaseAnonCredsRegistrar): + self.registrars.append(registry) + + async def _resolver_for_identifier(self, identifier: str) -> BaseAnonCredsResolver: + resolvers = [ + resolver + for resolver in self.resolvers + if await resolver.supports(identifier) + ] + if len(resolvers) > 1: + raise AnonCredsResolutionError( + f"More than one resolver found for identifier {identifier}" + ) + return resolvers[0] + + async def _registrar_for_identifier( + self, identifier: str + ) -> BaseAnonCredsRegistrar: + registrars = [ + registrar + for registrar in self.registrars + if await registrar.supports(identifier) + ] + if len(registrars) > 1: + raise AnonCredsRegistrationError( + f"More than one registrar found for identifier {identifier}" + ) + return registrars[0] + + async def get_schema(self, profile: Profile, schema_id: str) -> GetSchemaResult: + """Get a schema from the registry.""" + resolver = await self._resolver_for_identifier(schema_id) + return await resolver.get_schema(profile, schema_id) + + async def register_schema( + self, + profile: Profile, + schema: AnonCredsSchema, + options: Optional[dict] = None, + ) -> SchemaResult: + """Register a schema on the registry.""" + registrar = await self._registrar_for_identifier(schema.issuer_id) + return await registrar.register_schema(profile, schema, options) + + async def get_credential_definition( + self, profile: Profile, credential_definition_id: str + ) -> GetCredDefResult: + """Get a credential definition from the registry.""" + resolver = await self._resolver_for_identifier(credential_definition_id) + return await resolver.get_credential_definition( + profile, + credential_definition_id, + ) + + async def register_credential_definition( + self, + profile: Profile, + schema: GetSchemaResult, + credential_definition: CredDef, + options: Optional[dict] = None, + ) -> CredDefResult: + """Register a credential definition on the registry.""" + registrar = await self._registrar_for_identifier( + credential_definition.issuer_id + ) + + return await registrar.register_credential_definition( + profile, + schema, + credential_definition, + options, + ) + + async def get_revocation_registry_definition( + self, profile: Profile, revocation_registry_id: str + ) -> GetRevRegDefResult: + """Get a revocation registry definition from the registry.""" + resolver = await self._resolver_for_identifier(revocation_registry_id) + return await resolver.get_revocation_registry_definition( + profile, revocation_registry_id + ) + + async def register_revocation_registry_definition( + self, + profile: Profile, + revocation_registry_definition: RevRegDef, + options: Optional[dict] = None, + ) -> RevRegDefResult: + """Register a revocation registry definition on the registry.""" + registrar = await self._registrar_for_identifier( + revocation_registry_definition.issuer_id + ) + return await registrar.register_revocation_registry_definition( + profile, revocation_registry_definition, options + ) + + async def get_revocation_list( + self, profile: Profile, rev_reg_def_id: str, timestamp: int + ) -> GetRevListResult: + """Get a revocation list from the registry.""" + resolver = await self._resolver_for_identifier(rev_reg_def_id) + return await resolver.get_revocation_list(profile, rev_reg_def_id, timestamp) + + async def register_revocation_list( + self, + profile: Profile, + rev_reg_def: RevRegDef, + rev_list: RevList, + options: Optional[dict] = None, + ) -> RevListResult: + """Register a revocation list on the registry.""" + registrar = await self._registrar_for_identifier(rev_list.issuer_id) + return await registrar.register_revocation_list( + profile, rev_reg_def, rev_list, options + ) + + async def update_revocation_list( + self, + profile: Profile, + rev_reg_def: RevRegDef, + prev_list: RevList, + curr_list: RevList, + revoked: Sequence[int], + options: Optional[dict] = None, + ) -> RevListResult: + """Update a revocation list on the registry.""" + registrar = await self._registrar_for_identifier(prev_list.issuer_id) + return await registrar.update_revocation_list( + profile, rev_reg_def, prev_list, curr_list, revoked, options + ) diff --git a/aries_cloudagent/anoncreds/revocation.py b/aries_cloudagent/anoncreds/revocation.py new file mode 100644 index 0000000000..94d77b1360 --- /dev/null +++ b/aries_cloudagent/anoncreds/revocation.py @@ -0,0 +1,1124 @@ +"""Revocation through ledger agnostic AnonCreds interface.""" + +import asyncio +import hashlib +import http +import json +import logging +import os +from pathlib import Path +import time +from typing import List, NamedTuple, Optional, Sequence, Tuple +from urllib.parse import urlparse + +from anoncreds import ( + AnoncredsError, + Credential, + CredentialRevocationConfig, + 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.profile import Profile, ProfileSession +from ..tails.base import BaseTailsServer +from .issuer import ( + AnonCredsIssuer, + CATEGORY_CRED_DEF, + CATEGORY_CRED_DEF_PRIVATE, + STATE_FINISHED, +) +from .models.anoncreds_revocation import ( + RevList, + RevRegDef, + RevRegDefResult, + RevRegDefState, +) +from .registry import AnonCredsRegistry +from .util import indy_client_dir + +LOGGER = logging.getLogger(__name__) + +CATEGORY_REV_LIST = "revocation_list" +CATEGORY_REV_REG_DEF = "revocation_reg_def" +CATEGORY_REV_REG_DEF_PRIVATE = "revocation_reg_def_private" +CATEGORY_REV_REG_ISSUER = "revocation_reg_def_issuer" +STATE_REVOCATION_POSTED = "posted" +STATE_REVOCATION_PENDING = "pending" +REV_REG_DEF_STATE_ACTIVE = "active" + + +class AnonCredsRevocationError(BaseError): + """Generic revocation error.""" + + +class AnonCredsRevocationRegistryFullError(AnonCredsRevocationError): + """Revocation registry is full when issuing a new credential.""" + + +class RevokeResult(NamedTuple): + prev: RevList + curr: Optional[RevList] = None + revoked: Optional[Sequence[int]] = None + failed: Optional[Sequence[str]] = None + + +class AnonCredsRevocation: + """Revocation registry operations manager.""" + + def __init__(self, profile: Profile): + """ + Initialize an AnonCredsRevocation instance. + + Args: + profile: The active profile instance + + """ + self._profile = profile + + @property + def profile(self) -> AskarProfile: + """Accessor for the profile instance.""" + if not isinstance(self._profile, AskarProfile): + raise ValueError("AnonCreds interface requires Askar") + + return self._profile + + # Revocation artifact management + + async def _finish_registration( + self, + txn: AskarProfileSession, + category: str, + job_id: str, + registered_id: str, + *, + state: Optional[str] = None, + ): + entry = await txn.handle.fetch( + category, + job_id, + for_update=True, + ) + if not entry: + raise AnonCredsRevocationError( + f"{category} with job id {job_id} could not be found" + ) + + if state: + tags = entry.tags + tags["state"] = state + else: + tags = entry.tags + + await txn.handle.insert( + category, + registered_id, + value=entry.value, + tags=tags, + ) + await txn.handle.remove(category, job_id) + + async def create_and_register_revocation_registry_definition( + self, + issuer_id: str, + cred_def_id: str, + registry_type: str, + tag: str, + max_cred_num: int, + options: Optional[dict] = None, + ) -> RevRegDefResult: + """ + Create a new revocation registry and register on network. + + Args: + issuer_id (str): issuer identifier + cred_def_id (str): credential definition identifier + registry_type (str): revocation registry type + tag (str): revocation registry tag + max_cred_num (int): maximum number of credentials supported + options (dict): revocation registry options + + Returns: + RevRegDefResult: revocation registry definition result + + """ + try: + async with self.profile.session() as session: + cred_def = await session.handle.fetch(CATEGORY_CRED_DEF, cred_def_id) + except AskarError as err: + raise AnonCredsRevocationError( + "Error retrieving credential definition" + ) from err + + if not cred_def: + raise AnonCredsRevocationError( + "Credential definition not found for revocation registry" + ) + + tails_dir = indy_client_dir("tails", create=True) + + try: + ( + rev_reg_def, + rev_reg_def_private, + ) = await asyncio.get_event_loop().run_in_executor( + None, + lambda: RevocationRegistryDefinition.create( + cred_def_id, + cred_def.raw_value, + issuer_id, + tag, + registry_type, + max_cred_num, + tails_dir_path=tails_dir, + ), + ) + except AnoncredsError as err: + raise AnonCredsRevocationError( + "Error creating revocation registry" + ) from err + + rev_reg_def = RevRegDef.from_native(rev_reg_def) + + public_tails_uri = self.generate_public_tails_uri(rev_reg_def) + rev_reg_def.value.tails_location = public_tails_uri + anoncreds_registry = self.profile.inject(AnonCredsRegistry) + result = await anoncreds_registry.register_revocation_registry_definition( + self.profile, rev_reg_def, options + ) + + ident = result.rev_reg_def_id or result.job_id + if not ident: + raise AnonCredsRevocationError( + "Revocation registry definition id or job id not found" + ) + + # TODO Handle `failed` state + + try: + async with self.profile.transaction() as txn: + await txn.handle.insert( + CATEGORY_REV_REG_DEF, + ident, + rev_reg_def.to_json(), + tags={ + "cred_def_id": cred_def_id, + "state": result.revocation_registry_definition_state.state, + "active": json.dumps(False), + }, + ) + await txn.handle.insert( + CATEGORY_REV_REG_DEF_PRIVATE, + ident, + rev_reg_def_private.to_json_buffer(), + ) + await txn.commit() + except AskarError as err: + raise AnonCredsRevocationError( + "Error saving new revocation registry" + ) from err + + return result + + async def finish_revocation_registry_definition( + self, job_id: str, rev_reg_def_id: str + ): + """Mark a rev reg def as finished.""" + async with self.profile.transaction() as txn: + await self._finish_registration( + txn, CATEGORY_REV_REG_DEF, job_id, rev_reg_def_id, state=STATE_FINISHED + ) + await self._finish_registration( + txn, + CATEGORY_REV_REG_DEF_PRIVATE, + job_id, + rev_reg_def_id, + ) + await txn.commit() + + async def get_created_revocation_registry_definitions( + self, + cred_def_id: Optional[str] = None, + state: Optional[str] = None, + ) -> Sequence[str]: + """Retrieve IDs of rev reg defs previously created.""" + async with self.profile.session() as session: + # TODO limit? scan? + rev_reg_defs = await session.handle.fetch_all( + CATEGORY_REV_REG_DEF, + { + key: value + for key, value in { + "cred_def_id": cred_def_id, + "state": state, + }.items() + if value is not None + }, + ) + # entry.name was stored as the credential_definition's ID + return [entry.name for entry in rev_reg_defs] + + async def get_created_revocation_registry_definition( + self, + rev_reg_def_id: str, + ) -> Optional[RevRegDef]: + """Retrieve rev reg def by ID from rev reg defs previously created.""" + async with self.profile.session() as session: + rev_reg_def_entry = await session.handle.fetch( + CATEGORY_REV_REG_DEF, + name=rev_reg_def_id, + ) + + if rev_reg_def_entry: + return RevRegDef.deserialize(rev_reg_def_entry.value_json) + + return None + + async def set_active_registry(self, rev_reg_def_id: str): + """Mark a registry as active.""" + async with self.profile.transaction() as txn: + entry = await txn.handle.fetch( + CATEGORY_REV_REG_DEF, + rev_reg_def_id, + for_update=True, + ) + if not entry: + raise AnonCredsRevocationError( + f"{CATEGORY_REV_REG_DEF} with id " + f"{rev_reg_def_id} could not be found" + ) + + if entry.tags["active"] == json.dumps(True): + # NOTE If there are other registries set as active, we're not + # clearing them if the one we want to be active is already + # active. This probably isn't an issue. + return + + cred_def_id = entry.tags["cred_def_id"] + + old_active_entries = await txn.handle.fetch_all( + CATEGORY_REV_REG_DEF, + { + "active": json.dumps(True), + "cred_def_id": cred_def_id, + }, + for_update=True, + ) + + if len(old_active_entries) > 1: + LOGGER.error( + "More than one registry was set as active for " + f"cred def {cred_def_id}; clearing active tag from all records" + ) + + for old_entry in old_active_entries: + tags = old_entry.tags + tags["active"] = json.dumps(False) + await txn.handle.replace( + CATEGORY_REV_REG_DEF, + old_entry.name, + old_entry.value, + tags, + ) + + tags = entry.tags + tags["active"] = json.dumps(True) + await txn.handle.replace( + CATEGORY_REV_REG_DEF, + rev_reg_def_id, + value=entry.value, + tags=tags, + ) + await txn.commit() + + async def create_and_register_revocation_list( + self, rev_reg_def_id: str, options: Optional[dict] = None + ): + """Create and register a revocation list.""" + try: + async with self.profile.session() as session: + rev_reg_def_entry = await session.handle.fetch( + CATEGORY_REV_REG_DEF, rev_reg_def_id + ) + except AskarError as err: + raise AnonCredsRevocationError( + "Error retrieving revocation registry definition" + ) from err + + if not rev_reg_def_entry: + raise AnonCredsRevocationError( + f"Revocation registry definition not found for id {rev_reg_def_id}" + ) + + rev_reg_def = RevRegDef.deserialize(rev_reg_def_entry.value_json) + # TODO This is a little rough; stored tails location will have public uri + rev_reg_def.value.tails_location = self.get_local_tails_path(rev_reg_def) + + rev_list = RevocationStatusList.create( + rev_reg_def_id, + rev_reg_def.to_native(), + rev_reg_def.issuer_id, + ) + + anoncreds_registry = self.profile.inject(AnonCredsRegistry) + result = await anoncreds_registry.register_revocation_list( + self.profile, rev_reg_def, RevList.from_native(rev_list), options + ) + + # TODO Handle `failed` state + + rev_list = result.revocation_list_state.revocation_list + try: + async with self.profile.session() as session: + await session.handle.insert( + CATEGORY_REV_LIST, + rev_reg_def_id, + value_json={ + "rev_list": rev_list.serialize(), + "pending": None, + # TODO THIS IS A HACK; this fixes ACA-Py expecting 1-based indexes + "next_index": 1, + }, + tags={ + "state": result.revocation_list_state.state, + "pending": json.dumps(False), + }, + ) + except AskarError as err: + raise AnonCredsRevocationError( + "Error saving new revocation registry" + ) from err + + return result + + async def finish_revocation_list(self, rev_reg_def_id: str): + """Mark a revocation list as finished.""" + async with self.profile.transaction() as txn: + entry = await txn.handle.fetch( + CATEGORY_REV_LIST, + rev_reg_def_id, + for_update=True, + ) + if not entry: + raise AnonCredsRevocationError( + f"revocation list with id {rev_reg_def_id} could not be found" + ) + + tags = entry.tags + tags["state"] = STATE_FINISHED + await txn.handle.replace( + CATEGORY_REV_LIST, + rev_reg_def_id, + value=entry.value, + tags=tags, + ) + await txn.commit() + + async def update_revocation_list( + self, + rev_reg_def_id: str, + prev: RevList, + curr: RevList, + revoked: Sequence[int], + options: Optional[dict] = None, + ): + """Publish and update to a revocation list.""" + try: + async with self.profile.session() as session: + rev_reg_def_entry = await session.handle.fetch( + CATEGORY_REV_REG_DEF, rev_reg_def_id + ) + except AskarError as err: + raise AnonCredsRevocationError( + "Error retrieving revocation registry definition" + ) from err + + if not rev_reg_def_entry: + raise AnonCredsRevocationError( + f"Revocation registry definition not found for id {rev_reg_def_id}" + ) + + try: + async with self.profile.session() as session: + rev_list_entry = await session.handle.fetch( + CATEGORY_REV_LIST, rev_reg_def_id + ) + except AskarError as err: + raise AnonCredsRevocationError("Error retrieving revocation list") from err + + if not rev_list_entry: + raise AnonCredsRevocationError( + f"Revocation list not found for id {rev_reg_def_id}" + ) + + rev_reg_def = RevRegDef.deserialize(rev_reg_def_entry.value_json) + rev_list = RevList.deserialize(rev_list_entry.value_json["rev_list"]) + if rev_list.revocation_list != curr.revocation_list: + raise AnonCredsRevocationError( + "Passed revocation list does not match stored" + ) + + anoncreds_registry = self.profile.inject(AnonCredsRegistry) + result = await anoncreds_registry.update_revocation_list( + self.profile, rev_reg_def, prev, curr, revoked, options + ) + + # TODO Handle `failed` state + + try: + async with self.profile.session() as session: + rev_list_entry_upd = await session.handle.fetch( + CATEGORY_REV_LIST, rev_reg_def_id, for_update=True + ) + if not rev_list_entry_upd: + raise AnonCredsRevocationError( + "Revocation list not found for id {rev_reg_def_id}" + ) + tags = rev_list_entry_upd.tags + tags["state"] = result.revocation_list_state.state + await session.handle.replace( + CATEGORY_REV_LIST, + rev_reg_def_id, + value=rev_list_entry_upd.value, + tags=tags, + ) + except AskarError as err: + raise AnonCredsRevocationError( + "Error saving new revocation registry" + ) from err + + return result + + async def get_created_revocation_list( + self, rev_reg_def_id: str + ) -> Optional[RevList]: + """Return rev list from record in wallet.""" + try: + async with self.profile.session() as session: + rev_list_entry = await session.handle.fetch( + CATEGORY_REV_LIST, rev_reg_def_id + ) + except AskarError as err: + raise AnonCredsRevocationError("Error retrieving revocation list") from err + + if rev_list_entry: + return RevList.deserialize(rev_list_entry.value_json["rev_list"]) + + return None + + async def get_revocation_lists_with_pending_revocations(self) -> Sequence[str]: + """Return a list of rev reg def ids with pending revocations.""" + try: + async with self.profile.session() as session: + rev_list_entries = await session.handle.fetch_all( + CATEGORY_REV_LIST, + {"pending": json.dumps(True)}, + ) + except AskarError as err: + raise AnonCredsRevocationError("Error retrieving revocation list") from err + + if rev_list_entries: + return [entry.name for entry in rev_list_entries] + + return [] + + async def retrieve_tails(self, rev_reg_def: RevRegDef) -> str: + """Retrieve tails file from server.""" + LOGGER.info( + "Downloading the tails file with hash: %s", + rev_reg_def.value.tails_hash, + ) + + tails_file_path = Path(self.get_local_tails_path(rev_reg_def)) + 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 + file_hasher = hashlib.sha256() + with open(tails_file_path, "wb", buffer_size) as tails_file: + with Session() as req_session: + try: + resp = req_session.get( + rev_reg_def.value.tails_location, stream=True + ) + # Should this directly raise an Error? + if resp.status_code != http.HTTPStatus.OK: + LOGGER.warning( + f"Unexpected status code for tails file: {resp.status_code}" + ) + for buf in resp.iter_content(chunk_size=buffer_size): + tails_file.write(buf) + file_hasher.update(buf) + except RequestException as rx: + raise AnonCredsRevocationError(f"Error retrieving tails file: {rx}") + + download_tails_hash = base58.b58encode(file_hasher.digest()).decode("utf-8") + if download_tails_hash != rev_reg_def.value.tails_hash: + try: + os.remove(tails_file_path) + except OSError as err: + LOGGER.warning(f"Could not delete invalid tails file: {err}") + + raise AnonCredsRevocationError( + "The hash of the downloaded tails file does not match." + ) + + return str(tails_file_path) + + def _check_url(self, url) -> None: + parsed = urlparse(url) + if not (parsed.scheme and parsed.netloc and parsed.path): + raise AnonCredsRevocationError("URI {} is not a valid URL".format(url)) + + def generate_public_tails_uri(self, rev_reg_def: RevRegDef): + """Construct tails uri from rev_reg_def.""" + tails_base_url = self.profile.settings.get("tails_server_base_url") + if not tails_base_url: + raise AnonCredsRevocationError("tails_server_base_url not configured") + + public_tails_uri = ( + tails_base_url.rstrip("/") + f"/hash/{rev_reg_def.value.tails_hash}" + ) + + self._check_url(public_tails_uri) + return public_tails_uri + + def get_local_tails_path(self, rev_reg_def: RevRegDef) -> str: + """Get the local path to the tails file.""" + tails_dir = indy_client_dir("tails", create=False) + return os.path.join(tails_dir, rev_reg_def.value.tails_hash) + + async def upload_tails_file(self, rev_reg_def: RevRegDef): + """Upload the local tails file to the tails server.""" + tails_server = self.profile.inject_or(BaseTailsServer) + if not tails_server: + raise AnonCredsRevocationError("Tails server not configured") + if not Path(self.get_local_tails_path(rev_reg_def)).is_file(): + raise AnonCredsRevocationError("Local tails file not found") + + (upload_success, result) = await tails_server.upload_tails_file( + self.profile.context, + rev_reg_def.value.tails_hash, + self.get_local_tails_path(rev_reg_def), + interval=0.8, + backoff=-0.5, + max_attempts=5, # heuristic: respect HTTP timeout + ) + if not upload_success: + raise AnonCredsRevocationError( + f"Tails file for rev reg for {rev_reg_def.cred_def_id} " + f"failed to upload: {result}" + ) + if rev_reg_def.value.tails_location != result: + raise AnonCredsRevocationError( + f"Tails file for rev reg for {rev_reg_def.cred_def_id} " + f"uploaded to wrong location: {result} " + f"(should have been {rev_reg_def.value.tails_location})" + ) + + async def get_or_fetch_local_tails_path(self, rev_reg_def: RevRegDef) -> str: + """Return path to local tails file. + + If not present, retrieve from tails server. + """ + tails_file_path = self.get_local_tails_path(rev_reg_def) + if Path(tails_file_path).is_file(): + return tails_file_path + return await self.retrieve_tails(rev_reg_def) + + # Registry Management + + async def handle_full_registry(self, rev_reg_def_id: str): + """Update the registry status and start the next registry generation.""" + # TODO + + async def get_or_create_active_registry(self, cred_def_id: str) -> RevRegDefResult: + """Get or create a revocation registry for the given cred def id.""" + async with self.profile.session() as session: + rev_reg_defs = await session.handle.fetch_all( + CATEGORY_REV_REG_DEF, + { + "cred_def_id": cred_def_id, + "active": json.dumps(True), + }, + limit=1, + ) + + if not rev_reg_defs: + # TODO Create a registry if none available + raise AnonCredsRevocationError("No active registry") + + entry = rev_reg_defs[0] + + rev_reg_def = RevRegDef.deserialize(entry.value_json) + result = RevRegDefResult( + None, + RevRegDefState( + state=STATE_FINISHED, + revocation_registry_definition_id=entry.name, + revocation_registry_definition=rev_reg_def, + ), + registration_metadata={}, + revocation_registry_definition_metadata={}, + ) + return result + + # Credential Operations + + async def _create_credential( + self, + credential_definition_id: str, + schema_attributes: List[str], + credential_offer: dict, + credential_request: dict, + credential_values: dict, + rev_reg_def_id: Optional[str] = None, + tails_file_path: Optional[str] = None, + ) -> Tuple[str, str]: + try: + async with self.profile.session() as session: + cred_def = await session.handle.fetch( + CATEGORY_CRED_DEF, credential_definition_id + ) + cred_def_private = await session.handle.fetch( + CATEGORY_CRED_DEF_PRIVATE, credential_definition_id + ) + except AskarError as err: + raise AnonCredsRevocationError( + "Error retrieving credential definition" + ) from err + if not cred_def or not cred_def_private: + raise AnonCredsRevocationError( + "Credential definition not found for credential issuance" + ) + + raw_values = {} + for attribute in schema_attributes: + # Ensure every attribute present in schema to be set. + # Extraneous attribute names are ignored. + try: + credential_value = credential_values[attribute] + except KeyError: + raise AnonCredsRevocationError( + "Provided credential values are missing a value " + f"for the schema attribute '{attribute}'" + ) + + raw_values[attribute] = str(credential_value) + + if rev_reg_def_id and tails_file_path: + try: + async with self.profile.transaction() as txn: + rev_list = await txn.handle.fetch(CATEGORY_REV_LIST, rev_reg_def_id) + rev_reg_def = await txn.handle.fetch( + CATEGORY_REV_REG_DEF, rev_reg_def_id + ) + rev_key = await txn.handle.fetch( + CATEGORY_REV_REG_DEF_PRIVATE, rev_reg_def_id + ) + if not rev_list: + raise AnonCredsRevocationError("Revocation registry not found") + if not rev_reg_def: + raise AnonCredsRevocationError( + "Revocation registry definition not found" + ) + if not rev_key: + raise AnonCredsRevocationError( + "Revocation registry definition private data not found" + ) + # NOTE: we increment the index ahead of time to keep the + # transaction short. The revocation registry itself will NOT + # be updated because we always use ISSUANCE_BY_DEFAULT. + # If something goes wrong later, the index will be skipped. + # FIXME - double check issuance type in case of upgraded wallet? + rev_info = rev_list.value_json + rev_info_tags = rev_list.tags + rev_reg_index = rev_info["next_index"] + try: + rev_reg_def = RevocationRegistryDefinition.load( + rev_reg_def.raw_value + ) + rev_list = RevocationStatusList.load(rev_info["rev_list"]) + except AnoncredsError as err: + raise AnonCredsRevocationError( + "Error loading revocation registry definition" + ) from err + if rev_reg_index > rev_reg_def.max_cred_num: + raise AnonCredsRevocationRegistryFullError( + "Revocation registry is full" + ) + rev_info["next_index"] = rev_reg_index + 1 + await txn.handle.replace( + CATEGORY_REV_LIST, + rev_reg_def_id, + value_json=rev_info, + tags=rev_info_tags, + ) + await txn.commit() + except AskarError as err: + raise AnonCredsRevocationError( + "Error updating revocation registry index" + ) from err + + revoc = CredentialRevocationConfig( + rev_reg_def, + rev_key.raw_value, + rev_reg_index, + tails_file_path, + ) + credential_revocation_id = str(rev_reg_index) + else: + revoc = None + credential_revocation_id = None + rev_list = None + + try: + credential = await asyncio.get_event_loop().run_in_executor( + None, + lambda: Credential.create( + cred_def.raw_value, + cred_def_private.raw_value, + credential_offer, + credential_request, + raw_values, + None, + rev_reg_def_id, + rev_list, + revoc, + ), + ) + except AnoncredsError as err: + raise AnonCredsRevocationError("Error creating credential") from err + + return credential.to_json(), credential_revocation_id + + async def create_credential( + self, + credential_offer: dict, + credential_request: dict, + credential_values: dict, + *, + retries: int = 5, + ) -> Tuple[str, str, str]: + """ + Create a credential. + + Args + credential_offer: Credential Offer to create credential for + credential_request: Credential request to create credential for + credential_values: Values to go in credential + revoc_reg_id: ID of the revocation registry + retries: number of times to retry credential creation + + Returns: + A tuple of created credential and revocation id + + """ + issuer = AnonCredsIssuer(self.profile) + anoncreds_registry = self.profile.inject(AnonCredsRegistry) + schema_id = credential_offer["schema_id"] + schema_result = await anoncreds_registry.get_schema(self.profile, schema_id) + cred_def_id = credential_offer["cred_def_id"] + + revocable = await issuer.cred_def_supports_revocation(cred_def_id) + + for attempt in range(max(retries, 1)): + if attempt > 0: + LOGGER.info( + "Waiting 2s before retrying credential issuance for cred def '%s'", + cred_def_id, + ) + await asyncio.sleep(2) + + rev_reg_def_result = None + if revocable: + rev_reg_def_result = await self.get_or_create_active_registry( + cred_def_id + ) + if ( + rev_reg_def_result.revocation_registry_definition_state.state + != STATE_FINISHED + ): + continue + rev_reg_def_id = rev_reg_def_result.rev_reg_def_id + tails_file_path = self.get_local_tails_path( + rev_reg_def_result.rev_reg_def + ) + else: + rev_reg_def_id = None + tails_file_path = None + + try: + cred_json, cred_rev_id = await self._create_credential( + cred_def_id, + schema_result.schema_value.attr_names, + credential_offer, + credential_request, + credential_values, + rev_reg_def_id, + tails_file_path, + ) + except AnonCredsRevocationRegistryFullError: + # unlucky, another instance filled the registry first + continue + + if ( + rev_reg_def_result + and rev_reg_def_result.rev_reg_def.value.max_cred_num + <= int(cred_rev_id) + ): + await self.handle_full_registry(rev_reg_def_id) + + return cred_json, cred_rev_id, rev_reg_def_id + + raise AnonCredsRevocationError( + f"Cred def '{cred_def_id}' has no active revocation registry" + ) + + async def revoke_pending_credentials( + self, + revoc_reg_id: str, + *, + additional_crids: Optional[Sequence[int]] = None, + limit_crids: Optional[Sequence[int]] = None, + ) -> RevokeResult: + """ + Revoke a set of credentials in a revocation registry. + + Args: + revoc_reg_id: ID of the revocation registry + additional_crids: sequences of additional credential indexes to revoke + limit_crids: a sequence of credential indexes to limit revocation to + If None, all pending revocations will be published. + If given, the intersection of pending and limit crids will be published. + + Returns: + Tuple with the update revocation list, list of cred rev ids not revoked + + """ + updated_list = None + failed_crids = set() + max_attempt = 5 + attempt = 0 + + while True: + attempt += 1 + if attempt >= max_attempt: + raise AnonCredsRevocationError( + "Repeated conflict attempting to update registry" + ) + try: + async with self.profile.session() as session: + rev_reg_def_entry = await session.handle.fetch( + CATEGORY_REV_REG_DEF, revoc_reg_id + ) + rev_list_entry = await session.handle.fetch( + CATEGORY_REV_LIST, revoc_reg_id + ) + if not rev_reg_def_entry: + raise AnonCredsRevocationError( + "Revocation registry definition not found" + ) + if not rev_list_entry: + raise AnonCredsRevocationError("Revocation registry not found") + except AskarError as err: + raise AnonCredsRevocationError( + "Error retrieving revocation registry" + ) from err + + try: + # TODO This is a little rough; stored tails location will have public uri + # but library needs local tails location + rev_reg_def = RevRegDef.deserialize(rev_reg_def_entry.value_json) + rev_reg_def.value.tails_location = self.get_local_tails_path( + rev_reg_def + ) + except AnoncredsError as err: + raise AnonCredsRevocationError( + "Error loading revocation registry definition" + ) from err + + rev_crids = set() + failed_crids = set() + max_cred_num = rev_reg_def.value.max_cred_num + rev_info = rev_list_entry.value_json + cred_revoc_ids = rev_info["pending"] + (additional_crids or []) + rev_list = RevList.deserialize(rev_info["rev_list"]) + + for rev_id in cred_revoc_ids: + if rev_id < 1 or rev_id > max_cred_num: + LOGGER.error( + "Skipping requested credential revocation" + "on rev reg id %s, cred rev id=%s not in range", + revoc_reg_id, + rev_id, + ) + failed_crids.add(rev_id) + elif rev_id >= rev_info["next_index"]: + LOGGER.warn( + "Skipping requested credential revocation" + "on rev reg id %s, cred rev id=%s not yet issued", + revoc_reg_id, + rev_id, + ) + failed_crids.add(rev_id) + elif rev_list.revocation_list[rev_id] == 1: + LOGGER.warn( + "Skipping requested credential revocation" + "on rev reg id %s, cred rev id=%s already revoked", + revoc_reg_id, + rev_id, + ) + failed_crids.add(rev_id) + else: + rev_crids.add(rev_id) + + if not rev_crids: + break + + if limit_crids is None: + skipped_crids = set() + else: + skipped_crids = rev_crids - set(limit_crids) + rev_crids = rev_crids - skipped_crids + + try: + updated_list = await asyncio.get_event_loop().run_in_executor( + None, + lambda: rev_list.to_native().update( + int(time.time()), + None, # issued + list(rev_crids), # revoked + rev_reg_def.to_native(), + ), + ) + except AnoncredsError as err: + raise AnonCredsRevocationError( + "Error updating revocation registry" + ) from err + + try: + async with self.profile.transaction() as txn: + rev_info_upd = await txn.handle.fetch( + CATEGORY_REV_LIST, revoc_reg_id, for_update=True + ) + if not rev_info_upd: + LOGGER.warn( + "Revocation registry missing, skipping update: {}", + revoc_reg_id, + ) + updated_list = None + break + tags = rev_info_upd.tags + rev_info_upd = rev_info_upd.value_json + if rev_info_upd != rev_info: + # handle concurrent update to the registry by retrying + continue + rev_info_upd["rev_list"] = updated_list.to_dict() + rev_info_upd["pending"] = ( + list(skipped_crids) if skipped_crids else None + ) + tags["pending"] = json.dumps(True if skipped_crids else False) + await txn.handle.replace( + CATEGORY_REV_LIST, + revoc_reg_id, + value_json=rev_info_upd, + tags=tags, + ) + await txn.commit() + except AskarError as err: + raise AnonCredsRevocationError( + "Error saving revocation registry" + ) from err + break + + return RevokeResult( + prev=rev_list, + curr=RevList.from_native(updated_list) if updated_list else None, + revoked=list(rev_crids), + failed=[str(rev_id) for rev_id in sorted(failed_crids)], + ) + + async def mark_pending_revocations(self, rev_reg_def_id: str, *crids: int): + """Stores the cred rev ids to publish later.""" + async with self.profile.transaction() as txn: + entry = await txn.handle.fetch( + CATEGORY_REV_LIST, + rev_reg_def_id, + for_update=True, + ) + + if not entry: + raise AnonCredsRevocationError( + "Revocation list with id {rev_reg_def_id} not found" + ) + + pending: Optional[List[int]] = entry.value_json["pending"] + if pending: + pending.extend(crids) + else: + pending = list(crids) + + value = entry.value_json + value["pending"] = pending + tags = entry.tags + tags["pending"] = json.dumps(True) + await txn.handle.replace( + CATEGORY_REV_LIST, + rev_reg_def_id, + value_json=value, + tags=tags, + ) + await txn.commit() + + async def get_pending_revocations(self, rev_reg_def_id: str) -> List[int]: + """Retrieve the list of credential revocation ids pending revocation.""" + async with self.profile.session() as session: + entry = await session.handle.fetch(CATEGORY_REV_LIST, rev_reg_def_id) + if not entry: + return [] + + return entry.value_json["pending"] or [] + + async def clear_pending_revocations( + self, + txn: ProfileSession, + rev_reg_def_id: str, + crid_mask: Optional[Sequence[int]] = None, + ): + """Clear pending revocations.""" + if not isinstance(txn, AskarProfileSession): + raise ValueError("Askar wallet required") + + entry = await txn.handle.fetch( + CATEGORY_REV_LIST, + rev_reg_def_id, + for_update=True, + ) + + if not entry: + raise AnonCredsRevocationError( + "Revocation list with id {rev_reg_def_id} not found" + ) + + value = entry.value_json + if crid_mask is None: + value["pending"] = None + else: + value["pending"] = set(value["pending"]) - set(crid_mask) + + tags = entry.tags + tags["pending"] = json.dumps(False) + await txn.handle.replace( + CATEGORY_REV_LIST, + rev_reg_def_id, + value_json=value, + tags=tags, + ) diff --git a/aries_cloudagent/anoncreds/routes.py b/aries_cloudagent/anoncreds/routes.py new file mode 100644 index 0000000000..ea1e54cf8d --- /dev/null +++ b/aries_cloudagent/anoncreds/routes.py @@ -0,0 +1,608 @@ +"""Anoncreds admin routes.""" +from asyncio import shield +import logging + +from aiohttp import web +from aiohttp_apispec import ( + docs, + match_info_schema, + querystring_schema, + request_schema, + response_schema, +) +from marshmallow import fields + +from ..admin.request_context import AdminRequestContext +from ..askar.profile import AskarProfile +from ..messaging.models.openapi import OpenAPISchema +from ..messaging.valid import UUIDFour +from ..revocation.error import RevocationError, RevocationNotSupportedError +from ..revocation.manager import RevocationManager, RevocationManagerError +from ..revocation.routes import ( + PublishRevocationsSchema, + RevRegIdMatchInfoSchema, + RevocationModuleResponseSchema, + RevokeRequestSchema, + TxnOrPublishRevocationsResultSchema, +) +from ..storage.error import StorageError, StorageNotFoundError +from .base import AnonCredsRegistrationError +from .issuer import AnonCredsIssuer, AnonCredsIssuerError +from .models.anoncreds_cred_def import CredDefResultSchema, GetCredDefResultSchema +from .models.anoncreds_revocation import RevListResultSchema, RevRegDefResultSchema +from .models.anoncreds_schema import ( + AnonCredsSchemaSchema, + GetSchemaResultSchema, + SchemaResultSchema, +) +from .registry import AnonCredsRegistry +from .revocation import AnonCredsRevocation, AnonCredsRevocationError + +LOGGER = logging.getLogger(__name__) + +SPEC_URI = "https://hyperledger.github.io/anoncreds-spec" + + +class SchemaIdMatchInfo(OpenAPISchema): + """Path parameters and validators for request taking schema id.""" + + schema_id = fields.Str(data_key="schemaId", description="Schema identifier") + + +class CredIdMatchInfo(OpenAPISchema): + """Path parameters and validators for request taking credential id.""" + + cred_def_id = fields.Str( + description="Credential identifier", required=True, example=UUIDFour.EXAMPLE + ) + + +class InnerCredDefSchema(OpenAPISchema): + """Parameters and validators for credential definition.""" + + tag = fields.Str(description="Credential definition tag") + schemaId = fields.Str(data_key="schemaId", description="Schema identifier") + issuerId = fields.Str( + description="Issuer Identifier of the credential definition or schema", + ) + + +class CredDefPostOptionsSchema(OpenAPISchema): + """Parameters and validators for credential definition options.""" + + endorser_connection_id = fields.Str(required=False) + support_revocation = fields.Bool(required=False) + revocation_registry_size = fields.Int(required=False) + + +class CredDefPostRequestSchema(OpenAPISchema): + """Parameters and validators for query string in create credential definition.""" + + credential_definition = fields.Nested(InnerCredDefSchema()) + options = fields.Nested(CredDefPostOptionsSchema()) + + +class CredDefsQueryStringSchema(OpenAPISchema): + """Parameters and validators for credential definition list query.""" + + issuer_id = fields.Str( + description="Issuer Identifier of the credential definition", + ) + schema_id = fields.Str(data_key="schemaId", description="Schema identifier") + schema_name = fields.Str( + description="Schema name", + ) + schema_version = fields.Str(description="Schema version") + + +class SchemaPostOptionSchema(OpenAPISchema): + """Parameters and validators for schema options.""" + + endorser_connection_id = fields.UUID( + description="Connection identifier (optional) (this is an example)", + required=False, + example=UUIDFour.EXAMPLE, + ) + + +class SchemaPostRequestSchema(OpenAPISchema): + """Parameters and validators for query string in create schema.""" + + schema = fields.Nested(AnonCredsSchemaSchema()) + options = fields.Nested(SchemaPostOptionSchema()) + + +@docs(tags=["anoncreds"], summary="") +@request_schema(SchemaPostRequestSchema()) +@response_schema(SchemaResultSchema(), 200, description="") +async def schemas_post(request: web.BaseRequest): + """Request handler for creating a schema. + + Args: + request (web.BaseRequest): aiohttp request object + schema: { + "attrNames": ["string"], + "name": "string", + "version": "string", + "issuerId": "string" + }, + options: options method can be different per method, + but it can also include default options for all anoncreds + methods (none for schema). it can also be automatically + inferred from the agent startup parameters (default endorser) + endorser_connection_id: "" + Returns: + json object: + job_id: job identifier to keep track of the status of the schema creation. + MUST be absent or have a null value if the value of the schema_state. state + response field is either finished or failed, and MUST NOT have a null value + otherwise. + schema_state: + state : The state of the schema creation. Possible values are finished, + failed, action and wait. + schema_id : The id of the schema. If the value of the schema_state.state + response field is finished, this field MUST be present and MUST NOT have + a null value. + schema : The schema. If the value of the schema_state.state response field + is finished, this field MUST be present and MUST NOT have a null value. + registration_metadata : This field contains metadata about hte registration + process + schema_metadata : This fields contains metadata about the schema. + + """ + context: AdminRequestContext = request["context"] + + body = await request.json() + options = body.get("option") + schema_data = body.get("schema") + + issuer_id = schema_data.get("issuerId") + attr_names = schema_data.get("attrNames") + name = schema_data.get("name") + version = schema_data.get("version") + + issuer = AnonCredsIssuer(context.profile) + result = await issuer.create_and_register_schema( + issuer_id, name, version, attr_names, options=options + ) + return web.json_response(result.serialize()) + + +@docs(tags=["anoncreds"], summary="") +@match_info_schema(SchemaIdMatchInfo()) +@response_schema(GetSchemaResultSchema(), 200, description="") +async def schema_get(request: web.BaseRequest): + """Request handler for getting a schema. + + Args: + request (web.BaseRequest): aiohttp request object + + Returns: + json object: schema + + """ + context: AdminRequestContext = request["context"] + anoncreds_registry = context.inject(AnonCredsRegistry) + schema_id = request.match_info["schemaId"] + result = await anoncreds_registry.get_schema(context.profile, schema_id) + + return web.json_response(result.serialize()) + + +class SchemasQueryStringSchema(OpenAPISchema): + """Parameters and validators for query string in schemas list query.""" + + schema_name = fields.Str( + description="Schema name", + example="example-schema", + ) + schema_version = fields.Str(description="Schema version") + schema_issuer_id = fields.Str( + description="Issuer Identifier of the credential definition or schema", + ) + + +class GetSchemasResponseSchema(OpenAPISchema): + """Parameters and validators for schema list all response.""" + + schema_ids = fields.List( + fields.Str( + data_key="schemaIds", + description="Schema identifier", + ) + ) + + +@docs(tags=["anoncreds"], summary="") +@querystring_schema(SchemasQueryStringSchema()) +@response_schema(GetSchemasResponseSchema(), 200, description="") +async def schemas_get(request: web.BaseRequest): + """Request handler for getting all schemas. + + Args: + + Returns: + + """ + context: AdminRequestContext = request["context"] + + schema_issuer_id = request.query.get("schema_issuer_id") + schema_name = request.query.get("schema_name") + schema_version = request.query.get("schema_version") + + issuer = AnonCredsIssuer(context.profile) + schema_ids = await issuer.get_created_schemas( + schema_name, schema_version, schema_issuer_id + ) + return web.json_response({"schema_ids": schema_ids}) + + +@docs(tags=["anoncreds"], summary="") +@request_schema(CredDefPostRequestSchema()) +@response_schema(CredDefResultSchema(), 200, description="") +async def cred_def_post(request: web.BaseRequest): + """Request handler for creating . + + Args: + + Returns: + + """ + context: AdminRequestContext = request["context"] + body = await request.json() + options = body.get("options") + cred_def = body.get("credential_definition") + issuer_id = cred_def.get("issuerId") + schema_id = cred_def.get("schemaId") + tag = cred_def.get("tag") + + issuer = AnonCredsIssuer(context.profile) + result = await issuer.create_and_register_credential_definition( + issuer_id, + schema_id, + tag, + options=options, + ) + + return web.json_response(result.serialize()) + + +@docs(tags=["anoncreds"], summary="") +@match_info_schema(CredIdMatchInfo()) +@response_schema(GetCredDefResultSchema(), 200, description="") +async def cred_def_get(request: web.BaseRequest): + """Request handler for getting credential definition. + + Args: + + Returns: + + """ + context: AdminRequestContext = request["context"] + anon_creds_registry = context.inject(AnonCredsRegistry) + credential_id = request.match_info["cred_def_id"] + result = await anon_creds_registry.get_credential_definition( + context.profile, credential_id + ) + return web.json_response(result.serialize()) + + +class GetCredDefsResponseSchema(OpenAPISchema): + """AnonCredsRegistryGetCredDefsSchema""" + + credential_definition_ids = fields.List( + fields.Str( + description="credential definition identifiers", + ) + ) + + +@docs(tags=["anoncreds"], summary="") +@querystring_schema(CredDefsQueryStringSchema()) +@response_schema(GetCredDefsResponseSchema(), 200, description="") +async def cred_defs_get(request: web.BaseRequest): + """Request handler for getting all credential definitions. + + Args: + + Returns: + + """ + context: AdminRequestContext = request["context"] + issuer = AnonCredsIssuer(context.profile) + + cred_def_ids = await issuer.get_created_credential_definitions( + issuer_id=request.query.get("issuer_id"), + schema_id=request.query.get("schema_id"), + schema_name=request.query.get("schema_name"), + schema_version=request.query.get("schema_version"), + ) + return web.json_response(cred_def_ids) + + +class RevRegCreateRequestSchema(OpenAPISchema): + """Request schema for revocation registry creation request.""" + + issuer_id = fields.Str( + description="Issuer Identifier of the credential definition or schema", + data_key="issuerId", + ) + cred_def_id = fields.Str( + description="Credential definition identifier", + data_key="credDefId", + ) + tag = fields.Str(description="tag for revocation registry") + max_cred_num = fields.Int(data_key="maxCredNum") + registry_type = fields.Str( + description="Revocation registry type", + data_key="type", + required=False, + ) + + +@docs(tags=["anoncreds"], summary="") +@request_schema(RevRegCreateRequestSchema()) +@response_schema(RevRegDefResultSchema(), 200, description="") +async def rev_reg_def_post(request: web.BaseRequest): + """Request handler for creating revocation registry definition.""" + context: AdminRequestContext = request["context"] + body = await request.json() + issuer_id = body.get("issuerId") + cred_def_id = body.get("credDefId") + max_cred_num = body.get("maxCredNum") + options = body.get("options") + + issuer = AnonCredsIssuer(context.profile) + revocation = AnonCredsRevocation(context.profile) + # check we published this cred def + found = await issuer.match_created_credential_definitions(cred_def_id) + if not found: + raise web.HTTPNotFound( + reason=f"Not issuer of credential definition id {cred_def_id}" + ) + + try: + result = await shield( + revocation.create_and_register_revocation_registry_definition( + issuer_id, + cred_def_id, + registry_type="CL_ACCUM", + max_cred_num=max_cred_num, + tag="default", + options=options, + ) + ) + except RevocationNotSupportedError as e: + raise web.HTTPBadRequest(reason=e.message) from e + except AnonCredsRevocationError as e: + raise web.HTTPBadRequest(reason=e.message) from e + + return web.json_response(result.serialize()) + + +class RevListCreateRequestSchema(OpenAPISchema): + """Request schema for revocation registry creation request.""" + + rev_reg_def_id = fields.Str( + description="Revocation registry definition identifier", + data_key="revRegDefId", + ) + + +@docs(tags=["anoncreds"], summary="") +@request_schema(RevListCreateRequestSchema()) +@response_schema(RevListResultSchema(), 200, description="") +async def rev_list_post(request: web.BaseRequest): + """Request handler for creating registering a revocation list.""" + context: AdminRequestContext = request["context"] + body = await request.json() + rev_reg_def_id = body.get("revRegDefId") + options = body.get("options") + + revocation = AnonCredsRevocation(context.profile) + try: + result = await shield( + revocation.create_and_register_revocation_list( + rev_reg_def_id, + options, + ) + ) + LOGGER.debug("published revocation list for: %s", rev_reg_def_id) + + except StorageNotFoundError as err: + raise web.HTTPNotFound(reason=err.roll_up) from err + except AnonCredsRevocationError as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + + return web.json_response(result.serialize()) + + +@docs( + tags=["anoncreds"], + summary="Upload local tails file to server", +) +@match_info_schema(RevRegIdMatchInfoSchema()) +@response_schema(RevocationModuleResponseSchema(), description="") +async def upload_tails_file(request: web.BaseRequest): + """ + Request handler to upload local tails file for revocation registry. + + Args: + request: aiohttp request object + + """ + context: AdminRequestContext = request["context"] + profile: AskarProfile = context.profile + rev_reg_id = request.match_info["rev_reg_id"] + try: + revocation = AnonCredsRevocation(profile) + rev_reg_def = await revocation.get_created_revocation_registry_definition( + rev_reg_id + ) + if rev_reg_def is None: + raise web.HTTPNotFound(reason="No rev reg def found") + + await revocation.upload_tails_file(rev_reg_def) + + except AnonCredsIssuerError as e: + raise web.HTTPInternalServerError(reason=str(e)) from e + + return web.json_response({}) + + +@docs( + tags=["anoncreds"], + summary="Upload local tails file to server", +) +@match_info_schema(RevRegIdMatchInfoSchema()) +@response_schema(RevocationModuleResponseSchema(), description="") +async def set_active_registry(request: web.BaseRequest): + """ + Request handler to upload local tails file for revocation registry. + + Args: + request: aiohttp request object + + """ + context: AdminRequestContext = request["context"] + rev_reg_id = request.match_info["rev_reg_id"] + try: + revocation = AnonCredsRevocation(context.profile) + await revocation.set_active_registry(rev_reg_id) + except AnonCredsRevocationError as e: + raise web.HTTPInternalServerError(reason=str(e)) from e + + return web.json_response({}) + + +@docs( + tags=["anoncreds"], + summary="Revoke an issued credential", +) +@request_schema(RevokeRequestSchema()) +@response_schema(RevocationModuleResponseSchema(), description="") +async def revoke(request: web.BaseRequest): + """ + Request handler for storing a credential revocation. + + Args: + request: aiohttp request object + + Returns: + The credential revocation details. + + """ + context: AdminRequestContext = request["context"] + body = await request.json() + cred_ex_id = body.get("cred_ex_id") + body["notify"] = body.get("notify", context.settings.get("revocation.notify")) + notify = body.get("notify") + connection_id = body.get("connection_id") + body["notify_version"] = body.get("notify_version", "v1_0") + notify_version = body["notify_version"] + + if notify and not connection_id: + raise web.HTTPBadRequest(reason="connection_id must be set when notify is true") + if notify and not notify_version: + raise web.HTTPBadRequest( + reason="Request must specify notify_version if notify is true" + ) + + rev_manager = RevocationManager(context.profile) + try: + if cred_ex_id: + # rev_reg_id and cred_rev_id should not be present so we can + # safely splat the body + await rev_manager.revoke_credential_by_cred_ex_id(**body) + else: + # no cred_ex_id so we can safely splat the body + await rev_manager.revoke_credential(**body) + except ( + RevocationManagerError, + AnonCredsRevocationError, + StorageError, + AnonCredsIssuerError, + AnonCredsRegistrationError, + ) as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + + return web.json_response({}) + + +@docs(tags=["revocation"], summary="Publish pending revocations to ledger") +@request_schema(PublishRevocationsSchema()) +@response_schema(TxnOrPublishRevocationsResultSchema(), 200, description="") +async def publish_revocations(request: web.BaseRequest): + """ + Request handler for publishing pending revocations to the ledger. + + Args: + request: aiohttp request object + + Returns: + Credential revocation ids published as revoked by revocation registry id. + + """ + context: AdminRequestContext = request["context"] + body = await request.json() + rrid2crid = body.get("rrid2crid") + + rev_manager = RevocationManager(context.profile) + + try: + rev_reg_resp = await rev_manager.publish_pending_revocations( + rrid2crid, + ) + except ( + RevocationError, + StorageError, + AnonCredsIssuerError, + AnonCredsRevocationError, + ) as err: + raise web.HTTPBadRequest(reason=err.roll_up) from err + + return web.json_response({"rrid2crid": rev_reg_resp}) + + +async def register(app: web.Application): + """Register routes.""" + + app.add_routes( + [ + web.post("/anoncreds/schema", schemas_post), + web.get("/anoncreds/schema/{schemaId}", schema_get, allow_head=False), + web.get("/anoncreds/schemas", schemas_get, allow_head=False), + web.post("/anoncreds/credential-definition", cred_def_post), + web.get( + "/anoncreds/credential-definition/{cred_def_id}", + cred_def_get, + allow_head=False, + ), + web.get( + "/anoncreds/credential-definitions", + cred_defs_get, + allow_head=False, + ), + web.post("/anoncreds/revocation-registry-definition", rev_reg_def_post), + web.post("/anoncreds/revocation-list", rev_list_post), + web.put("/anoncreds/registry/{rev_reg_id}/tails-file", upload_tails_file), + web.put("/anoncreds/registry/{rev_reg_id}/active", set_active_registry), + web.post("/anoncreds/revoke", revoke), + web.post("/anoncreds/publish-revocations", publish_revocations), + ] + ) + + +def post_process_routes(app: web.Application): + """Amend swagger API.""" + + # Add top-level tags description + if "tags" not in app._state["swagger_dict"]: + app._state["swagger_dict"]["tags"] = [] + app._state["swagger_dict"]["tags"].append( + { + "name": "anoncreds", + "description": "Anoncreds management", + "externalDocs": {"description": "Specification", "url": SPEC_URI}, + } + ) diff --git a/aries_cloudagent/anoncreds/tests/__init__.py b/aries_cloudagent/anoncreds/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/anoncreds/tests/test_routes.py b/aries_cloudagent/anoncreds/tests/test_routes.py new file mode 100644 index 0000000000..a4416071fb --- /dev/null +++ b/aries_cloudagent/anoncreds/tests/test_routes.py @@ -0,0 +1,16 @@ +from asynctest import mock as async_mock, TestCase as AsyncTestCase +from .. import routes as test_module + + +class TestAnoncredsRoutes(AsyncTestCase): + async def test_register(self): + mock_app = async_mock.MagicMock() + mock_app.add_routes = async_mock.MagicMock() + + await test_module.register(mock_app) + mock_app.add_routes.assert_called_once() + + async def test_post_process_routes(self): + mock_app = async_mock.MagicMock(_state={"swagger_dict": {}}) + test_module.post_process_routes(mock_app) + assert "tags" in mock_app._state["swagger_dict"] diff --git a/aries_cloudagent/anoncreds/util.py b/aries_cloudagent/anoncreds/util.py new file mode 100644 index 0000000000..2c9a126c46 --- /dev/null +++ b/aries_cloudagent/anoncreds/util.py @@ -0,0 +1,39 @@ +"""Utilities for dealing with Indy conventions.""" + +from os import getenv, makedirs, urandom +from os.path import isdir, join +from pathlib import Path +from platform import system + + +async def generate_pr_nonce() -> str: + """Generate a nonce for a proof request.""" + # equivalent to indy.anoncreds.generate_nonce + return str(int.from_bytes(urandom(10), "big")) + + +def indy_client_dir(subpath: str = None, create: bool = False) -> str: + """ + Return '/'-terminated subdirectory of indy-client directory. + + Args: + subpath: subpath within indy-client structure + create: whether to create subdirectory if absent + """ + + home = Path.home() + target_dir = join( + home, + "Documents" + if isdir(join(home, "Documents")) + else getenv("EXTERNAL_STORAGE", "") + if system() == "Linux" + else "", + ".indy_client", + subpath if subpath else "", + "", # set trailing separator + ) + if create: + makedirs(target_dir, exist_ok=True) + + return target_dir diff --git a/aries_cloudagent/anoncreds/verifier.py b/aries_cloudagent/anoncreds/verifier.py new file mode 100644 index 0000000000..547be868bc --- /dev/null +++ b/aries_cloudagent/anoncreds/verifier.py @@ -0,0 +1,503 @@ +"""Indy-Credx verifier implementation.""" + +import asyncio +import logging +from enum import Enum +from time import time +from typing import List, Mapping, Tuple + +from anoncreds import AnoncredsError, Presentation + +from .registry import AnonCredsRegistry +from .models.anoncreds_cred_def import GetCredDefResult +from ..indy.models.xform import indy_proof_req2non_revoc_intervals +from ..core.profile import Profile +from ..messaging.util import canon, encode + +LOGGER = logging.getLogger(__name__) + + +class PresVerifyMsg(str, Enum): + """Credential verification codes.""" + + RMV_REFERENT_NON_REVOC_INTERVAL = "RMV_RFNT_NRI" + RMV_GLOBAL_NON_REVOC_INTERVAL = "RMV_GLB_NRI" + TSTMP_OUT_NON_REVOC_INTRVAL = "TS_OUT_NRI" + CT_UNREVEALED_ATTRIBUTES = "UNRVL_ATTR" + PRES_VALUE_ERROR = "VALUE_ERROR" + PRES_VERIFY_ERROR = "VERIFY_ERROR" + + +class AnonCredsVerifier: + """Verifier class.""" + + def __init__(self, profile: Profile): + """ + Initialize an AnonCredsVerifier instance. + + Args: + profile: an active profile instance + + """ + self.profile = profile + + def non_revoc_intervals(self, pres_req: dict, pres: dict, cred_defs: dict) -> list: + """ + Remove superfluous non-revocation intervals in presentation request. + + Irrevocable credentials constitute proof of non-revocation, but + indy rejects proof requests with non-revocation intervals lining up + with non-revocable credentials in proof: seek and remove. + + Args: + pres_req: presentation request + pres: corresponding presentation + + """ + msgs = [] + for req_proof_key, pres_key in { + "revealed_attrs": "requested_attributes", + "revealed_attr_groups": "requested_attributes", + "predicates": "requested_predicates", + }.items(): + for uuid, spec in pres["requested_proof"].get(req_proof_key, {}).items(): + if ( + "revocation" + not in cred_defs[ + pres["identifiers"][spec["sub_proof_index"]]["cred_def_id"] + ]["value"] + ): + if uuid in pres_req[pres_key] and pres_req[pres_key][uuid].pop( + "non_revoked", None + ): + msgs.append( + f"{PresVerifyMsg.RMV_REFERENT_NON_REVOC_INTERVAL.value}::" + f"{uuid}" + ) + LOGGER.info( + ( + "Amended presentation request (nonce=%s): removed " + "non-revocation interval at %s referent " + "%s; corresponding credential in proof is irrevocable" + ), + pres_req["nonce"], + pres_key, + uuid, + ) + + if all( + ( + spec.get("timestamp") is None + and "revocation" not in cred_defs[spec["cred_def_id"]]["value"] + ) + for spec in pres["identifiers"] + ): + pres_req.pop("non_revoked", None) + msgs.append(PresVerifyMsg.RMV_GLOBAL_NON_REVOC_INTERVAL.value) + LOGGER.warning( + ( + "Amended presentation request (nonce=%s); removed global " + "non-revocation interval; no revocable credentials in proof" + ), + pres_req["nonce"], + ) + return msgs + + async def check_timestamps( + self, + profile: Profile, + pres_req: Mapping, + pres: Mapping, + rev_reg_defs: Mapping, + ) -> list: + """ + Check for suspicious, missing, and superfluous timestamps. + + Raises ValueError on timestamp in the future, prior to rev reg creation, + superfluous or missing. + + Args: + profile: relevant profile + pres_req: indy proof request + pres: indy proof request + rev_reg_defs: rev reg defs by rev reg id, augmented with transaction times + """ + msgs = [] + now = int(time()) + non_revoc_intervals = indy_proof_req2non_revoc_intervals(pres_req) + LOGGER.debug(f">>> got non-revoc intervals: {non_revoc_intervals}") + + # timestamp for irrevocable credential + cred_defs: List[GetCredDefResult] = [] + for index, ident in enumerate(pres["identifiers"]): + LOGGER.debug(f">>> got (index, ident): ({index},{ident})") + cred_def_id = ident["cred_def_id"] + anoncreds_registry = profile.inject(AnonCredsRegistry) + cred_def_result = await anoncreds_registry.get_credential_definition( + profile, cred_def_id + ) + cred_defs.append(cred_def_result) + if ident.get("timestamp"): + if not cred_def_result.credential_definition.value.revocation: + raise ValueError( + f"Timestamp in presentation identifier #{index} " + f"for irrevocable cred def id {cred_def_id}" + ) + + # timestamp in the future too far in the past + for ident in pres["identifiers"]: + timestamp = ident.get("timestamp") + rev_reg_id = ident.get("rev_reg_id") + + if not timestamp: + continue + + if timestamp > now + 300: # allow 5 min for clock skew + raise ValueError(f"Timestamp {timestamp} is in the future") + reg_def = rev_reg_defs.get(rev_reg_id) + if not reg_def: + raise ValueError(f"Missing registry definition for '{rev_reg_id}'") + # TODO Generic anoncreds rev reg def does not include txn time or similar + # if "txnTime" not in reg_def: + # raise ValueError( + # f"Missing txnTime for registry definition '{rev_reg_id}'" + # ) + # if timestamp < reg_def["txnTime"]: + # raise ValueError( + # f"Timestamp {timestamp} predates rev reg {rev_reg_id} creation" + # ) + + # timestamp superfluous, missing, or outside non-revocation interval + revealed_attrs = pres["requested_proof"].get("revealed_attrs", {}) + unrevealed_attrs = pres["requested_proof"].get("unrevealed_attrs", {}) + revealed_groups = pres["requested_proof"].get("revealed_attr_groups", {}) + self_attested = pres["requested_proof"].get("self_attested_attrs", {}) + preds = pres["requested_proof"].get("predicates", {}) + for uuid, req_attr in pres_req["requested_attributes"].items(): + if "name" in req_attr: + if uuid in revealed_attrs: + index = revealed_attrs[uuid]["sub_proof_index"] + if cred_defs[index].credential_definition.value.revocation: + timestamp = pres["identifiers"][index].get("timestamp") + if (timestamp is not None) ^ bool( + non_revoc_intervals.get(uuid) + ): + LOGGER.debug(f">>> uuid: {uuid}") + LOGGER.debug( + f">>> revealed_attrs[uuid]: {revealed_attrs[uuid]}" + ) + raise ValueError( + f"Timestamp on sub-proof #{index} " + f"is {'superfluous' if timestamp else 'missing'} " + f"vs. requested attribute {uuid}" + ) + if non_revoc_intervals.get(uuid) and not ( + non_revoc_intervals[uuid].get("from", 0) + < timestamp + < non_revoc_intervals[uuid].get("to", now) + ): + msgs.append( + f"{PresVerifyMsg.TSTMP_OUT_NON_REVOC_INTRVAL.value}::" + f"{uuid}" + ) + LOGGER.info( + f"Timestamp {timestamp} from ledger for item" + f"{uuid} falls outside non-revocation interval " + f"{non_revoc_intervals[uuid]}" + ) + elif uuid in unrevealed_attrs: + # nothing to do, attribute value is not revealed + msgs.append( + f"{PresVerifyMsg.CT_UNREVEALED_ATTRIBUTES.value}::" f"{uuid}" + ) + elif uuid not in self_attested: + raise ValueError( + f"Presentation attributes mismatch requested attribute {uuid}" + ) + + elif "names" in req_attr: + group_spec = revealed_groups.get(uuid) + if ( + group_spec is None + or "sub_proof_index" not in group_spec + or "values" not in group_spec + ): + raise ValueError(f"Missing requested attribute group {uuid}") + index = group_spec["sub_proof_index"] + if cred_defs[index].credential_definition.value.revocation: + timestamp = pres["identifiers"][index].get("timestamp") + if (timestamp is not None) ^ bool(non_revoc_intervals.get(uuid)): + raise ValueError( + f"Timestamp on sub-proof #{index} " + f"is {'superfluous' if timestamp else 'missing'} " + f"vs. requested attribute group {uuid}" + ) + if non_revoc_intervals.get(uuid) and not ( + non_revoc_intervals[uuid].get("from", 0) + < timestamp + < non_revoc_intervals[uuid].get("to", now) + ): + msgs.append( + f"{PresVerifyMsg.TSTMP_OUT_NON_REVOC_INTRVAL.value}::" + f"{uuid}" + ) + LOGGER.warning( + f"Timestamp {timestamp} from ledger for item" + f"{uuid} falls outside non-revocation interval " + f"{non_revoc_intervals[uuid]}" + ) + + for uuid, req_pred in pres_req["requested_predicates"].items(): + pred_spec = preds.get(uuid) + if pred_spec is None or "sub_proof_index" not in pred_spec: + raise ValueError( + f"Presentation predicates mismatch requested predicate {uuid}" + ) + index = pred_spec["sub_proof_index"] + if cred_defs[index].credential_definition.value.revocation: + timestamp = pres["identifiers"][index].get("timestamp") + if (timestamp is not None) ^ bool(non_revoc_intervals.get(uuid)): + raise ValueError( + f"Timestamp on sub-proof #{index} " + f"is {'superfluous' if timestamp else 'missing'} " + f"vs. requested predicate {uuid}" + ) + if non_revoc_intervals.get(uuid) and not ( + non_revoc_intervals[uuid].get("from", 0) + < timestamp + < non_revoc_intervals[uuid].get("to", now) + ): + msgs.append( + f"{PresVerifyMsg.TSTMP_OUT_NON_REVOC_INTRVAL.value}::" f"{uuid}" + ) + LOGGER.warning( + f"Best-effort timestamp {timestamp} " + "from ledger falls outside non-revocation interval " + f"{non_revoc_intervals[uuid]}" + ) + return msgs + + async def pre_verify(self, pres_req: dict, pres: dict) -> list: + """ + Check for essential components and tampering in presentation. + + Visit encoded attribute values against raw, and predicate bounds, + in presentation, cross-reference against presentation request. + + Args: + pres_req: presentation request + pres: corresponding presentation + + """ + msgs = [] + if not ( + pres_req + and "requested_predicates" in pres_req + and "requested_attributes" in pres_req + ): + raise ValueError("Incomplete or missing proof request") + if not pres: + raise ValueError("No proof provided") + if "requested_proof" not in pres: + raise ValueError("Presentation missing 'requested_proof'") + if "proof" not in pres: + raise ValueError("Presentation missing 'proof'") + + for uuid, req_pred in pres_req["requested_predicates"].items(): + try: + canon_attr = canon(req_pred["name"]) + matched = False + found = False + for ge_proof in pres["proof"]["proofs"][ + pres["requested_proof"]["predicates"][uuid]["sub_proof_index"] + ]["primary_proof"]["ge_proofs"]: + pred = ge_proof["predicate"] + if pred["attr_name"] == canon_attr: + found = True + if pred["value"] == req_pred["p_value"]: + matched = True + break + if not matched: + raise ValueError(f"Predicate value != p_value: {pred['attr_name']}") + break + elif not found: + raise ValueError(f"Missing requested predicate '{uuid}'") + except (KeyError, TypeError): + raise ValueError(f"Missing requested predicate '{uuid}'") + + revealed_attrs = pres["requested_proof"].get("revealed_attrs", {}) + unrevealed_attrs = pres["requested_proof"].get("unrevealed_attrs", {}) + revealed_groups = pres["requested_proof"].get("revealed_attr_groups", {}) + self_attested = pres["requested_proof"].get("self_attested_attrs", {}) + for uuid, req_attr in pres_req["requested_attributes"].items(): + if "name" in req_attr: + if uuid in revealed_attrs: + pres_req_attr_spec = {req_attr["name"]: revealed_attrs[uuid]} + elif uuid in unrevealed_attrs: + # unrevealed attribute, nothing to do + pres_req_attr_spec = {} + msgs.append( + f"{PresVerifyMsg.CT_UNREVEALED_ATTRIBUTES.value}::" f"{uuid}" + ) + elif uuid in self_attested: + if not req_attr.get("restrictions"): + continue + raise ValueError( + "Attribute with restrictions cannot be self-attested: " + f"'{req_attr['name']}'" + ) + else: + raise ValueError( + f"Missing requested attribute '{req_attr['name']}'" + ) + elif "names" in req_attr: + group_spec = revealed_groups[uuid] + pres_req_attr_spec = { + attr: { + "sub_proof_index": group_spec["sub_proof_index"], + **group_spec["values"].get(attr), + } + for attr in req_attr["names"] + } + else: + raise ValueError( + f"Request attribute missing 'name' and 'names': '{uuid}'" + ) + + for attr, spec in pres_req_attr_spec.items(): + try: + primary_enco = pres["proof"]["proofs"][spec["sub_proof_index"]][ + "primary_proof" + ]["eq_proof"]["revealed_attrs"][canon(attr)] + except (KeyError, TypeError): + raise ValueError(f"Missing revealed attribute: '{attr}'") + if primary_enco != spec["encoded"]: + raise ValueError(f"Encoded representation mismatch for '{attr}'") + if primary_enco != encode(spec["raw"]): + raise ValueError(f"Encoded representation mismatch for '{attr}'") + return msgs + + async def process_pres_identifiers( + self, + identifiers: list, + ) -> Tuple[dict, dict, dict, dict]: + """Return schemas, cred_defs, rev_reg_defs, rev_lists.""" + schema_ids = [] + cred_def_ids = [] + + schemas = {} + cred_defs = {} + rev_reg_defs = {} + rev_lists = {} + + for identifier in identifiers: + schema_ids.append(identifier["schema_id"]) + cred_def_ids.append(identifier["cred_def_id"]) + + anoncreds_registry = self.profile.inject(AnonCredsRegistry) + # Build schemas for anoncreds + if identifier["schema_id"] not in schemas: + schemas[identifier["schema_id"]] = ( + await anoncreds_registry.get_schema( + self.profile, identifier["schema_id"] + ) + ).schema.serialize() + if identifier["cred_def_id"] not in cred_defs: + cred_defs[identifier["cred_def_id"]] = ( + await anoncreds_registry.get_credential_definition( + self.profile, identifier["cred_def_id"] + ) + ).credential_definition.serialize() + + if identifier.get("rev_reg_id"): + if identifier["rev_reg_id"] not in rev_reg_defs: + rev_reg_defs[identifier["rev_reg_id"]] = ( + await anoncreds_registry.get_revocation_registry_definition( + self.profile, identifier["rev_reg_id"] + ) + ).revocation_registry.serialize() + + if identifier.get("timestamp"): + rev_lists.setdefault(identifier["rev_reg_id"], {}) + + if ( + identifier["timestamp"] + not in rev_lists[identifier["rev_reg_id"]] + ): + result = await anoncreds_registry.get_revocation_list( + self.profile, + identifier["rev_reg_id"], + identifier["timestamp"], + ) + rev_lists[identifier["rev_reg_id"]][ + identifier["timestamp"] + ] = result.revocation_list.serialize() + return ( + schemas, + cred_defs, + rev_reg_defs, + rev_lists, + ) + + async def verify_presentation( + self, + pres_req, + pres, + schemas, + credential_definitions, + rev_reg_defs, + rev_lists, + ) -> Tuple[bool, list]: + """ + Verify a presentation. + + Args: + pres_req: Presentation request data + pres: Presentation data + schemas: Schema data + credential_definitions: credential definition data + rev_reg_defs: revocation registry definitions + rev_reg_entries: revocation registry entries + """ + + msgs = [] + try: + msgs += self.non_revoc_intervals(pres_req, pres, credential_definitions) + msgs += await self.check_timestamps( + self.profile, pres_req, pres, rev_reg_defs + ) + msgs += await self.pre_verify(pres_req, pres) + except ValueError as err: + s = str(err) + msgs.append(f"{PresVerifyMsg.PRES_VALUE_ERROR.value}::{s}") + LOGGER.error( + f"Presentation on nonce={pres_req['nonce']} " + f"cannot be validated: {str(err)}" + ) + return (False, msgs) + + try: + presentation = Presentation.load(pres) + verified = await asyncio.get_event_loop().run_in_executor( + None, + presentation.verify, + pres_req, + schemas, + credential_definitions, + rev_reg_defs, + [ + rev_list + for timestamp_to_list in rev_lists.values() + for rev_list in timestamp_to_list.values() + ], + ) + except AnoncredsError as err: + s = str(err) + msgs.append(f"{PresVerifyMsg.PRES_VERIFY_ERROR.value}::{s}") + LOGGER.exception( + f"Validation of presentation on nonce={pres_req['nonce']} " + "failed with error" + ) + verified = False + + return (verified, msgs) diff --git a/aries_cloudagent/askar/profile.py b/aries_cloudagent/askar/profile.py index a8cb10df71..721cdb3d62 100644 --- a/aries_cloudagent/askar/profile.py +++ b/aries_cloudagent/askar/profile.py @@ -16,9 +16,6 @@ from ..config.provider import ClassProvider from ..core.error import ProfileError from ..core.profile import Profile, ProfileManager, ProfileSession -from ..indy.holder import IndyHolder -from ..indy.issuer import IndyIssuer -from ..indy.verifier import IndyVerifier from ..ledger.base import BaseLedger from ..ledger.indy_vdr import IndyVdrLedger, IndyVdrLedgerPool from ..storage.base import BaseStorage, BaseStorageSearch @@ -99,20 +96,6 @@ def bind_providers(self): "aries_cloudagent.storage.askar.AskarStorageSearch", ref(self) ), ) - - injector.bind_provider( - IndyHolder, - ClassProvider( - "aries_cloudagent.indy.credx.holder.IndyCredxHolder", - ref(self), - ), - ) - injector.bind_provider( - IndyIssuer, - ClassProvider( - "aries_cloudagent.indy.credx.issuer.IndyCredxIssuer", ref(self) - ), - ) injector.bind_provider( VCHolder, ClassProvider( @@ -120,25 +103,16 @@ def bind_providers(self): ref(self), ), ) - if self.ledger_pool: injector.bind_provider( BaseLedger, ClassProvider(IndyVdrLedger, self.ledger_pool, ref(self)) ) - if self.ledger_pool or self.settings.get("ledger.ledger_config_list"): - injector.bind_provider( - IndyVerifier, - ClassProvider( - "aries_cloudagent.indy.credx.verifier.IndyCredxVerifier", - ref(self), - ), - ) - def session(self, context: InjectionContext = None) -> ProfileSession: + def session(self, context: InjectionContext = None) -> "AskarProfileSession": """Start a new interactive session with no transaction support requested.""" return AskarProfileSession(self, False, context=context) - def transaction(self, context: InjectionContext = None) -> ProfileSession: + def transaction(self, context: InjectionContext = None) -> "AskarProfileSession": """ Start a new interactive session with commit and rollback support. diff --git a/aries_cloudagent/config/default_context.py b/aries_cloudagent/config/default_context.py index 203aaac65b..173af589fd 100644 --- a/aries_cloudagent/config/default_context.py +++ b/aries_cloudagent/config/default_context.py @@ -1,5 +1,6 @@ """Classes for configuring the default injection context.""" +from ..anoncreds.registry import AnonCredsRegistry from ..cache.base import BaseCache from ..cache.in_memory import InMemoryCache from ..core.event_bus import EventBus @@ -55,6 +56,7 @@ async def build_context(self) -> InjectionContext: # Global did resolver context.injector.bind_instance(DIDResolver, DIDResolver([])) + context.injector.bind_instance(AnonCredsRegistry, AnonCredsRegistry()) context.injector.bind_instance(DIDMethods, DIDMethods()) context.injector.bind_instance(KeyTypes, KeyTypes()) context.injector.bind_instance( @@ -89,7 +91,7 @@ async def bind_providers(self, context: InjectionContext): context.injector.bind_provider( BaseTailsServer, ClassProvider( - "aries_cloudagent.tails.indy_tails_server.IndyTailsServer", + "aries_cloudagent.tails.anoncreds_tails_server.AnonCredsTailsServer", ), ) @@ -134,6 +136,12 @@ async def load_plugins(self, context: InjectionContext): plugin_registry.register_plugin("aries_cloudagent.revocation") plugin_registry.register_plugin("aries_cloudagent.resolver") plugin_registry.register_plugin("aries_cloudagent.wallet") + plugin_registry.register_plugin("aries_cloudagent.anoncreds") + plugin_registry.register_plugin("aries_cloudagent.anoncreds.default.did_indy") + plugin_registry.register_plugin("aries_cloudagent.anoncreds.default.did_web") + plugin_registry.register_plugin( + "aries_cloudagent.anoncreds.default.legacy_indy" + ) if context.settings.get("multitenant.admin_enabled"): plugin_registry.register_plugin("aries_cloudagent.multitenant.admin") diff --git a/aries_cloudagent/config/injection_context.py b/aries_cloudagent/config/injection_context.py index 9e5c5e051b..09279ad7b8 100644 --- a/aries_cloudagent/config/injection_context.py +++ b/aries_cloudagent/config/injection_context.py @@ -64,7 +64,7 @@ def update_settings(self, settings: Mapping[str, object]): self.injector.settings.update(settings) def start_scope( - self, scope_name: str, settings: Mapping[str, object] = None + self, scope_name: str, settings: Optional[Mapping[str, object]] = None ) -> "InjectionContext": """Begin a new named scope. diff --git a/aries_cloudagent/core/conductor.py b/aries_cloudagent/core/conductor.py index 8952ccb986..2371853063 100644 --- a/aries_cloudagent/core/conductor.py +++ b/aries_cloudagent/core/conductor.py @@ -33,7 +33,7 @@ upgrade, ) from ..core.profile import Profile -from ..indy.verifier import IndyVerifier +from ..anoncreds.verifier import AnonCredsVerifier from ..ledger.error import LedgerConfigError, LedgerTransactionError from ..ledger.multiple_ledger.base_manager import ( @@ -154,9 +154,9 @@ async def setup(self): and ledger.BACKEND_NAME == "indy-vdr" ): context.injector.bind_provider( - IndyVerifier, + AnonCredsVerifier, ClassProvider( - "aries_cloudagent.indy.credx.verifier.IndyCredxVerifier", + "aries_cloudagent.anoncreds.credx.verifier.IndyCredxVerifier", self.root_profile, ), ) @@ -165,9 +165,9 @@ async def setup(self): and ledger.BACKEND_NAME == "indy" ): context.injector.bind_provider( - IndyVerifier, + AnonCredsVerifier, ClassProvider( - "aries_cloudagent.indy.sdk.verifier.IndySdkVerifier", + "aries_cloudagent.anoncreds.sdk.verifier.IndySdkVerifier", self.root_profile, ), ) diff --git a/aries_cloudagent/ledger/base.py b/aries_cloudagent/ledger/base.py index 06bd362d95..5252082e94 100644 --- a/aries_cloudagent/ledger/base.py +++ b/aries_cloudagent/ledger/base.py @@ -7,13 +7,17 @@ from abc import ABC, abstractmethod, ABCMeta from enum import Enum from hashlib import sha256 -from typing import List, Sequence, Tuple, Union +from typing import List, Optional, Sequence, Tuple, Union -from ..indy.issuer import DEFAULT_CRED_DEF_TAG, IndyIssuer, IndyIssuerError from ..utils import sentinel from ..wallet.did_info import DIDInfo -from .error import BadLedgerRequestError, LedgerError, LedgerTransactionError +from .error import ( + BadLedgerRequestError, + LedgerError, + LedgerTransactionError, + LedgerObjectAlreadyExistsError, +) from .endpoint_type import EndpointType @@ -251,121 +255,97 @@ async def fetch_schema_by_seq_no(self, seq_no: int) -> dict: async def check_existing_schema( self, - public_did: str, - schema_name: str, - schema_version: str, + schema_id: str, attribute_names: Sequence[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) + schema = await self.fetch_schema_by_id(schema_id) if schema: fetched_attrs = schema["attrNames"].copy() - fetched_attrs.sort() - cmp_attrs = list(attribute_names) - cmp_attrs.sort() - if fetched_attrs != cmp_attrs: + if set(fetched_attrs) != set(attribute_names): raise LedgerTransactionError( "Schema already exists on ledger, but attributes do not match: " - + f"{schema_name}:{schema_version} {fetched_attrs} != {cmp_attrs}" + + f"{schema_id} {fetched_attrs} != {attribute_names}" ) - return fetch_schema_id, schema + return schema_id, schema - async def create_and_send_schema( + async def send_schema( self, - issuer: IndyIssuer, - schema_name: str, - schema_version: str, - attribute_names: Sequence[str], + schema_id: str, + schema_def: dict, write_ledger: bool = True, - endorser_did: str = None, - ) -> Tuple[str, dict]: - """ - Send schema to ledger. - - Args: - issuer: The issuer instance to use for schema creation - schema_name: The schema name - schema_version: The schema version - attribute_names: A list of schema attributes - - """ - + endorser_did: Optional[str] = None, + ) -> int: + """Send an already created schema to the ledger.""" public_info = await self.get_wallet_public_did() if not public_info: raise BadLedgerRequestError("Cannot publish schema without a public DID") schema_info = await self.check_existing_schema( - public_info.did, schema_name, schema_version, attribute_names + schema_id, schema_def["attrNames"] ) + if schema_info: - LOGGER.warning("Schema already exists on ledger. Returning details.") - schema_id, schema_def = schema_info - else: - if await self.is_ledger_read_only(): - raise LedgerError( - "Error cannot write schema when ledger is in read only mode" - ) + raise LedgerObjectAlreadyExistsError( + "Schema already exists on ledger", *schema_info + ) - try: - schema_id, schema_json = await issuer.create_schema( - public_info.did, - schema_name, - schema_version, - attribute_names, - ) - except IndyIssuerError as err: - raise LedgerError(err.message) from err - schema_def = json.loads(schema_json) + if await self.is_ledger_read_only(): + raise LedgerError( + "Error cannot write schema when ledger is in read only mode" + ) - schema_req = await self._create_schema_request( - public_info, - schema_json, + schema_req = await self._create_schema_request( + public_info, + json.dumps(schema_def), + write_ledger=write_ledger, + endorser_did=endorser_did, + ) + + try: + resp = await self.txn_submit( + schema_req, + sign=True, + sign_did=public_info, write_ledger=write_ledger, - endorser_did=endorser_did, ) + # TODO Clean this up + # if not write_ledger: + # return schema_id, {"signed_txn": resp} + try: - resp = await self.txn_submit( - schema_req, - sign=True, - sign_did=public_info, - write_ledger=write_ledger, + # parse sequence number out of response + seq_no = json.loads(resp)["result"]["txnMetadata"]["seqNo"] + return seq_no + except KeyError as err: + raise LedgerError( + "Failed to parse schema sequence number from ledger response" + ) from err + except LedgerTransactionError as e: + # Identify possible duplicate schema errors on indy-node < 1.9 and > 1.9 + if ( + "can have one and only one SCHEMA with name" in e.message + or "UnauthorizedClientRequest" in e.message + ): + # handle potential race condition if multiple agents are publishing + # the same schema simultaneously + schema_info = await self.check_existing_schema( + schema_id, schema_def["attrNames"] ) - - if not write_ledger: - return schema_id, {"signed_txn": resp} - - try: - # parse sequence number out of response - seq_no = json.loads(resp)["result"]["txnMetadata"]["seqNo"] - schema_def["seqNo"] = seq_no - except KeyError as err: - raise LedgerError( - "Failed to parse schema sequence number from ledger response" - ) from err - except LedgerTransactionError as e: - # Identify possible duplicate schema errors on indy-node < 1.9 and > 1.9 - if ( - "can have one and only one SCHEMA with name" in e.message - or "UnauthorizedClientRequest" in e.message - ): - # handle potential race condition if multiple agents are publishing - # the same schema simultaneously - schema_info = await self.check_existing_schema( - public_info.did, schema_name, schema_version, attribute_names + if schema_info: + LOGGER.warning( + "Schema already exists on ledger. Returning details." + " Error: %s", + e, + ) + raise LedgerObjectAlreadyExistsError( + f"Schema already exists on ledger (Error: {e})", *schema_info ) - if schema_info: - LOGGER.warning( - "Schema already exists on ledger. Returning details." - " Error: %s", - e, - ) - schema_id, schema_def = schema_info else: raise - - return schema_id, schema_def + else: + raise @abstractmethod async def _create_schema_request( @@ -403,16 +383,14 @@ async def send_revoc_reg_entry( ) -> dict: """Publish a revocation registry entry to the ledger.""" - async def create_and_send_credential_definition( + async def send_credential_definition( self, - issuer: IndyIssuer, schema_id: str, - signature_type: str = None, - tag: str = None, - support_revocation: bool = False, + cred_def_id: str, + cred_def: dict, write_ledger: bool = True, endorser_did: str = None, - ) -> Tuple[str, dict, bool]: + ) -> int: """ Send credential definition to ledger and store relevant key matter in wallet. @@ -435,87 +413,41 @@ async def create_and_send_credential_definition( schema = await self.get_schema(schema_id) if not schema: - raise LedgerError(f"Ledger {self.pool_name} has no schema {schema_id}") - - novel = False + 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_CRED_DEF_TAG]: - credential_definition_id = issuer.make_credential_definition_id( - public_info.did, schema, signature_type, test_tag - ) - ledger_cred_def = await self.fetch_credential_definition( - credential_definition_id + ledger_cred_def = await self.fetch_credential_definition(cred_def_id) + if ledger_cred_def: + credential_definition_json = json.dumps(ledger_cred_def) + raise LedgerObjectAlreadyExistsError( + f"Credential definition with id {cred_def_id} " + "already exists in wallet and on ledger.", + cred_def_id, + credential_definition_json, ) - if ledger_cred_def: - LOGGER.warning( - "Credential definition %s already exists on ledger %s", - credential_definition_id, - self.pool_name, - ) - try: - if not await issuer.credential_definition_in_wallet( - credential_definition_id - ): - raise LedgerError( - f"Credential definition {credential_definition_id} is on " - f"ledger {self.pool_name} but not in wallet " - f"{self.profile.name}" - ) - except IndyIssuerError as err: - raise LedgerError(err.message) from err - - credential_definition_json = json.dumps(ledger_cred_def) - break - else: # no such cred def on ledger - try: - if await issuer.credential_definition_in_wallet( - credential_definition_id - ): - raise LedgerError( - f"Credential definition {credential_definition_id} is in " - f"wallet {self.profile.name} but not on ledger " - f"{self.pool.name}" - ) - except IndyIssuerError as err: - raise LedgerError(err.message) from err - - # Cred def is neither on ledger nor in wallet: create and send it - novel = True - try: - ( - credential_definition_id, - credential_definition_json, - ) = await issuer.create_and_store_credential_definition( - public_info.did, - schema, - signature_type, - tag, - support_revocation, - ) - except IndyIssuerError as err: - raise LedgerError(err.message) from err + if await self.is_ledger_read_only(): + raise LedgerError( + "Error cannot write cred def when ledger is in read only mode" + ) - if await self.is_ledger_read_only(): - raise LedgerError( - "Error cannot write cred def when ledger is in read only mode" - ) + cred_def_req = await self._create_credential_definition_request( + public_info, + json.dumps(cred_def), + write_ledger=write_ledger, + endorser_did=endorser_did, + ) - cred_def_req = await self._create_credential_definition_request( - public_info, - credential_definition_json, - write_ledger=write_ledger, - endorser_did=endorser_did, - ) + resp = await self.txn_submit( + cred_def_req, True, sign_did=public_info, write_ledger=write_ledger + ) - resp = await self.txn_submit( - cred_def_req, True, sign_did=public_info, write_ledger=write_ledger - ) - if not write_ledger: - return (credential_definition_id, {"signed_txn": resp}, novel) + # TODO Clean up + # if not write_ledger: + # return (credential_definition_id, {"signed_txn": resp}, novel) - return (credential_definition_id, json.loads(credential_definition_json), novel) + seq_no = json.loads(resp)["result"]["txnMetadata"]["seqNo"] + return seq_no @abstractmethod async def _create_credential_definition_request( diff --git a/aries_cloudagent/ledger/error.py b/aries_cloudagent/ledger/error.py index 700ed89dc1..b7dd025236 100644 --- a/aries_cloudagent/ledger/error.py +++ b/aries_cloudagent/ledger/error.py @@ -1,5 +1,6 @@ """Ledger related errors.""" +from typing import Generic, TypeVar from ..core.error import BaseError @@ -21,3 +22,27 @@ class ClosedPoolError(LedgerError): class LedgerTransactionError(LedgerError): """The ledger rejected the transaction.""" + + +T = TypeVar("T") + + +class LedgerObjectAlreadyExistsError(LedgerError, Generic[T]): + """Raised when a ledger object already existed.""" + + def __init__( + self, + message: str, + obj_id: str, + obj: T = None, + *args, + **kwargs, + ): + super().__init__(message, obj_id, obj, *args, **kwargs) + self._message = message + self.obj_id = obj_id + self.obj = obj + + @property + def message(self): + return f"{self._message}: {self.obj_id}, {self.obj}" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py index 7ef901260f..9e549608dc 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py @@ -1,22 +1,25 @@ """V2.0 issue-credential indy credential format handler.""" +import json import logging +from typing import Mapping, Tuple from marshmallow import RAISE -import json -from typing import Mapping, Tuple -import asyncio -from ......cache.base import BaseCache -from ......indy.issuer import IndyIssuer, IndyIssuerRevocationRegistryFullError -from ......indy.holder import IndyHolder, IndyHolderError +from ......anoncreds.revocation import AnonCredsRevocation + +from ......anoncreds.registry import AnonCredsRegistry +from ......anoncreds.holder import AnonCredsHolder, AnonCredsHolderError +from ......anoncreds.issuer import ( + AnonCredsIssuer, +) from ......indy.models.cred import IndyCredentialSchema -from ......indy.models.cred_request import IndyCredRequestSchema from ......indy.models.cred_abstract import IndyCredAbstractSchema +from ......indy.models.cred_request import IndyCredRequestSchema +from ......cache.base import BaseCache from ......ledger.base import BaseLedger from ......ledger.multiple_ledger.ledger_requests_executor import ( GET_CRED_DEF, - GET_SCHEMA, IndyLedgerRequestsExecutor, ) from ......messaging.credential_definitions.util import ( @@ -25,11 +28,8 @@ ) from ......messaging.decorators.attach_decorator import AttachDecorator from ......multitenant.base import BaseMultitenantManager -from ......revocation.indy import IndyRevocation from ......revocation.models.issuer_cred_rev_record import IssuerCredRevRecord -from ......revocation.models.revocation_registry import RevocationRegistry from ......storage.base import BaseStorage - from ...message_types import ( ATTACHMENT_FORMAT, CRED_20_ISSUE, @@ -38,13 +38,12 @@ CRED_20_REQUEST, ) from ...messages.cred_format import V20CredFormat -from ...messages.cred_proposal import V20CredProposal +from ...messages.cred_issue import V20CredIssue from ...messages.cred_offer import V20CredOffer +from ...messages.cred_proposal import V20CredProposal from ...messages.cred_request import V20CredRequest -from ...messages.cred_issue import V20CredIssue from ...models.cred_ex_record import V20CredExRecord from ...models.detail.indy import V20CredExRecordIndy - from ..handler import CredFormatAttachment, V20CredFormatError, V20CredFormatHandler LOGGER = logging.getLogger(__name__) @@ -188,12 +187,12 @@ async def create_offer( ) -> CredFormatAttachment: """Create indy credential offer.""" - issuer = self.profile.inject(IndyIssuer) + issuer = AnonCredsIssuer(self.profile) ledger = self.profile.inject(BaseLedger) cache = self.profile.inject_or(BaseCache) - cred_def_id = await self._match_sent_cred_def_id( - cred_proposal_message.attachment(IndyCredFormatHandler.format) + cred_def_id = await issuer.match_created_credential_definitions( + **cred_proposal_message.attachment(IndyCredFormatHandler.format) ) async def _create(): @@ -265,23 +264,15 @@ async def create_request( cred_def_id = cred_offer["cred_def_id"] async def _create(): - multitenant_mgr = self.profile.inject_or(BaseMultitenantManager) - if multitenant_mgr: - ledger_exec_inst = IndyLedgerRequestsExecutor(self.profile) - else: - ledger_exec_inst = self.profile.inject(IndyLedgerRequestsExecutor) - ledger = ( - await ledger_exec_inst.get_ledger_for_identifier( - cred_def_id, - txn_record_type=GET_CRED_DEF, - ) - )[1] - async with ledger: - cred_def = await ledger.get_credential_definition(cred_def_id) + anoncreds_registry = self.profile.inject(AnonCredsRegistry) + + cred_def_result = await anoncreds_registry.get_credential_definition( + self.profile, cred_def_id + ) - holder = self.profile.inject(IndyHolder) + holder = AnonCredsHolder(self.profile) request_json, metadata_json = await holder.create_credential_request( - cred_offer, cred_def, holder_did + cred_offer, cred_def_result.credential_definition, holder_did ) return { @@ -334,95 +325,44 @@ async def issue_credential( cred_values = cred_ex_record.cred_offer.credential_preview.attr_dict( decode=False ) - schema_id = cred_offer["schema_id"] - cred_def_id = cred_offer["cred_def_id"] - issuer = self.profile.inject(IndyIssuer) - multitenant_mgr = self.profile.inject_or(BaseMultitenantManager) - if multitenant_mgr: - ledger_exec_inst = IndyLedgerRequestsExecutor(self.profile) + issuer = AnonCredsIssuer(self.profile) + cred_def_id = cred_offer["cred_def_id"] + if await issuer.cred_def_supports_revocation(cred_def_id): + revocation = AnonCredsRevocation(self.profile) + cred_json, cred_rev_id, rev_reg_def_id = await revocation.create_credential( + cred_offer, cred_request, cred_values + ) else: - ledger_exec_inst = self.profile.inject(IndyLedgerRequestsExecutor) - ledger = ( - await ledger_exec_inst.get_ledger_for_identifier( - schema_id, - txn_record_type=GET_SCHEMA, + cred_json = await issuer.create_credential( + cred_offer, cred_request, cred_values ) - )[1] - async with ledger: - schema = await ledger.get_schema(schema_id) - cred_def = await ledger.get_credential_definition(cred_def_id) - revocable = cred_def["value"].get("revocation") - result = None - - for attempt in range(max(retries, 1)): - if attempt > 0: - LOGGER.info( - "Waiting 2s before retrying credential issuance for cred def '%s'", - cred_def_id, - ) - await asyncio.sleep(2) - - if revocable: - revoc = IndyRevocation(self.profile) - registry_info = await revoc.get_or_create_active_registry(cred_def_id) - if not registry_info: - continue - del revoc - issuer_rev_reg, rev_reg = registry_info - rev_reg_id = issuer_rev_reg.revoc_reg_id - tails_path = rev_reg.tails_local_path - else: - rev_reg_id = None - tails_path = None - - try: - (cred_json, cred_rev_id) = await issuer.create_credential( - schema, - cred_offer, - cred_request, - cred_values, - rev_reg_id, - tails_path, - ) - except IndyIssuerRevocationRegistryFullError: - # unlucky, another instance filled the registry first - continue - - if revocable and rev_reg.max_creds <= int(cred_rev_id): - revoc = IndyRevocation(self.profile) - await revoc.handle_full_registry(rev_reg_id) - del revoc + cred_rev_id = None + rev_reg_def_id = None - result = self.get_format_data(CRED_20_ISSUE, json.loads(cred_json)) - break - - if not result: - raise V20CredFormatError( - f"Cred def '{cred_def_id}' has no active revocation registry" - ) + result = self.get_format_data(CRED_20_ISSUE, json.loads(cred_json)) async with self._profile.transaction() as txn: detail_record = V20CredExRecordIndy( cred_ex_id=cred_ex_record.cred_ex_id, - rev_reg_id=rev_reg_id, + rev_reg_id=rev_reg_def_id, cred_rev_id=cred_rev_id, ) await detail_record.save(txn, reason="v2.0 issue credential") - if revocable and cred_rev_id: + if cred_rev_id: issuer_cr_rec = IssuerCredRevRecord( state=IssuerCredRevRecord.STATE_ISSUED, cred_ex_id=cred_ex_record.cred_ex_id, cred_ex_version=IssuerCredRevRecord.VERSION_2, - rev_reg_id=rev_reg_id, + rev_reg_id=rev_reg_def_id, cred_rev_id=cred_rev_id, ) await issuer_cr_rec.save( txn, reason=( "Created issuer cred rev record for " - f"rev reg id {rev_reg_id}, index {cred_rev_id}" + f"rev reg id {rev_reg_def_id}, index {cred_rev_id}" ), ) await txn.commit() @@ -444,31 +384,27 @@ async def store_credential( cred = cred_ex_record.cred_issue.attachment(IndyCredFormatHandler.format) rev_reg_def = None - multitenant_mgr = self.profile.inject_or(BaseMultitenantManager) - if multitenant_mgr: - ledger_exec_inst = IndyLedgerRequestsExecutor(self.profile) - else: - ledger_exec_inst = self.profile.inject(IndyLedgerRequestsExecutor) - ledger = ( - await ledger_exec_inst.get_ledger_for_identifier( - cred["cred_def_id"], - txn_record_type=GET_CRED_DEF, + anoncreds_registry = self.profile.inject(AnonCredsRegistry) + cred_def_result = await anoncreds_registry.get_credential_definition( + self.profile, cred["cred_def_id"] + ) + if cred.get("rev_reg_id"): + rev_reg_def_result = ( + await anoncreds_registry.get_revocation_registry_definition( + self.profile, cred["rev_reg_id"] + ) ) - )[1] - async with ledger: - cred_def = await ledger.get_credential_definition(cred["cred_def_id"]) - if cred.get("rev_reg_id"): - rev_reg_def = await ledger.get_revoc_reg_def(cred["rev_reg_id"]) + rev_reg_def = rev_reg_def_result.revocation_registry - holder = self.profile.inject(IndyHolder) + holder = AnonCredsHolder(self.profile) cred_offer_message = cred_ex_record.cred_offer mime_types = None if cred_offer_message and cred_offer_message.credential_preview: mime_types = cred_offer_message.credential_preview.mime_types() or None if rev_reg_def: - rev_reg = RevocationRegistry.from_definition(rev_reg_def, True) - await rev_reg.get_or_fetch_local_tails_path() + revocation = AnonCredsRevocation(self.profile) + await revocation.get_or_fetch_local_tails_path(rev_reg_def) try: detail_record = await self.get_detail_record(cred_ex_record.cred_ex_id) if detail_record is None: @@ -477,12 +413,12 @@ async def store_credential( f"detail record found for cred ex id {cred_ex_record.cred_ex_id}" ) cred_id_stored = await holder.store_credential( - cred_def, + cred_def_result.credential_definition.serialize(), cred, detail_record.cred_request_metadata, mime_types, credential_id=cred_id, - rev_reg_def=rev_reg_def, + rev_reg_def=rev_reg_def.serialize() if rev_reg_def else None, ) detail_record.cred_id_stored = cred_id_stored @@ -494,6 +430,6 @@ async def store_credential( await detail_record.save( session, reason="store credential v2.0", event=True ) - except IndyHolderError as e: + except AnonCredsHolderError as e: LOGGER.error(f"Error storing credential: {e.error_code} - {e.message}") raise e diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_handler.py index 7366437304..78798ae03f 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_handler.py @@ -1,48 +1,45 @@ import asyncio from copy import deepcopy -from time import time import json +from time import time + from asynctest import TestCase as AsyncTestCase from asynctest import mock as async_mock from marshmallow import ValidationError from .. import handler as test_module - +from .......anoncreds.holder import AnonCredsHolder +from .......anoncreds.issuer import AnonCredsIssuer +from .......anoncreds.revocation import AnonCredsRevocationRegistryFullError +from .......cache.base import BaseCache +from .......cache.in_memory import InMemoryCache from .......core.in_memory import InMemoryProfile from .......ledger.base import BaseLedger from .......ledger.multiple_ledger.ledger_requests_executor import ( IndyLedgerRequestsExecutor, ) +from .......messaging.credential_definitions.util import CRED_DEF_SENT_RECORD_TYPE +from .......messaging.decorators.attach_decorator import AttachDecorator from .......multitenant.base import BaseMultitenantManager from .......multitenant.manager import MultitenantManager -from .......indy.issuer import IndyIssuer -from .......cache.in_memory import InMemoryCache -from .......cache.base import BaseCache -from .......storage.record import StorageRecord from .......storage.error import StorageNotFoundError -from .......messaging.credential_definitions.util import CRED_DEF_SENT_RECORD_TYPE -from .......messaging.decorators.attach_decorator import AttachDecorator -from .......indy.holder import IndyHolder -from ....models.detail.indy import V20CredExRecordIndy -from ....messages.cred_proposal import V20CredProposal -from ....messages.cred_format import V20CredFormat -from ....messages.cred_issue import V20CredIssue -from ....messages.inner.cred_preview import V20CredPreview, V20CredAttrSpec -from ....messages.cred_offer import V20CredOffer -from ....messages.cred_request import ( - V20CredRequest, -) -from ....models.cred_ex_record import V20CredExRecord +from .......storage.record import StorageRecord from ....message_types import ( ATTACHMENT_FORMAT, - CRED_20_PROPOSAL, + CRED_20_ISSUE, CRED_20_OFFER, + CRED_20_PROPOSAL, CRED_20_REQUEST, - CRED_20_ISSUE, ) - +from ....messages.cred_format import V20CredFormat +from ....messages.cred_issue import V20CredIssue +from ....messages.cred_offer import V20CredOffer +from ....messages.cred_proposal import V20CredProposal +from ....messages.cred_request import V20CredRequest +from ....messages.inner.cred_preview import V20CredAttrSpec, V20CredPreview +from ....models.cred_ex_record import V20CredExRecord +from ....models.detail.indy import V20CredExRecordIndy from ...handler import LOGGER, V20CredFormatError - from ..handler import IndyCredFormatHandler from ..handler import LOGGER as INDY_LOGGER @@ -232,12 +229,12 @@ async def setUp(self): self.context.injector.bind_instance(BaseCache, self.cache) # Issuer - self.issuer = async_mock.MagicMock(IndyIssuer, autospec=True) - self.context.injector.bind_instance(IndyIssuer, self.issuer) + self.issuer = async_mock.MagicMock(AnonCredsIssuer, autospec=True) + self.context.injector.bind_instance(AnonCredsIssuer, self.issuer) # Holder - self.holder = async_mock.MagicMock(IndyHolder, autospec=True) - self.context.injector.bind_instance(IndyHolder, self.holder) + self.holder = async_mock.MagicMock(AnonCredsHolder, autospec=True) + self.context.injector.bind_instance(AnonCredsHolder, self.holder) self.handler = IndyCredFormatHandler(self.profile) assert self.handler.profile @@ -1043,7 +1040,7 @@ async def test_issue_credential_rr_full(self): ) self.issuer.create_credential = async_mock.CoroutineMock( - side_effect=test_module.IndyIssuerRevocationRegistryFullError("Nope") + side_effect=AnonCredsRevocationRegistryFullError("Nope") ) with async_mock.patch.object( test_module, "IndyRevocation", autospec=True @@ -1262,7 +1259,7 @@ async def test_store_credential_holder_store_indy_error(self): cred_id = "cred-id" self.holder.store_credential = async_mock.CoroutineMock( - side_effect=test_module.IndyHolderError("Problem", {"message": "Nope"}) + side_effect=test_module.AnonCredsHolderError("Problem", {"message": "Nope"}) ) with async_mock.patch.object( @@ -1277,6 +1274,6 @@ async def test_store_credential_holder_store_indy_error(self): mock_rev_reg.return_value = async_mock.MagicMock( get_or_fetch_local_tails_path=async_mock.CoroutineMock() ) - with self.assertRaises(test_module.IndyHolderError) as context: + with self.assertRaises(test_module.AnonCredsHolderError) as context: await self.handler.store_credential(stored_cx_rec, cred_id) assert "Nope" in str(context.exception) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_issue_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_issue_handler.py index ee148857f1..8e88fa4c9f 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_issue_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_issue_handler.py @@ -1,7 +1,7 @@ """Credential issue message handler.""" from .....core.oob_processor import OobMessageProcessor -from .....indy.holder import IndyHolderError +from .....anoncreds.holder import AnonCredsHolderError from .....messaging.base_handler import BaseHandler, HandlerException from .....messaging.models.base import BaseModelError from .....messaging.request_context import RequestContext @@ -71,7 +71,7 @@ async def handle(self, context: RequestContext, responder: BaseResponder): cred_ex_record = await cred_manager.store_credential(cred_ex_record) except ( BaseModelError, - IndyHolderError, + AnonCredsHolderError, StorageError, V20CredManagerError, ) as err: diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_offer_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_offer_handler.py index 9fc223c4c0..1b1e0e532c 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_offer_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_offer_handler.py @@ -2,7 +2,7 @@ from .....wallet.util import default_did_from_verkey from .....core.oob_processor import OobMessageProcessor -from .....indy.holder import IndyHolderError +from .....anoncreds.holder import AnonCredsHolderError from .....ledger.error import LedgerError from .....messaging.base_handler import BaseHandler, HandlerException from .....messaging.models.base import BaseModelError @@ -89,7 +89,7 @@ async def handle(self, context: RequestContext, responder: BaseResponder): await responder.send_reply(cred_request_message) except ( BaseModelError, - IndyHolderError, + AnonCredsHolderError, LedgerError, StorageError, V20CredManagerError, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_proposal_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_proposal_handler.py index e59e9a36d2..4dc7dafdcd 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_proposal_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_proposal_handler.py @@ -1,6 +1,6 @@ """Credential proposal message handler.""" -from .....indy.issuer import IndyIssuerError +from .....anoncreds.issuer import AnonCredsIssuerError from .....ledger.error import LedgerError from .....messaging.base_handler import BaseHandler, HandlerException from .....messaging.models.base import BaseModelError @@ -69,7 +69,7 @@ async def handle(self, context: RequestContext, responder: BaseResponder): await responder.send_reply(cred_offer_message) except ( BaseModelError, - IndyIssuerError, + AnonCredsIssuerError, LedgerError, StorageError, V20CredManagerError, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py index 1bdf7671e9..af1ab58956 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py @@ -1,7 +1,7 @@ """Credential request message handler.""" from .....core.oob_processor import OobMessageProcessor -from .....indy.issuer import IndyIssuerError +from .....anoncreds.issuer import AnonCredsIssuerError from .....ledger.error import LedgerError from .....messaging.base_handler import BaseHandler, HandlerException from .....messaging.models.base import BaseModelError @@ -80,7 +80,7 @@ async def handle(self, context: RequestContext, responder: BaseResponder): await responder.send_reply(cred_issue_message) except ( BaseModelError, - IndyIssuerError, + AnonCredsIssuerError, LedgerError, StorageError, V20CredManagerError, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_issue_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_issue_handler.py index 2a47af1d5d..96d3c3793e 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_issue_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_issue_handler.py @@ -103,7 +103,7 @@ async def test_called_auto_store_x(self): ), store_credential=async_mock.CoroutineMock( side_effect=[ - test_module.IndyHolderError, + test_module.AnonCredsHolderError, test_module.StorageError(), ] ), diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_offer_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_offer_handler.py index 66e295ce34..58687b3def 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_offer_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_offer_handler.py @@ -106,7 +106,7 @@ async def test_called_auto_request_x(self): ) ) mock_cred_mgr.return_value.create_request = async_mock.CoroutineMock( - side_effect=test_module.IndyHolderError() + side_effect=test_module.AnonCredsHolderError() ) request_context.message = V20CredOffer() diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_proposal_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_proposal_handler.py index daaf4c0d79..2f7766d360 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_proposal_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_proposal_handler.py @@ -80,7 +80,7 @@ async def test_called_auto_offer_x(self): ) mock_cred_mgr.return_value.receive_proposal.return_value.auto_offer = True mock_cred_mgr.return_value.create_offer = async_mock.CoroutineMock( - side_effect=test_module.IndyIssuerError() + side_effect=test_module.AnonCredsIssuerError() ) request_context.message = V20CredProposal() diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_request_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_request_handler.py index 25edba8b36..510c2a9098 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_request_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/tests/test_cred_request_handler.py @@ -115,7 +115,7 @@ async def test_called_auto_issue_x(self): ) mock_cred_mgr.return_value.receive_request.return_value.auto_issue = True mock_cred_mgr.return_value.issue_credential = async_mock.CoroutineMock( - side_effect=test_module.IndyIssuerError() + side_effect=test_module.AnonCredsIssuerError() ) request_context.message = V20CredRequest() diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py index 129b98250f..2b5078816d 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py @@ -20,8 +20,8 @@ from ....admin.request_context import AdminRequestContext from ....connections.models.conn_record import ConnRecord from ....core.profile import Profile -from ....indy.holder import IndyHolderError -from ....indy.issuer import IndyIssuerError +from ....anoncreds.holder import AnonCredsHolderError +from ....anoncreds.issuer import AnonCredsIssuerError from ....ledger.error import LedgerError from ....messaging.decorators.attach_decorator import AttachDecorator from ....messaging.models.base import BaseModelError @@ -976,7 +976,7 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): except ( BaseModelError, - IndyIssuerError, + AnonCredsIssuerError, LedgerError, StorageNotFoundError, V20CredFormatError, @@ -1081,7 +1081,7 @@ async def credential_exchange_send_bound_offer(request: web.BaseRequest): except ( BaseModelError, - IndyIssuerError, + AnonCredsIssuerError, LedgerError, StorageError, V20CredFormatError, @@ -1187,7 +1187,7 @@ async def credential_exchange_send_free_request(request: web.BaseRequest): except ( BaseModelError, - IndyHolderError, + AnonCredsHolderError, LedgerError, StorageError, V20CredManagerError, @@ -1296,7 +1296,7 @@ async def credential_exchange_send_bound_request(request: web.BaseRequest): except ( BaseModelError, - IndyHolderError, + AnonCredsHolderError, LedgerError, StorageError, V20CredFormatError, @@ -1390,7 +1390,7 @@ async def credential_exchange_issue(request: web.BaseRequest): except ( BaseModelError, - IndyIssuerError, + AnonCredsIssuerError, LedgerError, StorageError, V20CredFormatError, @@ -1479,7 +1479,7 @@ async def credential_exchange_store(request: web.BaseRequest): cred_ex_record = await cred_manager.store_credential(cred_ex_record, cred_id) except ( - IndyHolderError, + AnonCredsHolderError, StorageError, V20CredManagerError, ) as err: # treat failure to store as mangled on receipt hence protocol error diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py index 55d654c5f1..45b42b47be 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py @@ -9,7 +9,7 @@ from .....cache.base import BaseCache from .....cache.in_memory import InMemoryCache from .....core.in_memory import InMemoryProfile -from .....indy.issuer import IndyIssuer +from .....anoncreds.issuer import AnonCredsIssuer from .....messaging.decorators.thread_decorator import ThreadDecorator from .....messaging.decorators.attach_decorator import AttachDecorator from .....messaging.responder import BaseResponder, MockResponder @@ -973,7 +973,7 @@ async def test_issue_credential(self): issuer.create_credential = async_mock.CoroutineMock( return_value=(json.dumps(INDY_CRED), cred_rev_id) ) - self.context.injector.bind_instance(IndyIssuer, issuer) + self.context.injector.bind_instance(AnonCredsIssuer, issuer) with async_mock.patch.object( V20CredExRecord, "save", autospec=True diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py index 845f21555c..54d1909d88 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py @@ -1340,7 +1340,7 @@ async def test_credential_exchange_issue_rev_reg_full(self): mock_conn_rec.retrieve_by_id.return_value.is_ready = True mock_issue_cred = async_mock.CoroutineMock( - side_effect=test_module.IndyIssuerError() + side_effect=test_module.AnonCredsIssuerError() ) mock_cred_mgr.return_value.issue_credential = mock_issue_cred diff --git a/aries_cloudagent/protocols/present_proof/indy/pres_exch_handler.py b/aries_cloudagent/protocols/present_proof/indy/pres_exch_handler.py index 4bff88cc89..b6f447b866 100644 --- a/aries_cloudagent/protocols/present_proof/indy/pres_exch_handler.py +++ b/aries_cloudagent/protocols/present_proof/indy/pres_exch_handler.py @@ -2,21 +2,17 @@ import json import logging import time +from typing import Dict, Tuple, Union -from typing import Union, Tuple - +from ....anoncreds.holder import AnonCredsHolder, AnonCredsHolderError +from ....anoncreds.models.anoncreds_cred_def import CredDef +from ....anoncreds.models.anoncreds_revocation import RevRegDef +from ....anoncreds.models.anoncreds_schema import AnonCredsSchema +from ....indy.models.xform import indy_proof_req2non_revoc_intervals +from ....anoncreds.registry import AnonCredsRegistry +from ....anoncreds.revocation import AnonCredsRevocation from ....core.error import BaseError from ....core.profile import Profile -from ....indy.holder import IndyHolder, IndyHolderError -from ....indy.models.xform import indy_proof_req2non_revoc_intervals -from ....ledger.multiple_ledger.ledger_requests_executor import ( - GET_SCHEMA, - GET_REVOC_REG_DELTA, - IndyLedgerRequestsExecutor, -) -from ....multitenant.base import BaseMultitenantManager -from ....revocation.models.revocation_registry import RevocationRegistry - from ..v1_0.models.presentation_exchange import V10PresentationExchange from ..v2_0.messages.pres_format import V20PresFormat from ..v2_0.models.pres_exchange import V20PresExRecord @@ -38,46 +34,62 @@ def __init__( """Initialize PresExchange Handler.""" super().__init__() self._profile = profile + self.holder = AnonCredsHolder(profile) - async def return_presentation( + def _extract_proof_request(self, pres_ex_record): + if isinstance(pres_ex_record, V20PresExRecord): + return pres_ex_record.pres_request.attachment(V20PresFormat.Format.INDY) + elif isinstance(pres_ex_record, V10PresentationExchange): + return pres_ex_record._presentation_request.ser + + raise TypeError( + "pres_ex_record must be V10PresentationExchange or V20PresExRecord" + ) + + def _get_requested_referents( self, - pres_ex_record: Union[V10PresentationExchange, V20PresExRecord], - requested_credentials: dict = {}, + proof_request: dict, + requested_credentials: dict, + non_revoc_intervals: dict, ) -> dict: - """Return Indy proof request as dict.""" - # Get all credentials for this presentation - holder = self._profile.inject(IndyHolder) - credentials = {} + """Get requested referents for a proof request and requested credentials. + + Returns a dictionary that looks like: + { + "referent-0": {"cred_id": "0", "non_revoked": {"from": ..., "to": ...}}, + "referent-1": {"cred_id": "1", "non_revoked": {"from": ..., "to": ...}} + } + """ - # extract credential ids and non_revoked requested_referents = {} - if isinstance(pres_ex_record, V20PresExRecord): - proof_request = pres_ex_record.pres_request.attachment( - V20PresFormat.Format.INDY - ) - elif isinstance(pres_ex_record, V10PresentationExchange): - proof_request = pres_ex_record._presentation_request.ser - non_revoc_intervals = indy_proof_req2non_revoc_intervals(proof_request) attr_creds = requested_credentials.get("requested_attributes", {}) req_attrs = proof_request.get("requested_attributes", {}) for reft in attr_creds: requested_referents[reft] = {"cred_id": attr_creds[reft]["cred_id"]} if reft in req_attrs and reft in non_revoc_intervals: requested_referents[reft]["non_revoked"] = non_revoc_intervals[reft] + pred_creds = requested_credentials.get("requested_predicates", {}) req_preds = proof_request.get("requested_predicates", {}) for reft in pred_creds: requested_referents[reft] = {"cred_id": pred_creds[reft]["cred_id"]} if reft in req_preds and reft in non_revoc_intervals: requested_referents[reft]["non_revoked"] = non_revoc_intervals[reft] - # extract mapping of presentation referents to credential ids + return requested_referents + + async def _get_credentials(self, requested_referents: dict): + """Extract mapping of presentation referents to credential ids""" + credentials = {} for reft in requested_referents: credential_id = requested_referents[reft]["cred_id"] if credential_id not in credentials: credentials[credential_id] = json.loads( - await holder.get_credential(credential_id) + await self.holder.get_credential(credential_id) ) - # remove any timestamps that cannot correspond to non-revoc intervals + return credentials + + def _remove_superfluous_timestamps(self, requested_credentials, credentials): + """Remove any timestamps that cannot correspond to non-revoc intervals.""" for r in ("requested_attributes", "requested_predicates"): for reft, req_item in requested_credentials.get(r, {}).items(): if not credentials[req_item["cred_id"]].get( @@ -87,44 +99,49 @@ async def return_presentation( f"Removed superfluous timestamp from requested_credentials {r} " f"{reft} for non-revocable credential {req_item['cred_id']}" ) - # Get all schemas, credential definitions, and revocation registries in use + + async def _get_ledger_objects( + self, credentials: dict + ) -> Tuple[Dict[str, AnonCredsSchema], Dict[str, CredDef], Dict[str, RevRegDef]]: + """Get all schemas, credential definitions, and revocation registries in use""" schemas = {} cred_defs = {} revocation_registries = {} for credential in credentials.values(): schema_id = credential["schema_id"] - multitenant_mgr = self._profile.inject_or(BaseMultitenantManager) - if multitenant_mgr: - ledger_exec_inst = IndyLedgerRequestsExecutor(self._profile) - else: - ledger_exec_inst = self._profile.inject(IndyLedgerRequestsExecutor) - ledger = ( - await ledger_exec_inst.get_ledger_for_identifier( - schema_id, - txn_record_type=GET_SCHEMA, - ) - )[1] - async with ledger: - if schema_id not in schemas: - schemas[schema_id] = await ledger.get_schema(schema_id) - cred_def_id = credential["cred_def_id"] - if cred_def_id not in cred_defs: - cred_defs[cred_def_id] = await ledger.get_credential_definition( - cred_def_id + anoncreds_registry = self._profile.inject(AnonCredsRegistry) + if schema_id not in schemas: + schemas[schema_id] = ( + await anoncreds_registry.get_schema(self._profile, schema_id) + ).schema + cred_def_id = credential["cred_def_id"] + if cred_def_id not in cred_defs: + cred_defs[cred_def_id] = ( + await anoncreds_registry.get_credential_definition( + self._profile, cred_def_id ) - if credential.get("rev_reg_id"): - 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 + ).credential_definition + if credential.get("rev_reg_id"): + revocation_registry_id = credential["rev_reg_id"] + if revocation_registry_id not in revocation_registries: + rev_reg = ( + await anoncreds_registry.get_revocation_registry_definition( + self._profile, revocation_registry_id ) - # Get delta with non-revocation interval defined in "non_revoked" - # of the presentation request or attributes + ).revocation_registry + revocation_registries[revocation_registry_id] = rev_reg + + return schemas, cred_defs, revocation_registries + + async def _get_revocation_lists(self, requested_referents: dict, credentials: dict): + """Get revocation lists. + + Get revocation lists with non-revocation interval defined in + "non_revoked" of the presentation request or attributes + """ epoch_now = int(time.time()) - revoc_reg_deltas = {} + rev_lists = {} for precis in requested_referents.values(): # cred_id, non-revoc interval credential_id = precis["cred_id"] if not credentials[credential_id].get("rev_reg_id"): @@ -132,68 +149,70 @@ async def return_presentation( if "timestamp" in precis: continue rev_reg_id = credentials[credential_id]["rev_reg_id"] - multitenant_mgr = self._profile.inject_or(BaseMultitenantManager) - if multitenant_mgr: - ledger_exec_inst = IndyLedgerRequestsExecutor(self._profile) - else: - ledger_exec_inst = self._profile.inject(IndyLedgerRequestsExecutor) - ledger = ( - await ledger_exec_inst.get_ledger_for_identifier( - rev_reg_id, - txn_record_type=GET_REVOC_REG_DELTA, + + anoncreds_registry = self._profile.inject(AnonCredsRegistry) + reft_non_revoc_interval = precis.get("non_revoked") + if reft_non_revoc_interval: + key = ( + f"{rev_reg_id}_" + f"{reft_non_revoc_interval.get('from', 0)}_" + f"{reft_non_revoc_interval.get('to', epoch_now)}" ) - )[1] - async with ledger: - reft_non_revoc_interval = precis.get("non_revoked") - if reft_non_revoc_interval: - key = ( - f"{rev_reg_id}_" - f"{reft_non_revoc_interval.get('from', 0)}_" - f"{reft_non_revoc_interval.get('to', epoch_now)}" + if key not in rev_lists: + result = await anoncreds_registry.get_revocation_list( + self._profile, + rev_reg_id, + reft_non_revoc_interval.get("to", epoch_now), ) - if key not in revoc_reg_deltas: - (delta, delta_timestamp) = await ledger.get_revoc_reg_delta( - rev_reg_id, - reft_non_revoc_interval.get("from", 0), - reft_non_revoc_interval.get("to", epoch_now), - ) - revoc_reg_deltas[key] = ( - rev_reg_id, - credential_id, - delta, - delta_timestamp, - ) - for stamp_me in requested_referents.values(): - # often one cred satisfies many requested attrs/preds - if stamp_me["cred_id"] == credential_id: - stamp_me["timestamp"] = revoc_reg_deltas[key][3] - # Get revocation states to prove non-revoked + + rev_lists[key] = ( + rev_reg_id, + credential_id, + result.revocation_list.serialize(), + result.revocation_list.timestamp, + ) + for stamp_me in requested_referents.values(): + # often one cred satisfies many requested attrs/preds + if stamp_me["cred_id"] == credential_id: + stamp_me["timestamp"] = rev_lists[key][3] + + return rev_lists + + async def _get_revocation_states( + self, revocation_registries: dict, credentials: dict, rev_lists: dict + ): + """Get revocation states to prove non-revoked.""" revocation_states = {} for ( rev_reg_id, credential_id, - delta, - delta_timestamp, - ) in revoc_reg_deltas.values(): + rev_list, + timestamp, + ) in rev_lists.values(): if rev_reg_id not in revocation_states: revocation_states[rev_reg_id] = {} - rev_reg = revocation_registries[rev_reg_id] - tails_local_path = await rev_reg.get_or_fetch_local_tails_path() + rev_reg_def = revocation_registries[rev_reg_id] + revocation = AnonCredsRevocation(self._profile) + tails_local_path = await revocation.get_or_fetch_local_tails_path( + rev_reg_def + ) try: - revocation_states[rev_reg_id][delta_timestamp] = json.loads( - await holder.create_revocation_state( + revocation_states[rev_reg_id][timestamp] = json.loads( + await self.holder.create_revocation_state( credentials[credential_id]["cred_rev_id"], - rev_reg.reg_def, - delta, - delta_timestamp, + rev_reg_def.serialize(), + rev_list, tails_local_path, ) ) - except IndyHolderError as e: + except AnonCredsHolderError as e: LOGGER.error( f"Failed to create revocation state: {e.error_code}, {e.message}" ) raise e + return revocation_states + + def _set_timestamps(self, requested_credentials: dict, requested_referents: dict): for referent, precis in requested_referents.items(): if "timestamp" not in precis: continue @@ -205,82 +224,41 @@ async def return_presentation( requested_credentials["requested_predicates"][referent][ "timestamp" ] = precis["timestamp"] - indy_proof_json = await holder.create_presentation( - proof_request, - requested_credentials, - schemas, - cred_defs, - revocation_states, - ) - indy_proof = json.loads(indy_proof_json) - return indy_proof - async def process_pres_identifiers( + async def return_presentation( self, - identifiers: list, - ) -> Tuple[dict, dict, dict, dict]: - """Return schemas, cred_defs, rev_reg_defs, rev_reg_entries.""" - schema_ids = [] - cred_def_ids = [] + pres_ex_record: Union[V10PresentationExchange, V20PresExRecord], + requested_credentials: dict = {}, + ) -> dict: + """Return Indy proof request as dict.""" + proof_request = self._extract_proof_request(pres_ex_record) + non_revoc_intervals = indy_proof_req2non_revoc_intervals(proof_request) - schemas = {} - cred_defs = {} - rev_reg_defs = {} - rev_reg_entries = {} - - for identifier in identifiers: - schema_ids.append(identifier["schema_id"]) - cred_def_ids.append(identifier["cred_def_id"]) - multitenant_mgr = self._profile.inject_or(BaseMultitenantManager) - if multitenant_mgr: - ledger_exec_inst = IndyLedgerRequestsExecutor(self._profile) - else: - ledger_exec_inst = self._profile.inject(IndyLedgerRequestsExecutor) - ledger = ( - await ledger_exec_inst.get_ledger_for_identifier( - identifier["schema_id"], - txn_record_type=GET_SCHEMA, - ) - )[1] - async with ledger: - # Build schemas for anoncreds - if identifier["schema_id"] not in schemas: - schemas[identifier["schema_id"]] = await ledger.get_schema( - identifier["schema_id"] - ) + requested_referents = self._get_requested_referents( + proof_request, requested_credentials, non_revoc_intervals + ) - if identifier["cred_def_id"] not in cred_defs: - cred_defs[ - identifier["cred_def_id"] - ] = await ledger.get_credential_definition( - identifier["cred_def_id"] - ) + credentials = await self._get_credentials(requested_referents) + self._remove_superfluous_timestamps(requested_credentials, credentials) + + schemas, cred_defs, revocation_registries = await self._get_ledger_objects( + credentials + ) + + rev_lists = await self._get_revocation_lists(requested_referents, credentials) - if identifier.get("rev_reg_id"): - 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 identifier.get("timestamp"): - rev_reg_entries.setdefault(identifier["rev_reg_id"], {}) - - if ( - identifier["timestamp"] - not in rev_reg_entries[identifier["rev_reg_id"]] - ): - ( - found_rev_reg_entry, - _found_timestamp, - ) = await ledger.get_revoc_reg_entry( - identifier["rev_reg_id"], identifier["timestamp"] - ) - rev_reg_entries[identifier["rev_reg_id"]][ - identifier["timestamp"] - ] = found_rev_reg_entry - return ( + revocation_states = await self._get_revocation_states( + revocation_registries, credentials, rev_lists + ) + + self._set_timestamps(requested_credentials, requested_referents) + + indy_proof_json = await self.holder.create_presentation( + proof_request, + requested_credentials, schemas, cred_defs, - rev_reg_defs, - rev_reg_entries, + revocation_states, ) + indy_proof = json.loads(indy_proof_json) + return indy_proof diff --git a/aries_cloudagent/protocols/present_proof/v2_0/formats/indy/handler.py b/aries_cloudagent/protocols/present_proof/v2_0/formats/indy/handler.py index 8f0a3e5057..0940468e06 100644 --- a/aries_cloudagent/protocols/present_proof/v2_0/formats/indy/handler.py +++ b/aries_cloudagent/protocols/present_proof/v2_0/formats/indy/handler.py @@ -6,13 +6,13 @@ from marshmallow import RAISE from typing import Mapping, Tuple -from ......indy.holder import IndyHolder +from ......anoncreds.holder import AnonCredsHolder from ......indy.models.predicate import Predicate from ......indy.models.proof import IndyProofSchema from ......indy.models.proof_request import IndyProofRequestSchema from ......indy.models.xform import indy_proof_req_preview2indy_requested_creds -from ......indy.util import generate_pr_nonce -from ......indy.verifier import IndyVerifier +from ......anoncreds.util import generate_pr_nonce +from ......anoncreds.verifier import AnonCredsVerifier from ......messaging.decorators.attach_decorator import AttachDecorator from ......messaging.util import canon @@ -143,7 +143,7 @@ async def create_pres( await indy_proof_req_preview2indy_requested_creds( indy_proof_request, preview=None, - holder=self._profile.inject(IndyHolder), + holder=AnonCredsHolder(self._profile), ) ) except ValueError as err: @@ -320,22 +320,24 @@ async def verify_pres(self, pres_ex_record: V20PresExRecord) -> V20PresExRecord: pres_request_msg = pres_ex_record.pres_request indy_proof_request = pres_request_msg.attachment(IndyPresExchangeHandler.format) indy_proof = pres_ex_record.pres.attachment(IndyPresExchangeHandler.format) - indy_handler = IndyPresExchHandler(self._profile) + verifier = AnonCredsVerifier(self._profile) + ( schemas, cred_defs, rev_reg_defs, - rev_reg_entries, - ) = await indy_handler.process_pres_identifiers(indy_proof["identifiers"]) + rev_lists, + ) = await verifier.process_pres_identifiers(indy_proof["identifiers"]) + + verifier = AnonCredsVerifier(self._profile) - verifier = self._profile.inject(IndyVerifier) (verified, verified_msgs) = await verifier.verify_presentation( indy_proof_request, indy_proof, schemas, cred_defs, rev_reg_defs, - rev_reg_entries, + rev_lists, ) pres_ex_record.verified = json.dumps(verified) pres_ex_record.verified_msgs = list(set(verified_msgs)) diff --git a/aries_cloudagent/protocols/present_proof/v2_0/handlers/pres_request_handler.py b/aries_cloudagent/protocols/present_proof/v2_0/handlers/pres_request_handler.py index 1d8abe88b6..6ad3729c51 100644 --- a/aries_cloudagent/protocols/present_proof/v2_0/handlers/pres_request_handler.py +++ b/aries_cloudagent/protocols/present_proof/v2_0/handlers/pres_request_handler.py @@ -1,7 +1,7 @@ """Presentation request message handler.""" from .....core.oob_processor import OobMessageProcessor -from .....indy.holder import IndyHolderError +from .....anoncreds.holder import AnonCredsHolderError from .....ledger.error import LedgerError from .....messaging.base_handler import BaseHandler, HandlerException from .....messaging.models.base import BaseModelError @@ -116,7 +116,7 @@ async def handle(self, context: RequestContext, responder: BaseResponder): await responder.send_reply(pres_message) except ( BaseModelError, - IndyHolderError, + AnonCredsHolderError, LedgerError, StorageError, WalletNotFoundError, diff --git a/aries_cloudagent/protocols/present_proof/v2_0/handlers/tests/test_pres_request_handler.py b/aries_cloudagent/protocols/present_proof/v2_0/handlers/tests/test_pres_request_handler.py index c0e28fa043..18e49355ca 100644 --- a/aries_cloudagent/protocols/present_proof/v2_0/handlers/tests/test_pres_request_handler.py +++ b/aries_cloudagent/protocols/present_proof/v2_0/handlers/tests/test_pres_request_handler.py @@ -2,7 +2,7 @@ from copy import deepcopy from ......core.oob_processor import OobMessageProcessor -from ......indy.holder import IndyHolder +from ......anoncreds.holder import AnonCredsHolder from ......messaging.decorators.attach_decorator import AttachDecorator from ......messaging.request_context import RequestContext from ......messaging.responder import MockResponder @@ -339,7 +339,7 @@ async def test_called_auto_present_x(self): ) ) ) - request_context.injector.bind_instance(IndyHolder, mock_holder) + request_context.injector.bind_instance(AnonCredsHolder, mock_holder) request_context.injector.bind_instance(OobMessageProcessor, mock_oob_processor) with async_mock.patch.object( @@ -356,7 +356,7 @@ async def test_called_auto_present_x(self): ) mock_pres_mgr.return_value.create_pres = async_mock.CoroutineMock( - side_effect=test_module.IndyHolderError() + side_effect=test_module.AnonCredsHolderError() ) request_context.connection_ready = True @@ -409,7 +409,7 @@ async def test_called_auto_present_indy(self): ) ) ) - request_context.injector.bind_instance(IndyHolder, mock_holder) + request_context.injector.bind_instance(AnonCredsHolder, mock_holder) with async_mock.patch.object( test_module, "V20PresManager", autospec=True @@ -553,7 +553,7 @@ async def test_called_auto_present_no_preview(self): ) ) ) - request_context.injector.bind_instance(IndyHolder, mock_holder) + request_context.injector.bind_instance(AnonCredsHolder, mock_holder) with async_mock.patch.object( test_module, "V20PresManager", autospec=True @@ -627,7 +627,7 @@ async def test_called_auto_present_pred_no_match(self): async_mock.CoroutineMock(return_value=[]) ) ) - request_context.injector.bind_instance(IndyHolder, mock_holder) + request_context.injector.bind_instance(AnonCredsHolder, mock_holder) with async_mock.patch.object( test_module, "V20PresManager", autospec=True @@ -691,7 +691,7 @@ async def test_called_auto_present_pred_single_match(self): ) ) ) - request_context.injector.bind_instance(IndyHolder, mock_holder) + request_context.injector.bind_instance(AnonCredsHolder, mock_holder) with async_mock.patch.object( test_module, "V20PresManager", autospec=True @@ -762,7 +762,7 @@ async def test_called_auto_present_pred_multi_match(self): ) ) ) - request_context.injector.bind_instance(IndyHolder, mock_holder) + request_context.injector.bind_instance(AnonCredsHolder, mock_holder) with async_mock.patch.object( test_module, "V20PresManager", autospec=True @@ -874,7 +874,7 @@ async def test_called_auto_present_multi_cred_match_reft(self): ) ) ) - request_context.injector.bind_instance(IndyHolder, mock_holder) + request_context.injector.bind_instance(AnonCredsHolder, mock_holder) px_rec_instance = test_module.V20PresExRecord( pres_proposal=pres_proposal.serialize(), diff --git a/aries_cloudagent/protocols/present_proof/v2_0/messages/tests/test_pres_format.py b/aries_cloudagent/protocols/present_proof/v2_0/messages/tests/test_pres_format.py index 90a62f5465..11ffac0b40 100644 --- a/aries_cloudagent/protocols/present_proof/v2_0/messages/tests/test_pres_format.py +++ b/aries_cloudagent/protocols/present_proof/v2_0/messages/tests/test_pres_format.py @@ -2,7 +2,7 @@ from marshmallow import ValidationError -from ......indy.models.pres_preview import ( +from ......anoncreds.models.pres_preview import ( IndyPresAttrSpec, IndyPresPreview, IndyPresPredSpec, diff --git a/aries_cloudagent/protocols/present_proof/v2_0/messages/tests/test_pres_proposal.py b/aries_cloudagent/protocols/present_proof/v2_0/messages/tests/test_pres_proposal.py index a2815c2ce4..4b48060e51 100644 --- a/aries_cloudagent/protocols/present_proof/v2_0/messages/tests/test_pres_proposal.py +++ b/aries_cloudagent/protocols/present_proof/v2_0/messages/tests/test_pres_proposal.py @@ -2,7 +2,7 @@ from unittest import TestCase -from ......indy.models.pres_preview import ( +from ......anoncreds.models.pres_preview import ( IndyPresAttrSpec, IndyPresPredSpec, IndyPresPreview, diff --git a/aries_cloudagent/protocols/present_proof/v2_0/messages/tests/test_pres_request.py b/aries_cloudagent/protocols/present_proof/v2_0/messages/tests/test_pres_request.py index 91b87bfa6e..7f082b3f6a 100644 --- a/aries_cloudagent/protocols/present_proof/v2_0/messages/tests/test_pres_request.py +++ b/aries_cloudagent/protocols/present_proof/v2_0/messages/tests/test_pres_request.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from unittest import TestCase -from ......indy.models.pres_preview import PRESENTATION_PREVIEW +from ......anoncreds.models.pres_preview import PRESENTATION_PREVIEW from ......messaging.decorators.attach_decorator import AttachDecorator from ......messaging.models.base import BaseModelError from ......messaging.util import str_to_datetime, str_to_epoch diff --git a/aries_cloudagent/protocols/present_proof/v2_0/models/tests/test_record.py b/aries_cloudagent/protocols/present_proof/v2_0/models/tests/test_record.py index c22a6ff23b..37eccf22b0 100644 --- a/aries_cloudagent/protocols/present_proof/v2_0/models/tests/test_record.py +++ b/aries_cloudagent/protocols/present_proof/v2_0/models/tests/test_record.py @@ -1,7 +1,7 @@ from asynctest import mock as async_mock, TestCase as AsyncTestCase from ......core.in_memory import InMemoryProfile -from ......indy.models.pres_preview import ( +from ......anoncreds.models.pres_preview import ( IndyPresAttrSpec, IndyPresPredSpec, IndyPresPreview, diff --git a/aries_cloudagent/protocols/present_proof/v2_0/routes.py b/aries_cloudagent/protocols/present_proof/v2_0/routes.py index 68a55087ef..0bb7bac889 100644 --- a/aries_cloudagent/protocols/present_proof/v2_0/routes.py +++ b/aries_cloudagent/protocols/present_proof/v2_0/routes.py @@ -15,11 +15,11 @@ from ....admin.request_context import AdminRequestContext from ....connections.models.conn_record import ConnRecord -from ....indy.holder import IndyHolder, IndyHolderError +from ....anoncreds.holder import AnonCredsHolder, AnonCredsHolderError from ....indy.models.cred_precis import IndyCredPrecisSchema from ....indy.models.proof import IndyPresSpecSchema from ....indy.models.proof_request import IndyProofRequestSchema -from ....indy.util import generate_pr_nonce +from ....anoncreds.util import generate_pr_nonce from ....ledger.error import LedgerError from ....messaging.decorators.attach_decorator import AttachDecorator from ....messaging.models.base import BaseModelError @@ -479,7 +479,7 @@ async def present_proof_credentials_list(request: web.BaseRequest): start = int(start) if isinstance(start, str) else 0 count = int(count) if isinstance(count, str) else 10 - indy_holder = profile.inject(IndyHolder) + indy_holder = AnonCredsHolder(profile) indy_credentials = [] # INDY try: @@ -496,7 +496,7 @@ async def present_proof_credentials_list(request: web.BaseRequest): extra_query, ) ) - except IndyHolderError as err: + except AnonCredsHolderError as err: if pres_ex_record: async with profile.session() as session: await pres_ex_record.save_error_state(session, reason=err.roll_up) @@ -1111,7 +1111,7 @@ async def present_proof_send_presentation(request: web.BaseRequest): result = pres_ex_record.serialize() except ( BaseModelError, - IndyHolderError, + AnonCredsHolderError, LedgerError, V20PresFormatHandlerError, StorageError, diff --git a/aries_cloudagent/protocols/present_proof/v2_0/tests/test_manager.py b/aries_cloudagent/protocols/present_proof/v2_0/tests/test_manager.py index 14a22ce4d8..f8130b83e6 100644 --- a/aries_cloudagent/protocols/present_proof/v2_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/present_proof/v2_0/tests/test_manager.py @@ -6,14 +6,14 @@ from asynctest import mock as async_mock, TestCase as AsyncTestCase from .....core.in_memory import InMemoryProfile -from .....indy.holder import IndyHolder -from .....indy.models.xform import indy_proof_req_preview2indy_requested_creds -from .....indy.models.pres_preview import ( +from .....anoncreds.holder import AnonCredsHolder +from .....anoncreds.models.xform import indy_proof_req_preview2indy_requested_creds +from .....anoncreds.models.pres_preview import ( IndyPresAttrSpec, IndyPresPreview, IndyPresPredSpec, ) -from .....indy.verifier import IndyVerifier +from .....anoncreds.verifier import AnonCredsVerifier from .....ledger.base import BaseLedger from .....ledger.multiple_ledger.ledger_requests_executor import ( IndyLedgerRequestsExecutor, @@ -439,7 +439,7 @@ async def setUp(self): ), ) - Holder = async_mock.MagicMock(IndyHolder, autospec=True) + Holder = async_mock.MagicMock(AnonCredsHolder, autospec=True) self.holder = Holder() get_creds = async_mock.CoroutineMock( return_value=( @@ -476,14 +476,14 @@ async def setUp(self): } ) ) - injector.bind_instance(IndyHolder, self.holder) + injector.bind_instance(AnonCredsHolder, self.holder) - Verifier = async_mock.MagicMock(IndyVerifier, autospec=True) + Verifier = async_mock.MagicMock(AnonCredsVerifier, autospec=True) self.verifier = Verifier() self.verifier.verify_presentation = async_mock.CoroutineMock( return_value=("true", []) ) - injector.bind_instance(IndyVerifier, self.verifier) + injector.bind_instance(AnonCredsVerifier, self.verifier) self.manager = V20PresManager(self.profile) @@ -1005,7 +1005,7 @@ async def test_create_pres_no_revocation(self): ) px_rec_in = V20PresExRecord(pres_request=pres_request.serialize()) - Holder = async_mock.MagicMock(IndyHolder, autospec=True) + Holder = async_mock.MagicMock(AnonCredsHolder, autospec=True) self.holder = Holder() get_creds = async_mock.CoroutineMock( return_value=( @@ -1031,7 +1031,7 @@ async def test_create_pres_no_revocation(self): ) ) self.holder.create_presentation = async_mock.CoroutineMock(return_value="{}") - self.profile.context.injector.bind_instance(IndyHolder, self.holder) + self.profile.context.injector.bind_instance(AnonCredsHolder, self.holder) with async_mock.patch.object( V20PresExRecord, "save", autospec=True @@ -1090,7 +1090,7 @@ async def test_create_pres_bad_revoc_state(self): ) px_rec_in = V20PresExRecord(pres_request=pres_request.serialize()) - Holder = async_mock.MagicMock(IndyHolder, autospec=True) + Holder = async_mock.MagicMock(AnonCredsHolder, autospec=True) self.holder = Holder() get_creds = async_mock.CoroutineMock( return_value=( @@ -1118,11 +1118,11 @@ async def test_create_pres_bad_revoc_state(self): ) self.holder.create_presentation = async_mock.CoroutineMock(return_value="{}") self.holder.create_revocation_state = async_mock.CoroutineMock( - side_effect=test_indy_util_module.IndyHolderError( + side_effect=test_indy_util_module.AnonCredsHolderError( "Problem", {"message": "Nope"} ) ) - self.profile.context.injector.bind_instance(IndyHolder, self.holder) + self.profile.context.injector.bind_instance(AnonCredsHolder, self.holder) more_magic_rr = async_mock.MagicMock( get_or_fetch_local_tails_path=async_mock.CoroutineMock( @@ -1144,7 +1144,7 @@ async def test_create_pres_bad_revoc_state(self): return_value=mock_attach_decorator ) request_data = {} - with self.assertRaises(test_indy_util_module.IndyHolderError): + with self.assertRaises(test_indy_util_module.AnonCredsHolderError): await self.manager.create_pres(px_rec_in, request_data) async def test_create_pres_multi_matching_proposal_creds_names(self): @@ -1163,7 +1163,7 @@ async def test_create_pres_multi_matching_proposal_creds_names(self): ) px_rec_in = V20PresExRecord(pres_request=pres_request.serialize()) - Holder = async_mock.MagicMock(IndyHolder, autospec=True) + Holder = async_mock.MagicMock(AnonCredsHolder, autospec=True) self.holder = Holder() get_creds = async_mock.CoroutineMock( return_value=( @@ -1212,7 +1212,7 @@ async def test_create_pres_multi_matching_proposal_creds_names(self): } ) ) - self.profile.context.injector.bind_instance(IndyHolder, self.holder) + self.profile.context.injector.bind_instance(AnonCredsHolder, self.holder) more_magic_rr = async_mock.MagicMock( get_or_fetch_local_tails_path=async_mock.CoroutineMock( @@ -2202,7 +2202,7 @@ async def test_verify_pres_indy_and_dif(self): return_value=PresentationVerificationResult(verified=False) ), ), async_mock.patch.object( - IndyVerifier, + AnonCredsVerifier, "verify_presentation", async_mock.CoroutineMock( return_value=PresentationVerificationResult(verified=True) diff --git a/aries_cloudagent/protocols/present_proof/v2_0/tests/test_routes.py b/aries_cloudagent/protocols/present_proof/v2_0/tests/test_routes.py index 0d13acc826..2006813d0b 100644 --- a/aries_cloudagent/protocols/present_proof/v2_0/tests/test_routes.py +++ b/aries_cloudagent/protocols/present_proof/v2_0/tests/test_routes.py @@ -6,9 +6,9 @@ from unittest.mock import ANY from .....admin.request_context import AdminRequestContext -from .....indy.holder import IndyHolder +from .....anoncreds.holder import AnonCredsHolder from .....indy.models.proof_request import IndyProofReqAttrSpecSchema -from .....indy.verifier import IndyVerifier +from .....anoncreds.verifier import AnonCredsVerifier from .....ledger.base import BaseLedger from .....storage.error import StorageNotFoundError from .....storage.vc_holder.base import VCHolder @@ -308,10 +308,12 @@ async def test_present_proof_credentials_x(self): self.request.query = {"extra_query": {}} returned_credentials = [{"name": "Credential1"}, {"name": "Credential2"}] self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( - async_mock.CoroutineMock(side_effect=test_module.IndyHolderError()) + async_mock.CoroutineMock( + side_effect=test_module.AnonCredsHolderError() + ) ) ), ) @@ -336,7 +338,7 @@ async def test_present_proof_credentials_list_single_referent(self): returned_credentials = [{"name": "Credential1"}, {"name": "Credential2"}] self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock(return_value=returned_credentials) @@ -365,7 +367,7 @@ async def test_present_proof_credentials_list_multiple_referents(self): returned_credentials = [{"name": "Credential1"}, {"name": "Credential2"}] self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock(return_value=returned_credentials) @@ -396,7 +398,7 @@ async def test_present_proof_credentials_list_dif(self): async_mock.MagicMock(cred_value={"name": "Credential2"}), ] self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock() @@ -474,7 +476,7 @@ async def test_present_proof_credentials_list_dif_one_of_filter(self): ), ] self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock() @@ -564,7 +566,7 @@ async def test_present_proof_credentials_dif_no_tag_query(self): async_mock.MagicMock(cred_value={"name": "Credential2"}), ] self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock() @@ -644,7 +646,7 @@ async def test_present_proof_credentials_single_ldp_vp_claim_format(self): async_mock.MagicMock(cred_value={"name": "Credential2"}), ] self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock() @@ -724,7 +726,7 @@ async def test_present_proof_credentials_double_ldp_vp_claim_format(self): async_mock.MagicMock(cred_value={"name": "Credential2"}), ] self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock() @@ -829,7 +831,7 @@ async def test_present_proof_credentials_single_ldp_vp_error(self): ) self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock() @@ -892,7 +894,7 @@ async def test_present_proof_credentials_double_ldp_vp_error(self): ) self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock() @@ -952,7 +954,7 @@ async def test_present_proof_credentials_list_limit_disclosure_no_bbs(self): ) self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock() @@ -1015,7 +1017,7 @@ async def test_present_proof_credentials_no_ldp_vp(self): ) self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock() @@ -1080,7 +1082,7 @@ async def test_present_proof_credentials_list_schema_uri(self): async_mock.MagicMock(cred_value={"name": "Credential2"}), ] self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock() @@ -1121,7 +1123,7 @@ async def test_present_proof_credentials_list_dif_error(self): self.request.query = {"extra_query": {}} self.profile.context.injector.bind_instance( - IndyHolder, + AnonCredsHolder, async_mock.MagicMock( get_credentials_for_presentation_request_by_referent=( async_mock.CoroutineMock() @@ -1499,7 +1501,7 @@ async def test_present_proof_send_bound_request(self): ), ) self.profile.context.injector.bind_instance( - IndyVerifier, + AnonCredsVerifier, async_mock.MagicMock( verify_presentation=async_mock.CoroutineMock(), ), @@ -1556,7 +1558,7 @@ async def test_present_proof_send_bound_request_not_found(self): ), ) self.profile.context.injector.bind_instance( - IndyVerifier, + AnonCredsVerifier, async_mock.MagicMock( verify_presentation=async_mock.CoroutineMock(), ), @@ -1596,7 +1598,7 @@ async def test_present_proof_send_bound_request_not_ready(self): ), ) self.profile.context.injector.bind_instance( - IndyVerifier, + AnonCredsVerifier, async_mock.MagicMock( verify_presentation=async_mock.CoroutineMock(), ), @@ -1653,7 +1655,7 @@ async def test_present_proof_send_bound_request_bad_state(self): ), ) self.profile.context.injector.bind_instance( - IndyVerifier, + AnonCredsVerifier, async_mock.MagicMock( verify_presentation=async_mock.CoroutineMock(), ), @@ -1688,7 +1690,7 @@ async def test_present_proof_send_bound_request_x(self): ), ) self.profile.context.injector.bind_instance( - IndyVerifier, + AnonCredsVerifier, async_mock.MagicMock( verify_presentation=async_mock.CoroutineMock(), ), @@ -1744,7 +1746,7 @@ async def test_present_proof_send_presentation(self): "pres_ex_id": "dummy", } self.profile.context.injector.bind_instance( - IndyVerifier, + AnonCredsVerifier, async_mock.MagicMock( verify_presentation=async_mock.CoroutineMock(), ), @@ -1799,7 +1801,7 @@ async def test_present_proof_send_presentation_dif(self): "pres_ex_id": "dummy", } self.profile.context.injector.bind_instance( - IndyVerifier, + AnonCredsVerifier, async_mock.MagicMock( verify_presentation=async_mock.CoroutineMock(), ), @@ -1878,7 +1880,7 @@ async def test_present_proof_send_presentation_dif_error(self): error_msg=None, ) self.profile.context.injector.bind_instance( - IndyVerifier, + AnonCredsVerifier, async_mock.MagicMock( verify_presentation=async_mock.CoroutineMock(), ), @@ -1953,7 +1955,7 @@ async def test_present_proof_send_presentation_not_found(self): "pres_ex_id": "dummy", } self.profile.context.injector.bind_instance( - IndyVerifier, + AnonCredsVerifier, async_mock.MagicMock( verify_presentation=async_mock.CoroutineMock(), ), @@ -1998,7 +2000,7 @@ async def test_present_proof_send_presentation_not_ready(self): "pres_ex_id": "dummy", } self.profile.context.injector.bind_instance( - IndyVerifier, + AnonCredsVerifier, async_mock.MagicMock( verify_presentation=async_mock.CoroutineMock(), ), @@ -2075,7 +2077,7 @@ async def test_present_proof_send_presentation_x(self): "pres_ex_id": "dummy", } self.profile.context.injector.bind_instance( - IndyVerifier, + AnonCredsVerifier, async_mock.MagicMock( verify_presentation=async_mock.CoroutineMock(), ), diff --git a/aries_cloudagent/revocation/manager.py b/aries_cloudagent/revocation/manager.py index 592a879c7a..cb174a8628 100644 --- a/aries_cloudagent/revocation/manager.py +++ b/aries_cloudagent/revocation/manager.py @@ -1,26 +1,22 @@ """Classes to manage credential revocation.""" -import json import logging -from typing import Mapping, Sequence, Text +from typing import Mapping, Optional, Sequence, Text, Tuple -from ..protocols.revocation_notification.v1_0.models.rev_notification_record import ( - RevNotificationRecord, -) +from ..anoncreds.default.legacy_indy.registry import LegacyIndyRegistry +from ..anoncreds.revocation import AnonCredsRevocation from ..core.error import BaseError from ..core.profile import Profile -from ..indy.issuer import IndyIssuer -from ..storage.error import StorageNotFoundError -from .indy import IndyRevocation -from .models.issuer_cred_rev_record import IssuerCredRevRecord -from .models.issuer_rev_reg_record import IssuerRevRegRecord -from .util import notify_pending_cleared_event, notify_revocation_published_event from ..protocols.issue_credential.v1_0.models.credential_exchange import ( V10CredentialExchange, ) -from ..protocols.issue_credential.v2_0.models.cred_ex_record import ( - V20CredExRecord, +from ..protocols.issue_credential.v2_0.models.cred_ex_record import V20CredExRecord +from ..protocols.revocation_notification.v1_0.models.rev_notification_record import ( + RevNotificationRecord, ) +from ..storage.error import StorageNotFoundError +from .models.issuer_cred_rev_record import IssuerCredRevRecord +from .util import notify_pending_cleared_event, notify_revocation_published_event class RevocationManagerError(BaseError): @@ -107,15 +103,32 @@ async def revoke_credential( along with any revocations pending against it """ - issuer = self._profile.inject(IndyIssuer) - - revoc = IndyRevocation(self._profile) - issuer_rr_rec = await revoc.get_issuer_rev_reg_record(rev_reg_id) - if not issuer_rr_rec: + revoc = AnonCredsRevocation(self._profile) + rev_reg_def = await revoc.get_created_revocation_registry_definition(rev_reg_id) + if not rev_reg_def: raise RevocationManagerError( f"No revocation registry record found for id: {rev_reg_id}" ) + if publish: + await revoc.get_or_fetch_local_tails_path(rev_reg_def) + result = await revoc.revoke_pending_credentials( + rev_reg_id, + additional_crids=[int(cred_rev_id)], + ) + + if result.curr and result.revoked: + await self.set_cred_revoked_state(rev_reg_id, result.revoked) + await revoc.update_revocation_list( + rev_reg_id, result.prev, result.curr, result.revoked + ) + await notify_revocation_published_event( + self._profile, rev_reg_id, [cred_rev_id] + ) + + else: + await revoc.mark_pending_revocations(rev_reg_id, int(cred_rev_id)) + if notify: thread_id = thread_id or f"indy::{rev_reg_id}::{cred_rev_id}" rev_notify_rec = RevNotificationRecord( @@ -129,43 +142,17 @@ async def revoke_credential( async with self._profile.session() as session: await rev_notify_rec.save(session, reason="New revocation notification") - if publish: - rev_reg = await revoc.get_ledger_registry(rev_reg_id) - await rev_reg.get_or_fetch_local_tails_path() - # pick up pending revocations on input revocation registry - crids = (issuer_rr_rec.pending_pub or []) + [cred_rev_id] - (delta_json, _) = await issuer.revoke_credentials( - issuer_rr_rec.revoc_reg_id, issuer_rr_rec.tails_local_path, crids - ) - async with self._profile.transaction() as txn: - issuer_rr_upd = await IssuerRevRegRecord.retrieve_by_id( - txn, issuer_rr_rec.record_id, for_update=True - ) - if delta_json: - issuer_rr_upd.revoc_reg_entry = json.loads(delta_json) - await issuer_rr_upd.clear_pending(txn, crids) - await txn.commit() - await self.set_cred_revoked_state(rev_reg_id, crids) - if delta_json: - await issuer_rr_upd.send_entry(self._profile) - await notify_revocation_published_event( - self._profile, rev_reg_id, [cred_rev_id] - ) - - else: - async with self._profile.transaction() as txn: - await issuer_rr_rec.mark_pending(txn, cred_rev_id) - await txn.commit() - async def update_rev_reg_revoked_state( self, + rev_reg_def_id: str, apply_ledger_update: bool, - rev_reg_record: IssuerRevRegRecord, - genesis_transactions: dict, - ) -> (dict, dict, dict): + genesis_transactions: str, + ) -> Tuple[dict, dict, dict]: """ Request handler to fix ledger entry of credentials revoked against registry. + This is an indy registry specific operation. + Args: rev_reg_id: revocation registry id apply_ledger_update: whether to apply an update to the ledger @@ -174,15 +161,31 @@ async def update_rev_reg_revoked_state( Number of credentials posted to ledger """ - return await rev_reg_record.fix_ledger_entry( - self._profile, - apply_ledger_update, - genesis_transactions, + revoc = AnonCredsRevocation(self._profile) + rev_list = await revoc.get_created_revocation_list(rev_reg_def_id) + if not rev_list: + raise RevocationManagerError( + f"No revocation list found for revocation registry id {rev_reg_def_id}" + ) + + indy_registry = LegacyIndyRegistry() + + if await indy_registry.supports(rev_reg_def_id): + return await indy_registry.fix_ledger_entry( + self._profile, + rev_list, + apply_ledger_update, + genesis_transactions, + ) + + raise RevocationManagerError( + "Indy registry does not support revocation registry " + f"identified by {rev_reg_def_id}" ) async def publish_pending_revocations( self, - rrid2crid: Mapping[Text, Sequence[Text]] = None, + rrid2crid: Optional[Mapping[Text, Sequence[Text]]] = None, ) -> Mapping[Text, Sequence[Text]]: """ Publish pending revocations to the ledger. @@ -207,47 +210,32 @@ async def publish_pending_revocations( Returns: mapping from each revocation registry id to its cred rev ids published. """ - result = {} - issuer = self._profile.inject(IndyIssuer) - - async with self._profile.session() as session: - issuer_rr_recs = await IssuerRevRegRecord.query_by_pending(session) + published_crids = {} + revoc = AnonCredsRevocation(self._profile) - for issuer_rr_rec in issuer_rr_recs: - rrid = issuer_rr_rec.revoc_reg_id + rev_reg_def_ids = await revoc.get_revocation_lists_with_pending_revocations() + for rrid in rev_reg_def_ids: if rrid2crid: if rrid not in rrid2crid: continue - limit_crids = rrid2crid[rrid] + limit_crids = [int(crid) for crid in rrid2crid[rrid]] else: - limit_crids = () - crids = set(issuer_rr_rec.pending_pub or ()) - if limit_crids: - crids = crids.intersection(limit_crids) - if crids: - (delta_json, failed_crids) = await issuer.revoke_credentials( - issuer_rr_rec.revoc_reg_id, - issuer_rr_rec.tails_local_path, - crids, + limit_crids = None + + result = await revoc.revoke_pending_credentials( + rrid, limit_crids=limit_crids + ) + if result.curr and result.revoked: + await self.set_cred_revoked_state(rrid, result.revoked) + await revoc.update_revocation_list( + rrid, result.prev, result.curr, result.revoked ) - async with self._profile.transaction() as txn: - issuer_rr_upd = await IssuerRevRegRecord.retrieve_by_id( - txn, issuer_rr_rec.record_id, for_update=True - ) - if delta_json: - issuer_rr_upd.revoc_reg_entry = json.loads(delta_json) - await issuer_rr_upd.clear_pending(txn, crids) - await txn.commit() - await self.set_cred_revoked_state(issuer_rr_rec.revoc_reg_id, crids) - if delta_json: - await issuer_rr_upd.send_entry(self._profile) - published = sorted(crid for crid in crids if crid not in failed_crids) - result[issuer_rr_rec.revoc_reg_id] = published + published_crids[rrid] = sorted(result.revoked) await notify_revocation_published_event( - self._profile, issuer_rr_rec.revoc_reg_id, crids + self._profile, rrid, [str(crid) for crid in result.revoked] ) - return result + return published_crids async def clear_pending_revocations( self, purge: Mapping[Text, Sequence[Text]] = None @@ -282,13 +270,18 @@ async def clear_pending_revocations( result = {} notify = [] + revoc = AnonCredsRevocation(self._profile) + rrids = await revoc.get_revocation_lists_with_pending_revocations() async with self._profile.transaction() as txn: - issuer_rr_recs = await IssuerRevRegRecord.query_by_pending(txn) - for issuer_rr_rec in issuer_rr_recs: - rrid = issuer_rr_rec.revoc_reg_id - await issuer_rr_rec.clear_pending(txn, (purge or {}).get(rrid)) - if issuer_rr_rec.pending_pub: - result[rrid] = issuer_rr_rec.pending_pub + for rrid in rrids: + await revoc.clear_pending_revocations( + txn, + rrid, + crid_mask=[int(crid) for crid in (purge or {}).get(rrid, ())], + ) + remaining = await revoc.get_pending_revocations(rrid) + if remaining: + result[rrid] = remaining notify.append(rrid) await txn.commit() @@ -298,7 +291,7 @@ async def clear_pending_revocations( return result async def set_cred_revoked_state( - self, rev_reg_id: str, cred_rev_ids: Sequence[str] + self, rev_reg_id: str, cred_rev_ids: Sequence[int] ) -> None: """ Update credentials state to credential_revoked. @@ -317,7 +310,7 @@ async def set_cred_revoked_state( try: async with self._profile.transaction() as txn: rev_rec = await IssuerCredRevRecord.retrieve_by_ids( - txn, rev_reg_id, cred_rev_id, for_update=True + txn, rev_reg_id, str(cred_rev_id), for_update=True ) cred_ex_id = rev_rec.cred_ex_id cred_ex_version = rev_rec.cred_ex_version diff --git a/aries_cloudagent/revocation/models/issuer_cred_rev_record.py b/aries_cloudagent/revocation/models/issuer_cred_rev_record.py index 10ba69ef53..b9fc9c0555 100644 --- a/aries_cloudagent/revocation/models/issuer_cred_rev_record.py +++ b/aries_cloudagent/revocation/models/issuer_cred_rev_record.py @@ -7,9 +7,6 @@ from ...core.profile import ProfileSession from ...messaging.models.base_record import BaseRecord, BaseRecordSchema from ...messaging.valid import ( - INDY_CRED_DEF_ID, - INDY_CRED_REV_ID, - INDY_REV_REG_ID, UUIDFour, ) @@ -152,17 +149,14 @@ class Meta: rev_reg_id = fields.Str( required=False, description="Revocation registry identifier", - **INDY_REV_REG_ID, ) cred_def_id = fields.Str( required=False, description="Credential definition identifier", - **INDY_CRED_DEF_ID, ) cred_rev_id = fields.Str( required=False, description="Credential revocation identifier", - **INDY_CRED_REV_ID, ) cred_ex_version = fields.Str( required=False, diff --git a/aries_cloudagent/revocation/recover.py b/aries_cloudagent/revocation/recover.py index 49adcbc5ed..504c53cdbe 100644 --- a/aries_cloudagent/revocation/recover.py +++ b/aries_cloudagent/revocation/recover.py @@ -23,6 +23,8 @@ failed.) """ +# TODO This should probably be moved to an Indy plugin + class RevocRecoveryException(Exception): """Raise exception generating the recovery transaction.""" diff --git a/aries_cloudagent/revocation/tests/test_manager.py b/aries_cloudagent/revocation/tests/test_manager.py index ec026b9afd..5fe2c86bc7 100644 --- a/aries_cloudagent/revocation/tests/test_manager.py +++ b/aries_cloudagent/revocation/tests/test_manager.py @@ -8,7 +8,7 @@ ) from ...core.in_memory import InMemoryProfile -from ...indy.issuer import IndyIssuer +from ...anoncreds.issuer import AnonCredsIssuer from ...protocols.issue_credential.v1_0.models.credential_exchange import ( V10CredentialExchange, ) @@ -45,7 +45,7 @@ async def test_revoke_credential_publish(self): clear_pending=async_mock.CoroutineMock(), pending_pub=["2"], ) - issuer = async_mock.MagicMock(IndyIssuer, autospec=True) + issuer = async_mock.MagicMock(AnonCredsIssuer, autospec=True) issuer.revoke_credentials = async_mock.CoroutineMock( return_value=( json.dumps( @@ -61,7 +61,7 @@ async def test_revoke_credential_publish(self): [], ) ) - self.profile.context.injector.bind_instance(IndyIssuer, issuer) + self.profile.context.injector.bind_instance(AnonCredsIssuer, issuer) with async_mock.patch.object( test_module.IssuerCredRevRecord, @@ -105,8 +105,8 @@ async def test_revoke_cred_by_cxid_not_found(self): ) as mock_retrieve: mock_retrieve.side_effect = test_module.StorageNotFoundError("no such rec") - issuer = async_mock.MagicMock(IndyIssuer, autospec=True) - self.profile.context.injector.bind_instance(IndyIssuer, issuer) + issuer = async_mock.MagicMock(AnonCredsIssuer, autospec=True) + self.profile.context.injector.bind_instance(AnonCredsIssuer, issuer) with self.assertRaises(RevocationManagerError): await self.manager.revoke_credential_by_cred_ex_id(CRED_EX_ID) @@ -128,8 +128,8 @@ async def test_revoke_credential_no_rev_reg_rec(self): return_value=None ) - issuer = async_mock.MagicMock(IndyIssuer, autospec=True) - self.profile.context.injector.bind_instance(IndyIssuer, issuer) + issuer = async_mock.MagicMock(AnonCredsIssuer, autospec=True) + self.profile.context.injector.bind_instance(AnonCredsIssuer, issuer) with self.assertRaises(RevocationManagerError): await self.manager.revoke_credential(REV_REG_ID, CRED_REV_ID) @@ -139,8 +139,8 @@ async def test_revoke_credential_pend(self): mock_issuer_rev_reg_record = async_mock.MagicMock( mark_pending=async_mock.CoroutineMock() ) - issuer = async_mock.MagicMock(IndyIssuer, autospec=True) - self.profile.context.injector.bind_instance(IndyIssuer, issuer) + issuer = async_mock.MagicMock(AnonCredsIssuer, autospec=True) + self.profile.context.injector.bind_instance(AnonCredsIssuer, issuer) with async_mock.patch.object( test_module, "IndyRevocation", autospec=True @@ -200,7 +200,7 @@ async def test_publish_pending_revocations_basic(self): "retrieve_by_id", async_mock.CoroutineMock(return_value=mock_issuer_rev_reg_record), ): - issuer = async_mock.MagicMock(IndyIssuer, autospec=True) + issuer = async_mock.MagicMock(AnonCredsIssuer, autospec=True) issuer.merge_revocation_registry_deltas = async_mock.CoroutineMock( side_effect=deltas ) @@ -208,7 +208,7 @@ async def test_publish_pending_revocations_basic(self): issuer.revoke_credentials = async_mock.CoroutineMock( side_effect=[(json.dumps(delta), []) for delta in deltas] ) - self.profile.context.injector.bind_instance(IndyIssuer, issuer) + self.profile.context.injector.bind_instance(AnonCredsIssuer, issuer) result = await self.manager.publish_pending_revocations() assert result == {REV_REG_ID: ["1", "2"]} @@ -259,7 +259,7 @@ async def test_publish_pending_revocations_1_rev_reg_all(self): side_effect=lambda _, id, **args: mock_issuer_rev_reg_records[id] ), ): - issuer = async_mock.MagicMock(IndyIssuer, autospec=True) + issuer = async_mock.MagicMock(AnonCredsIssuer, autospec=True) issuer.merge_revocation_registry_deltas = async_mock.CoroutineMock( side_effect=deltas ) @@ -267,7 +267,7 @@ async def test_publish_pending_revocations_1_rev_reg_all(self): issuer.revoke_credentials = async_mock.CoroutineMock( side_effect=[(json.dumps(delta), []) for delta in deltas] ) - self.profile.context.injector.bind_instance(IndyIssuer, issuer) + self.profile.context.injector.bind_instance(AnonCredsIssuer, issuer) result = await self.manager.publish_pending_revocations({REV_REG_ID: None}) assert result == {REV_REG_ID: ["1", "2"]} @@ -319,7 +319,7 @@ async def test_publish_pending_revocations_1_rev_reg_some(self): side_effect=lambda _, id, **args: mock_issuer_rev_reg_records[id] ), ): - issuer = async_mock.MagicMock(IndyIssuer, autospec=True) + issuer = async_mock.MagicMock(AnonCredsIssuer, autospec=True) issuer.merge_revocation_registry_deltas = async_mock.CoroutineMock( side_effect=deltas ) @@ -327,7 +327,7 @@ async def test_publish_pending_revocations_1_rev_reg_some(self): issuer.revoke_credentials = async_mock.CoroutineMock( side_effect=[(json.dumps(delta), []) for delta in deltas] ) - self.profile.context.injector.bind_instance(IndyIssuer, issuer) + self.profile.context.injector.bind_instance(AnonCredsIssuer, issuer) result = await self.manager.publish_pending_revocations({REV_REG_ID: "2"}) assert result == {REV_REG_ID: ["2"]} diff --git a/aries_cloudagent/tails/anoncreds_tails_server.py b/aries_cloudagent/tails/anoncreds_tails_server.py new file mode 100644 index 0000000000..2447ba6e7a --- /dev/null +++ b/aries_cloudagent/tails/anoncreds_tails_server.py @@ -0,0 +1,64 @@ +"""AnonCreds tails server interface class.""" + +import logging + +from typing import Tuple + +from ..config.injection_context import InjectionContext +from ..utils.http import put_file, PutError + +from .base import BaseTailsServer +from .error import TailsServerNotConfiguredError + + +LOGGER = logging.getLogger(__name__) + + +class AnonCredsTailsServer(BaseTailsServer): + """AnonCreds tails server interface.""" + + async def upload_tails_file( + self, + context: InjectionContext, + filename: str, + tails_file_path: str, + interval: float = 1.0, + backoff: float = 0.25, + max_attempts: int = 5, + ) -> Tuple[bool, str]: + """Upload tails file to tails server. + + Args: + context: context with configuration settings + filename: file name given to tails server + tails_file_path: path to the tails file to upload + interval: initial interval between attempts + backoff: exponential backoff in retry interval + max_attempts: maximum number of attempts to make + + Returns: + Tuple[bool, str]: tuple with success status and url of uploaded + file or error message if failed + """ + tails_server_upload_url = context.settings.get("tails_server_upload_url") + + if not tails_server_upload_url: + raise TailsServerNotConfiguredError( + "tails_server_upload_url setting is not set" + ) + + upload_url = tails_server_upload_url.rstrip("/") + f"/hash/{filename}" + + try: + await put_file( + upload_url, + {"tails": tails_file_path}, + {}, + interval=interval, + backoff=backoff, + max_attempts=max_attempts, + ) + except PutError as x_put: + return (False, x_put.message) + + return True, upload_url diff --git a/aries_cloudagent/tails/base.py b/aries_cloudagent/tails/base.py index 7d4b82186b..f3c56f4527 100644 --- a/aries_cloudagent/tails/base.py +++ b/aries_cloudagent/tails/base.py @@ -13,7 +13,7 @@ class BaseTailsServer(ABC, metaclass=ABCMeta): async def upload_tails_file( self, context: InjectionContext, - rev_reg_id: str, + filename: str, tails_file_path: str, interval: float = 1.0, backoff: float = 0.25, @@ -22,9 +22,14 @@ async def upload_tails_file( """Upload tails file to tails server. Args: - rev_reg_id: The revocation registry identifier + context: context with configuration settings + filename: file name given to tails server tails_file: The path to the tails file to upload interval: initial interval between attempts backoff: exponential backoff in retry interval max_attempts: maximum number of attempts to make + + Returns: + Tuple[bool, str]: tuple with success status and url of uploaded + file or error message if failed """ diff --git a/aries_cloudagent/tails/indy_tails_server.py b/aries_cloudagent/tails/indy_tails_server.py index 0c5ebb6ab4..0b6d85e734 100644 --- a/aries_cloudagent/tails/indy_tails_server.py +++ b/aries_cloudagent/tails/indy_tails_server.py @@ -21,7 +21,7 @@ class IndyTailsServer(BaseTailsServer): async def upload_tails_file( self, context: InjectionContext, - rev_reg_id: str, + filename: str, tails_file_path: str, interval: float = 1.0, backoff: float = 0.25, @@ -31,11 +31,15 @@ async def upload_tails_file( Args: context: context with configuration settings - rev_reg_id: revocation registry identifier + filename: file name given to tails server tails_file_path: path to the tails file to upload interval: initial interval between attempts backoff: exponential backoff in retry interval max_attempts: maximum number of attempts to make + + Returns: + Tuple[bool, str]: tuple with success status and url of uploaded + file or error message if failed """ tails_server_upload_url = context.settings.get("tails_server_upload_url") genesis_transactions = context.settings.get("ledger.genesis_transactions") @@ -59,7 +63,7 @@ async def upload_tails_file( "tails_server_upload_url setting is not set" ) - upload_url = tails_server_upload_url.rstrip("/") + f"/{rev_reg_id}" + upload_url = tails_server_upload_url.rstrip("/") + f"/{filename}" try: await put_file( diff --git a/demo/run_bdd b/demo/run_bdd index a8306aa947..ba0329bb68 100755 --- a/demo/run_bdd +++ b/demo/run_bdd @@ -126,8 +126,8 @@ AGENT_PORT=8020 AGENT_PORT_RANGE=8020-8079 echo "Preparing agent image..." -docker build -q -t faber-alice-demo -f ../docker/Dockerfile.demo .. || exit 1 -docker build -q -t aries-bdd-image -f ../docker/Dockerfile.bdd .. || exit 1 +docker build --platform linux/amd64 -q -t faber-alice-demo -f ../docker/Dockerfile.demo .. || exit 1 +docker build --platform linux/amd64 -q -t aries-bdd-image -f ../docker/Dockerfile.bdd .. || exit 1 if [ ! -z "$DOCKERHOST" ]; then # provided via APPLICATION_URL environment variable @@ -233,6 +233,7 @@ fi DOCKER=${DOCKER:-docker} $DOCKER run --name $AGENT --rm ${DOCKER_OPTS} \ + --platform linux/amd64 \ --network=${DOCKER_NET} \ -p 0.0.0.0:$AGENT_PORT_RANGE:$AGENT_PORT_RANGE \ -v "/$(pwd)/../logs:/home/indy/logs" \ diff --git a/docker/Dockerfile.indy b/docker/Dockerfile.indy index edd7200b8a..098c540d27 100644 --- a/docker/Dockerfile.indy +++ b/docker/Dockerfile.indy @@ -195,7 +195,9 @@ RUN pip3 install --no-cache-dir \ -r requirements.txt \ -r requirements.askar.txt \ -r requirements.bbs.txt \ - -r requirements.dev.txt + -r requirements.dev.txt \ + -r requirements.anoncreds.txt +RUN curl -sL https://github.com/Indicio-tech/anoncreds-rs/releases/download/v0.1.0-dev.9/library-linux-x86_64.tar.gz | tar -xz -C /usr/local/lib/python3.9/site-packages/anoncreds/ ADD --chown=indy:root . . USER indy diff --git a/docker/Dockerfile.run b/docker/Dockerfile.run index fe1164f647..67a3c28f41 100644 --- a/docker/Dockerfile.run +++ b/docker/Dockerfile.run @@ -1,6 +1,13 @@ -FROM bcgovimages/von-image:py36-1.15-1 +ARG python_version=3.9.16 +FROM python:${python_version}-slim-buster -ENV ENABLE_PTVSD 0 +RUN apt-get update -y && \ + apt-get install -y --no-install-recommends \ + libsodium23 git curl && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/app ADD requirements*.txt ./ @@ -8,17 +15,20 @@ RUN pip3 install --no-cache-dir \ -r requirements.txt \ -r requirements.askar.txt \ -r requirements.bbs.txt \ - -r requirements.dev.txt + -r requirements.dev.txt \ + -r requirements.anoncreds.txt +RUN curl -sL https://github.com/hyperledger/anoncreds-rs/releases/download/v0.1.0-dev.15/library-linux-x86_64.tar.gz | tar -xz -C /usr/local/lib/python3.9/site-packages/anoncreds/ RUN mkdir aries_cloudagent && touch aries_cloudagent/__init__.py ADD aries_cloudagent/version.py aries_cloudagent/version.py ADD bin ./bin ADD README.md ./ ADD setup.py ./ +ADD healthcheck.py ./ RUN pip3 install --no-cache-dir -e . -RUN mkdir logs && chown -R indy:indy logs && chmod -R ug+rw logs +RUN mkdir logs && chmod -R ug+rw logs ADD aries_cloudagent ./aries_cloudagent ENTRYPOINT ["/bin/bash", "-c", "aca-py \"$@\"", "--"] diff --git a/docker/Dockerfile.test b/docker/Dockerfile.test index 6a1b4df76a..d3764af32a 100644 --- a/docker/Dockerfile.test +++ b/docker/Dockerfile.test @@ -1,21 +1,23 @@ -ARG python_version=3.6.13 +ARG python_version=3.9.16 FROM python:${python_version}-slim-buster RUN apt-get update -y && \ - apt-get install -y --no-install-recommends \ - libsodium23 && \ + apt-get install -y --no-install-recommends \ + libsodium23 git curl && \ apt-get clean && \ - rm -rf /var/lib/apt/lists/* + rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app ADD requirements*.txt ./ RUN pip3 install --no-cache-dir \ - -r requirements.txt \ - -r requirements.askar.txt \ - -r requirements.bbs.txt \ - -r requirements.dev.txt + -r requirements.txt \ + -r requirements.askar.txt \ + -r requirements.bbs.txt \ + -r requirements.dev.txt \ + -r requirements.anoncreds.txt +RUN curl -sL https://github.com/Indicio-tech/anoncreds-rs/releases/download/v0.1.0-dev.9/library-linux-x86_64.tar.gz | tar -xz -C /usr/local/lib/python3.9/site-packages/anoncreds/ ADD . . diff --git a/requirements.anoncreds.txt b/requirements.anoncreds.txt new file mode 100644 index 0000000000..ac3e09e3cc --- /dev/null +++ b/requirements.anoncreds.txt @@ -0,0 +1 @@ +anoncreds@git+https://github.com/hyperledger/anoncreds-rs@3eca56d#subdirectory=wrappers/python \ No newline at end of file diff --git a/scripts/run_docker b/scripts/run_docker index 13b65ee2c6..ff02a85aa2 100755 --- a/scripts/run_docker +++ b/scripts/run_docker @@ -3,7 +3,7 @@ cd "$(dirname "$0")" || exit 1 CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" -$CONTAINER_RUNTIME build -t aries-cloudagent-run -f ../docker/Dockerfile.run .. || exit 1 +$CONTAINER_RUNTIME build --platform linux/amd64 -t aries-cloudagent-run -f ../docker/Dockerfile.run .. || exit 1 ARGS="" for PORT in $PORTS; do @@ -89,4 +89,4 @@ fi echo "" # shellcheck disable=SC2086,SC2090 -$CONTAINER_RUNTIME run --rm -ti $ARGS aries-cloudagent-run "${ACAPY_ARGUMENTS[@]}" +$CONTAINER_RUNTIME run --rm -ti --platform linux/amd64 $ARGS aries-cloudagent-run "${ACAPY_ARGUMENTS[@]}" diff --git a/setup.py b/setup.py index d4a4d26ff0..10ede89740 100644 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ def parse_requirements(filename): "askar": parse_requirements("requirements.askar.txt"), "indy": parse_requirements("requirements.indy.txt"), "bbs": parse_requirements("requirements.bbs.txt"), + "anoncreds": parse_requirements("requirements.anoncreds.txt"), "uvloop": {"uvloop": "^=0.14.0"}, }, python_requires=">=3.6.3",