diff --git a/acapy_agent/config/default_context.py b/acapy_agent/config/default_context.py index 136c79791d..c367a785d7 100644 --- a/acapy_agent/config/default_context.py +++ b/acapy_agent/config/default_context.py @@ -132,9 +132,7 @@ async def load_plugins(self, context: InjectionContext): # Currently providing admin routes only plugin_registry.register_plugin("acapy_agent.holder") - plugin_registry.register_plugin("acapy_agent.ledger") - plugin_registry.register_plugin("acapy_agent.messaging.jsonld") plugin_registry.register_plugin("acapy_agent.resolver") plugin_registry.register_plugin("acapy_agent.settings") @@ -143,6 +141,9 @@ async def load_plugins(self, context: InjectionContext): plugin_registry.register_plugin("acapy_agent.wallet") plugin_registry.register_plugin("acapy_agent.wallet.keys") + # Did management plugins + plugin_registry.register_plugin("acapy_agent.did.indy") + anoncreds_plugins = [ "acapy_agent.anoncreds", "acapy_agent.anoncreds.default.did_indy", diff --git a/acapy_agent/config/tests/test_wallet.py b/acapy_agent/config/tests/test_wallet.py index 9fe20279c3..6e08991e47 100644 --- a/acapy_agent/config/tests/test_wallet.py +++ b/acapy_agent/config/tests/test_wallet.py @@ -2,6 +2,7 @@ from acapy_agent.tests import mock +from ...core.error import StartupError from ...core.in_memory import InMemoryProfile from ...core.profile import ProfileManager, ProfileSession from ...storage.base import BaseStorage @@ -143,7 +144,7 @@ async def test_wallet_config_auto_provision(self): ): mock_mgr_open.side_effect = test_module.ProfileNotFoundError() - with self.assertRaises(test_module.ProfileNotFoundError): + with self.assertRaises(StartupError): await test_module.wallet_config(self.context, provision=False) self.context.update_settings({"auto_provision": True}) diff --git a/acapy_agent/config/wallet.py b/acapy_agent/config/wallet.py index f857b07847..774b17d0c0 100644 --- a/acapy_agent/config/wallet.py +++ b/acapy_agent/config/wallet.py @@ -3,7 +3,7 @@ import logging from typing import Tuple -from ..core.error import ProfileNotFoundError +from ..core.error import ProfileNotFoundError, StartupError from ..core.profile import Profile, ProfileManager, ProfileSession from ..storage.base import BaseStorage from ..storage.error import StorageNotFoundError @@ -29,104 +29,77 @@ } -async def wallet_config( - context: InjectionContext, provision: bool = False -) -> Tuple[Profile, DIDInfo]: - """Initialize the root profile.""" +def _create_config_with_settings(settings) -> dict: + profile_config = {} - mgr = context.inject(ProfileManager) - - settings = context.settings - profile_cfg = {} for k in CFG_MAP: pk = f"wallet.{k}" if pk in settings: - profile_cfg[k] = settings[pk] + profile_config[k] = settings[pk] # may be set by `aca-py provision --recreate` if settings.get("wallet.recreate"): - profile_cfg["auto_recreate"] = True + profile_config["auto_recreate"] = True - if provision: - profile = await mgr.provision(context, profile_cfg) - else: - try: - profile = await mgr.open(context, profile_cfg) - except ProfileNotFoundError: - if settings.get("auto_provision", False): - profile = await mgr.provision(context, profile_cfg) - provision = True - else: - raise + return profile_config - if provision: - if profile.created: - print("Created new profile") + +async def _attempt_open_profile( + profile_manager: ProfileManager, + context: InjectionContext, + profile_config: dict, + settings: dict, +) -> Tuple[Profile, bool]: + provision = False + try: + profile = await profile_manager.open(context, profile_config) + except ProfileNotFoundError: + if settings.get("auto_provision", False): + profile = await profile_manager.provision(context, profile_config) + provision = True else: - print("Opened existing profile") - print("Profile backend:", profile.backend) - print("Profile name:", profile.name) + raise StartupError( + "Profile not found. Use `aca-py start --auto-provision` to create." + ) - wallet_seed = context.settings.get("wallet.seed") - wallet_local_did = context.settings.get("wallet.local_did") - txn = await profile.transaction() - wallet = txn.inject(BaseWallet) + return (profile, provision) - public_did_info = await wallet.get_public_did() - public_did = None - if public_did_info: - public_did = public_did_info.did - if wallet_seed and seed_to_did(wallet_seed) != public_did: - if context.settings.get("wallet.replace_public_did"): - replace_did_info = await wallet.create_local_did( - method=SOV, key_type=ED25519, seed=wallet_seed - ) - public_did = replace_did_info.did - await wallet.set_public_did(public_did) - print(f"Created new public DID: {public_did}") - print(f"Verkey: {replace_did_info.verkey}") - else: - # If we already have a registered public did and it doesn't match - # the one derived from `wallet_seed` then we error out. - raise ConfigError( - "New seed provided which doesn't match the registered" - + f" public did {public_did}" - ) - # wait until ledger config to set public DID endpoint - wallet goes first - elif wallet_seed: - if wallet_local_did: - endpoint = context.settings.get("default_endpoint") - metadata = {"endpoint": endpoint} if endpoint else None - - local_did_info = await wallet.create_local_did( - method=SOV, - key_type=ED25519, - seed=wallet_seed, - metadata=metadata, - ) - local_did = local_did_info.did - if provision: - print(f"Created new local DID: {local_did}") - print(f"Verkey: {local_did_info.verkey}") - else: - public_did_info = await wallet.create_public_did( - method=SOV, key_type=ED25519, seed=wallet_seed +def _log_provision_info(profile: Profile) -> None: + print(f'{"Created new profile" if profile.created else "Opened existing profile"}') + print(f"Profile name: {profile.name} Profile backend: {profile.backend}") + + +async def _initialize_with_public_did( + public_did_info: DIDInfo, + wallet: BaseWallet, + settings: dict, + wallet_seed: str, +) -> str: + public_did = public_did_info.did + # Check did:sov seed matches public DID + if wallet_seed and (seed_to_did(wallet_seed) != public_did): + if not settings.get("wallet.replace_public_did"): + raise ConfigError( + "New seed provided which doesn't match the registered" + + f" public did {public_did}" ) - public_did = public_did_info.did - if provision: - print(f"Created new public DID: {public_did}") - print(f"Verkey: {public_did_info.verkey}") - # wait until ledger config to set public DID endpoint - wallet goes first - if provision and not wallet_local_did and not public_did: - print("No public DID") + print("Replacing public DID due to --replace-public-did flag") + replace_did_info = await wallet.create_local_did( + method=SOV, key_type=ED25519, seed=wallet_seed + ) + public_did = replace_did_info.did + await wallet.set_public_did(public_did) + print( + f"Created new public DID: {public_did}, with verkey: {replace_did_info.verkey}" # noqa: E501 + ) - # Debug settings - test_seed = context.settings.get("debug.seed") - if context.settings.get("debug.enabled"): - if not test_seed: - test_seed = "testseed000000000000000000000001" + +async def _initialize_with_debug_settings(settings: dict, wallet: BaseWallet): + test_seed = settings.get("debug.seed") + if settings.get("debug.enabled") and not test_seed: + test_seed = "testseed000000000000000000000001" if test_seed: await wallet.create_local_did( method=SOV, @@ -135,6 +108,74 @@ async def wallet_config( metadata={"endpoint": "1.2.3.4:8021"}, ) + +async def _initialize_with_seed( + settings: dict, wallet: BaseWallet, provision: bool, create_local_did: bool, seed: str +): + if create_local_did: + endpoint = settings.get("default_endpoint") + metadata = {"endpoint": endpoint} if endpoint else None + + local_did_info = await wallet.create_local_did( + method=SOV, + key_type=ED25519, + seed=seed, + metadata=metadata, + ) + local_did = local_did_info.did + if provision: + print(f"Created new local DID: {local_did}") + print(f"Verkey: {local_did_info.verkey}") + else: + public_did_info = await wallet.create_public_did( + method=SOV, key_type=ED25519, seed=seed + ) + public_did = public_did_info.did + if provision: + print(f"Created new public DID: {public_did}") + print(f"Verkey: {public_did_info.verkey}") + + +async def wallet_config( + context: InjectionContext, provision: bool = False +) -> Tuple[Profile, DIDInfo]: + """Initialize the root profile.""" + + profile_manager = context.inject(ProfileManager) + + settings = context.settings + profile_config = _create_config_with_settings(settings) + wallet_seed = settings.get("wallet.seed") + create_local_did = settings.get("wallet.local_did") + + if provision: + profile = await profile_manager.provision(context, profile_config) + else: + profile, provision = await _attempt_open_profile( + profile_manager, context, profile_config, settings + ) + + _log_provision_info(profile) + + txn = await profile.transaction() + wallet = txn.inject(BaseWallet) + public_did_info = await wallet.get_public_did() + public_did = None + + if public_did_info: + public_did = await _initialize_with_public_did( + public_did_info, wallet, settings, wallet_seed + ) + elif wallet_seed: + await _initialize_with_seed( + settings, wallet, provision, create_local_did, wallet_seed + ) + + if provision and not create_local_did and not public_did: + print("No public DID") + + await _initialize_with_debug_settings(settings, wallet) + await txn.commit() return (profile, public_did_info) diff --git a/acapy_agent/did/indy/indy_manager.py b/acapy_agent/did/indy/indy_manager.py new file mode 100644 index 0000000000..c08d82ebe8 --- /dev/null +++ b/acapy_agent/did/indy/indy_manager.py @@ -0,0 +1,90 @@ +"""DID manager for Indy.""" + +from aries_askar import AskarError, Key + +from ...core.profile import Profile +from ...utils.general import strip_did_prefix +from ...wallet.askar import CATEGORY_DID +from ...wallet.crypto import validate_seed +from ...wallet.did_method import INDY, DIDMethods +from ...wallet.did_parameters_validation import DIDParametersValidation +from ...wallet.error import WalletError +from ...wallet.key_type import ED25519, KeyType, KeyTypes +from ...wallet.util import bytes_to_b58 + + +class DidIndyManager: + """DID manager for Indy.""" + + def __init__(self, profile: Profile) -> None: + """Initialize the DID manager.""" + self.profile = profile + + async def _get_holder_defined_did(self, options: dict) -> str | None: + async with self.profile.session() as session: + did_methods = session.inject(DIDMethods) + indy_method = did_methods.from_method(INDY.method_name) + + if indy_method.holder_defined_did() and options.get("did"): + return strip_did_prefix(options.get("did")) + + return None + + async def _get_key_type(self, key_type: str) -> KeyType: + async with self.profile.session() as session: + key_types = session.inject(KeyTypes) + return key_types.from_key_type(key_type) or ED25519 + + def _create_key_pair(self, options: dict, key_type: KeyType) -> Key: + seed = options.get("seed") + if seed and not self.profile.settings.get("wallet.allow_insecure_seed"): + raise WalletError("Insecure seed is not allowed") + + if seed: + seed = validate_seed(seed) + return Key.from_secret_bytes(key_type, seed) + return Key.generate(key_type) + + async def register(self, options: dict) -> dict: + """Register a DID Indy.""" + options = options or {} + + key_type = await self._get_key_type(options.get("key_type") or ED25519) + did_validation = DIDParametersValidation(self.profile.inject(DIDMethods)) + did_validation.validate_key_type(INDY, key_type) + + key_pair = self._create_key_pair(options, key_type.key_type) + verkey_bytes = key_pair.get_public_bytes() + verkey = bytes_to_b58(verkey_bytes) + + nym = did_validation.validate_or_derive_did( + INDY, ED25519, verkey_bytes, (await self._get_holder_defined_did(options)) + ) + did = f"did:indy:{nym}" + + async with self.profile.session() as session: + try: + await session.handle.insert_key(verkey, key_pair) + await session.handle.insert( + CATEGORY_DID, + did, + value_json={ + "did": did, + "method": INDY.method_name, + "verkey": verkey, + "verkey_type": ED25519.key_type, + "metadata": {}, + }, + tags={ + "method": INDY.method_name, + "verkey": verkey, + "verkey_type": ED25519.key_type, + }, + ) + except AskarError as err: + raise WalletError(f"Error registering DID: {err}") from err + + return { + "did": did, + "verkey": verkey, + } diff --git a/acapy_agent/did/indy/routes.py b/acapy_agent/did/indy/routes.py new file mode 100644 index 0000000000..cae5e0c7ad --- /dev/null +++ b/acapy_agent/did/indy/routes.py @@ -0,0 +1,91 @@ +"""DID INDY routes.""" + +from http import HTTPStatus + +from aiohttp import web +from aiohttp_apispec import docs, request_schema, response_schema +from marshmallow import fields + +from ...admin.decorators.auth import tenant_authentication +from ...admin.request_context import AdminRequestContext +from ...did.indy.indy_manager import DidIndyManager +from ...messaging.models.openapi import OpenAPISchema +from ...wallet.error import WalletError + + +class CreateRequestSchema(OpenAPISchema): + """Parameters and validators for create DID endpoint.""" + + options = fields.Dict( + required=False, + metadata={ + "description": "Additional configuration options", + "example": { + "did": "did:indy:WRfXPg8dantKVubE3HX8pw", + "seed": "000000000000000000000000Trustee1", + "key_type": "ed25519", + }, + }, + ) + features = fields.Dict( + required=False, + metadata={ + "description": "Additional features to enable for the did.", + "example": "{}", + }, + ) + + +class CreateResponseSchema(OpenAPISchema): + """Response schema for create DID endpoint.""" + + did = fields.Str( + metadata={ + "description": "DID created", + "example": "did:indy:DFZgMggBEXcZFVQ2ZBTwdr", + } + ) + verkey = fields.Str( + metadata={ + "description": "Verification key", + "example": "BnSWTUQmdYCewSGFrRUhT6LmKdcCcSzRGqWXMPnEP168", + } + ) + + +@docs(tags=["did"], summary="Create a did:indy") +@request_schema(CreateRequestSchema()) +@response_schema(CreateResponseSchema, HTTPStatus.OK) +@tenant_authentication +async def create_indy_did(request: web.BaseRequest): + """Create a INDY DID.""" + context: AdminRequestContext = request["context"] + body = await request.json() + try: + return web.json_response( + (await DidIndyManager(context.profile).register(body.get("options"))), + ) + except WalletError as e: + raise web.HTTPBadRequest(reason=str(e)) + + +async def register(app: web.Application): + """Register routes.""" + app.add_routes([web.post("/did/indy/create", create_indy_did)]) + + +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": "did", + "description": "Endpoints for managing dids", + "externalDocs": { + "description": "Specification", + "url": "https://www.w3.org/TR/did-core/", + }, + } + ) diff --git a/acapy_agent/did/indy/tests/test_indy_manager.py b/acapy_agent/did/indy/tests/test_indy_manager.py new file mode 100644 index 0000000000..5847ea33c4 --- /dev/null +++ b/acapy_agent/did/indy/tests/test_indy_manager.py @@ -0,0 +1,103 @@ +from unittest import IsolatedAsyncioTestCase + +from aries_askar import AskarError + +from acapy_agent.core.in_memory.profile import ( + InMemoryProfile, + InMemoryProfileSession, +) +from acapy_agent.did.indy.indy_manager import DidIndyManager +from acapy_agent.tests import mock +from acapy_agent.wallet.did_method import DIDMethods +from acapy_agent.wallet.error import WalletError +from acapy_agent.wallet.key_type import KeyTypes + + +class TestIndyManager(IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.profile = InMemoryProfile.test_profile() + self.profile.context.injector.bind_instance( + DIDMethods, mock.MagicMock(auto_spec=DIDMethods) + ) + self.profile.context.injector.bind_instance(KeyTypes, KeyTypes()) + + def test_init(self): + assert DidIndyManager(self.profile) + + @mock.patch.object(InMemoryProfileSession, "handle") + async def test_register(self, mock_handle): + mock_handle.insert_key = mock.CoroutineMock() + mock_handle.insert = mock.CoroutineMock() + manager = DidIndyManager(self.profile) + result = await manager.register({}) + assert result.get("did") + assert result.get("verkey") + mock_handle.insert_key.assert_called_once() + mock_handle.insert.assert_called_once() + + # error saving key + mock_handle.insert_key.side_effect = AskarError( + code=1, message="Error saving key" + ) + with self.assertRaises(WalletError): + await manager.register({}) + + @mock.patch.object(InMemoryProfileSession, "handle") + async def test_register_with_seed_without_allow_insecure(self, mock_handle): + mock_handle.insert_key = mock.CoroutineMock() + mock_handle.insert = mock.CoroutineMock() + manager = DidIndyManager(self.profile) + with self.assertRaises(WalletError): + await manager.register({"seed": "000000000000000000000000Trustee1"}) + + @mock.patch.object(InMemoryProfileSession, "handle") + async def test_register_with_seed_with_allow_insecure(self, mock_handle): + self.profile.context.injector.bind_instance( + DIDMethods, mock.MagicMock(auto_spec=DIDMethods) + ) + self.profile.context.injector.bind_instance(KeyTypes, KeyTypes()) + self.profile.settings.set_value("wallet.allow_insecure_seed", True) + mock_handle.insert_key = mock.CoroutineMock() + mock_handle.insert = mock.CoroutineMock() + manager = DidIndyManager(self.profile) + + result = await manager.register({"seed": "000000000000000000000000Steward1"}) + assert result.get("did") == "did:indy:HPMyRCWfgav5HXtYaaJaZK" + assert result.get("verkey") + + @mock.patch.object(InMemoryProfileSession, "handle") + async def test_register_with_seed_with_key_type(self, mock_handle): + mock_handle.insert_key = mock.CoroutineMock() + mock_handle.insert = mock.CoroutineMock() + manager = DidIndyManager(self.profile) + + result = await manager.register({"key_type": "ed25519"}) + assert result.get("did") + assert result.get("verkey") + + @mock.patch.object(InMemoryProfileSession, "handle") + async def test_register_with_seed_with_defined_did(self, mock_handle): + mock_handle.insert_key = mock.CoroutineMock() + mock_handle.insert = mock.CoroutineMock() + manager = DidIndyManager(self.profile) + + result = await manager.register({"did": "did:indy:WRfXPg8dantKVubE3HX8pw"}) + assert result.get("did") == "did:indy:WRfXPg8dantKVubE3HX8pw" + assert result.get("verkey") + + @mock.patch.object(InMemoryProfileSession, "handle") + async def test_register_with_seed_with_all_options(self, mock_handle): + self.profile.settings.set_value("wallet.allow_insecure_seed", True) + mock_handle.insert_key = mock.CoroutineMock() + mock_handle.insert = mock.CoroutineMock() + manager = DidIndyManager(self.profile) + + result = await manager.register( + { + "did": "did:indy:WRfXPg8dantKVubE3HX8pw", + "key_type": "ed25519", + "seed": "000000000000000000000000Trustee1", + } + ) + assert result.get("did") == "did:indy:WRfXPg8dantKVubE3HX8pw" + assert result.get("verkey") diff --git a/acapy_agent/did/indy/tests/test_routes.py b/acapy_agent/did/indy/tests/test_routes.py new file mode 100644 index 0000000000..d401b80fb5 --- /dev/null +++ b/acapy_agent/did/indy/tests/test_routes.py @@ -0,0 +1,73 @@ +from unittest import IsolatedAsyncioTestCase + +from aiohttp import web + +from acapy_agent.admin.request_context import AdminRequestContext +from acapy_agent.core.in_memory.profile import InMemoryProfile +from acapy_agent.did.indy.indy_manager import DidIndyManager +from acapy_agent.did.indy.routes import create_indy_did +from acapy_agent.tests import mock +from acapy_agent.wallet.did_method import DIDMethods +from acapy_agent.wallet.error import WalletError + + +class TestDidIndyRoutes(IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.session_inject = {} + self.profile = InMemoryProfile.test_profile( + settings={ + "admin.admin_api_key": "secret-key", + }, + ) + self.context = AdminRequestContext.test_context(self.session_inject, self.profile) + self.request_dict = { + "context": self.context, + } + self.request = mock.MagicMock( + app={}, + match_info={}, + query={}, + __getitem__=lambda _, k: self.request_dict[k], + context=self.context, + headers={"x-api-key": "secret-key"}, + ) + + @mock.patch.object( + DidIndyManager, + "register", + return_value={"did": "did:indy:DFZgMggBEXcZFVQ2ZBTwdr", "verkey": "BnSWTUQmdYC"}, + ) + async def test_create_indy_did(self, mock_register): + self.profile.context.injector.bind_instance( + DIDMethods, mock.MagicMock(auto_spec=DIDMethods) + ) + self.request.json = mock.CoroutineMock(return_value={}) + response = await create_indy_did(self.request) + assert response.status == 200 + assert mock_register.called + + self.request.json = mock.CoroutineMock( + return_value={ + "features": {}, + "options": { + "did": "did:indy:WRfXPg8dantKVubE3HX8pw", + "key_type": "ed25519", + }, + } + ) + response = await create_indy_did(self.request) + assert response.status == 200 + assert mock_register.called + + @mock.patch.object( + DidIndyManager, + "register", + side_effect=[WalletError("Error creating DID")], + ) + async def test_create_indy_did_wallet_error(self, _): + self.profile.context.injector.bind_instance( + DIDMethods, mock.MagicMock(auto_spec=DIDMethods) + ) + self.request.json = mock.CoroutineMock(return_value={}) + with self.assertRaises(web.HTTPBadRequest): + await create_indy_did(self.request) diff --git a/acapy_agent/indy/credx/issuer.py b/acapy_agent/indy/credx/issuer.py index 8cd857df9e..09461936c1 100644 --- a/acapy_agent/indy/credx/issuer.py +++ b/acapy_agent/indy/credx/issuer.py @@ -19,6 +19,7 @@ ) from ...askar.profile import AskarProfile +from ...utils.general import strip_did_prefix from ..issuer import ( DEFAULT_CRED_DEF_TAG, DEFAULT_SIGNATURE_TYPE, @@ -78,7 +79,10 @@ async def create_schema( """ try: schema = Schema.create( - origin_did, schema_name, schema_version, attribute_names + strip_did_prefix(origin_did), + schema_name, + schema_version, + attribute_names, ) schema_id = schema.id schema_json = schema.to_json() @@ -143,7 +147,7 @@ async def create_and_store_credential_definition( ) = await asyncio.get_event_loop().run_in_executor( None, lambda: CredentialDefinition.create( - origin_did, + strip_did_prefix(origin_did), schema, signature_type or DEFAULT_SIGNATURE_TYPE, tag or DEFAULT_CRED_DEF_TAG, @@ -592,7 +596,7 @@ async def create_and_store_revocation_registry( ) = await asyncio.get_event_loop().run_in_executor( None, lambda: RevocationRegistryDefinition.create( - origin_did, + strip_did_prefix(origin_did), cred_def.raw_value, tag, revoc_def_type, diff --git a/acapy_agent/ledger/base.py b/acapy_agent/ledger/base.py index ffafe3bb31..0f5d3d1b66 100644 --- a/acapy_agent/ledger/base.py +++ b/acapy_agent/ledger/base.py @@ -2,7 +2,6 @@ import json import logging -import re from abc import ABC, ABCMeta, abstractmethod from enum import Enum from hashlib import sha256 @@ -11,6 +10,7 @@ from ..indy.issuer import DEFAULT_CRED_DEF_TAG, IndyIssuer, IndyIssuerError from ..messaging.valid import IndyDID from ..utils import sentinel +from ..utils.general import strip_did_prefix from ..wallet.did_info import DIDInfo from .endpoint_type import EndpointType from .error import ( @@ -174,11 +174,6 @@ async def rotate_public_did_keypair(self, next_seed: Optional[str] = None) -> No next_seed: seed for incoming ed25519 keypair (default random) """ - def did_to_nym(self, did: str) -> str: - """Remove the ledger's DID prefix to produce a nym.""" - if did: - return re.sub(r"^did:\w+:", "", did) - @abstractmethod async def get_wallet_public_did(self) -> DIDInfo: """Fetch the public DID from the wallet.""" @@ -462,7 +457,7 @@ async def create_and_send_credential_definition( # 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 + strip_did_prefix(public_info.did), schema, signature_type, test_tag ) ledger_cred_def = await self.fetch_credential_definition( credential_definition_id diff --git a/acapy_agent/ledger/indy_vdr.py b/acapy_agent/ledger/indy_vdr.py index d26734ec6f..8571a83b63 100644 --- a/acapy_agent/ledger/indy_vdr.py +++ b/acapy_agent/ledger/indy_vdr.py @@ -21,6 +21,7 @@ from ..storage.base import BaseStorage, StorageRecord from ..utils import sentinel from ..utils.env import storage_path +from ..utils.general import strip_did_prefix from ..wallet.base import BaseWallet, DIDInfo from ..wallet.did_posture import DIDPosture from ..wallet.error import WalletNotFoundError @@ -379,7 +380,9 @@ async def _create_schema_request( ): """Create the ledger request for publishing a schema.""" try: - schema_req = ledger.build_schema_request(public_info.did, schema_json) + schema_req = ledger.build_schema_request( + strip_did_prefix(public_info.did), schema_json + ) except VdrError as err: raise LedgerError("Exception when building schema request") from err @@ -462,7 +465,9 @@ async def fetch_schema_by_id(self, schema_id: str) -> dict: public_did = public_info.did if public_info else None try: - schema_req = ledger.build_get_schema_request(public_did, schema_id) + schema_req = ledger.build_get_schema_request( + strip_did_prefix(public_did), strip_did_prefix(schema_id) + ) except VdrError as err: raise LedgerError("Exception when building get-schema request") from err @@ -568,7 +573,9 @@ async def get_credential_definition(self, credential_definition_id: str) -> dict return await self.fetch_credential_definition(credential_definition_id) - async def fetch_credential_definition(self, credential_definition_id: str) -> dict: + async def fetch_credential_definition( + self, credential_definition_id: str + ) -> dict | None: """Get a credential definition from the ledger by id. Args: @@ -581,7 +588,7 @@ async def fetch_credential_definition(self, credential_definition_id: str) -> di try: cred_def_req = ledger.build_get_cred_def_request( - public_did, credential_definition_id + strip_did_prefix(public_did), strip_did_prefix(credential_definition_id) ) except VdrError as err: raise LedgerError("Exception when building get-cred-def request") from err @@ -630,7 +637,7 @@ async def get_key_for_did(self, did: str) -> str: Args: did: The DID to look up on the ledger or in the cache """ - nym = self.did_to_nym(did) + nym = strip_did_prefix(did) public_info = await self.get_wallet_public_did() public_did = public_info.did if public_info else None @@ -653,7 +660,7 @@ async def get_all_endpoints_for_did(self, did: str) -> dict: Args: did: The DID to look up on the ledger or in the cache """ - nym = self.did_to_nym(did) + nym = strip_did_prefix(did) public_info = await self.get_wallet_public_did() public_did = public_info.did if public_info else None try: @@ -685,7 +692,7 @@ async def get_endpoint_for_did( if not endpoint_type: endpoint_type = EndpointType.ENDPOINT - nym = self.did_to_nym(did) + nym = strip_did_prefix(did) public_info = await self.get_wallet_public_did() public_did = public_info.did if public_info else None try: @@ -746,7 +753,7 @@ async def update_endpoint_for_did( "Error cannot update endpoint when ledger is in read only mode" ) - nym = self.did_to_nym(did) + nym = strip_did_prefix(did) attr_json = await self._construct_attr_json( endpoint, endpoint_type, all_exist_endpoints, routing_keys @@ -850,7 +857,7 @@ def nym_to_did(self, nym: str) -> str: """Format a nym with the ledger's DID prefix.""" if nym: # remove any existing prefix - nym = self.did_to_nym(nym) + nym = strip_did_prefix(nym) return f"did:sov:{nym}" async def build_and_return_get_nym_request( @@ -884,7 +891,7 @@ async def rotate_public_did_keypair(self, next_seed: Optional[str] = None) -> No await txn.commit() # fetch current nym info from ledger - nym = self.did_to_nym(public_did) + nym = strip_did_prefix(public_did) try: get_nym_req = ledger.build_get_nym_request(public_did, nym) except VdrError as err: diff --git a/acapy_agent/ledger/tests/test_indy_vdr.py b/acapy_agent/ledger/tests/test_indy_vdr.py index 53def8fa51..d5666e8abf 100644 --- a/acapy_agent/ledger/tests/test_indy_vdr.py +++ b/acapy_agent/ledger/tests/test_indy_vdr.py @@ -441,7 +441,7 @@ async def test_send_credential_definition( ledger: IndyVdrLedger, ): wallet = (await ledger.profile.session()).wallet - test_did = await wallet.create_public_did(SOV, ED25519) + await wallet.create_public_did(SOV, ED25519) schema_id = "55GkHamhTU1ZbTbV2ab9DE:2:schema_name:9.1" cred_def_id = "55GkHamhTU1ZbTbV2ab9DE:3:CL:99:tag" cred_def = { diff --git a/acapy_agent/messaging/valid.py b/acapy_agent/messaging/valid.py index 894c1a819b..24117dfcf1 100644 --- a/acapy_agent/messaging/valid.py +++ b/acapy_agent/messaging/valid.py @@ -336,8 +336,8 @@ def __init__(self): class IndyDID(Regexp): """Validate value against indy DID.""" - EXAMPLE = "WgWxqztrNooG92RXvxSTWv" - PATTERN = re.compile(rf"^(did:sov:)?[{B58}]{{21,22}}$") + EXAMPLE = "did:sov:WgWxqztrNooG92RXvxSTWv" + PATTERN = re.compile(rf"^(did:(sov|indy):)?[{B58}]{{21,22}}$") def __init__(self): """Initialize the instance.""" diff --git a/acapy_agent/revocation/indy.py b/acapy_agent/revocation/indy.py index 7c1efe6e25..14fc941c9d 100644 --- a/acapy_agent/revocation/indy.py +++ b/acapy_agent/revocation/indy.py @@ -18,6 +18,8 @@ is_author_role, ) from ..storage.base import StorageNotFoundError +from ..wallet.askar import CATEGORY_DID +from ..wallet.error import WalletNotFoundError from .error import ( RevocationError, RevocationInvalidStateValueError, @@ -79,11 +81,20 @@ async def init_issuer_registry( record_id = str(uuid4()) issuer_did = cred_def_id.split(":")[0] + # Try and get a did:indy did from nym value stored as a did + async with self._profile.session() as session: + try: + indy_did = await session.handle.fetch( + CATEGORY_DID, f"did:indy:{issuer_did}" + ) + except WalletNotFoundError: + indy_did = None + record = IssuerRevRegRecord( new_with_id=True, record_id=record_id, cred_def_id=cred_def_id, - issuer_did=issuer_did, + issuer_did=indy_did.name if indy_did else issuer_did, max_cred_num=max_cred_num, revoc_def_type=revoc_def_type, tag=tag, diff --git a/acapy_agent/revocation/tests/test_indy.py b/acapy_agent/revocation/tests/test_indy.py index 0ccb161051..fed75500b9 100644 --- a/acapy_agent/revocation/tests/test_indy.py +++ b/acapy_agent/revocation/tests/test_indy.py @@ -3,6 +3,7 @@ from acapy_agent.tests import mock from ...core.in_memory import InMemoryProfile +from ...core.in_memory.profile import InMemoryProfileSession from ...ledger.base import BaseLedger from ...ledger.multiple_ledger.ledger_requests_executor import ( IndyLedgerRequestsExecutor, @@ -25,8 +26,8 @@ def setUp(self): self.profile = InMemoryProfile.test_profile() self.context = self.profile.context - Ledger = mock.MagicMock(BaseLedger, autospec=True) - self.ledger = Ledger() + ledger = mock.MagicMock(BaseLedger, autospec=True) + self.ledger = ledger() self.ledger.get_credential_definition = mock.CoroutineMock( return_value={"value": {"revocation": True}} ) @@ -44,7 +45,9 @@ def setUp(self): self.test_did = "sample-did" - async def test_init_issuer_registry(self): + @mock.patch.object(InMemoryProfileSession, "handle") + async def test_init_issuer_registry(self, mock_handle): + mock_handle.fetch = mock.CoroutineMock(return_value=None) CRED_DEF_ID = f"{self.test_did}:3:CL:1234:default" result = await self.revoc.init_issuer_registry(CRED_DEF_ID) @@ -78,9 +81,8 @@ async def test_init_issuer_registry_no_cred_def(self): self.ledger.get_credential_definition = mock.CoroutineMock(return_value=None) self.profile.context.injector.bind_instance(BaseLedger, self.ledger) - with self.assertRaises(RevocationNotSupportedError) as x_revo: + with self.assertRaises(RevocationNotSupportedError): await self.revoc.init_issuer_registry(CRED_DEF_ID) - assert x_revo.message == "Credential definition not found" async def test_init_issuer_registry_bad_size(self): CRED_DEF_ID = f"{self.test_did}:3:CL:1234:default" @@ -91,15 +93,17 @@ async def test_init_issuer_registry_bad_size(self): ) self.profile.context.injector.bind_instance(BaseLedger, self.ledger) - with self.assertRaises(RevocationRegistryBadSizeError) as x_revo: + with self.assertRaises(RevocationRegistryBadSizeError): await self.revoc.init_issuer_registry( CRED_DEF_ID, max_cred_num=1, ) - assert "Bad revocation registry size" in x_revo.message - async def test_get_active_issuer_rev_reg_record(self): + @mock.patch.object(InMemoryProfileSession, "handle") + async def test_get_active_issuer_rev_reg_record(self, mock_handle): + mock_handle.fetch = mock.CoroutineMock(return_value=None) CRED_DEF_ID = f"{self.test_did}:3:CL:1234:default" + self.profile.context.injector.bind_instance(BaseLedger, self.ledger) rec = await self.revoc.init_issuer_registry(CRED_DEF_ID) rec.revoc_reg_id = "dummy" rec.state = IssuerRevRegRecord.STATE_ACTIVE @@ -112,7 +116,7 @@ async def test_get_active_issuer_rev_reg_record(self): async def test_get_active_issuer_rev_reg_record_none(self): CRED_DEF_ID = f"{self.test_did}:3:CL:1234:default" - with self.assertRaises(StorageNotFoundError) as x_init: + with self.assertRaises(StorageNotFoundError): await self.revoc.get_active_issuer_rev_reg_record(CRED_DEF_ID) async def test_init_issuer_registry_no_revocation(self): @@ -124,11 +128,12 @@ async def test_init_issuer_registry_no_revocation(self): ) self.profile.context.injector.bind_instance(BaseLedger, self.ledger) - with self.assertRaises(RevocationNotSupportedError) as x_revo: + with self.assertRaises(RevocationNotSupportedError): await self.revoc.init_issuer_registry(CRED_DEF_ID) - assert x_revo.message == "Credential definition does not support revocation" - async def test_get_issuer_rev_reg_record(self): + @mock.patch.object(InMemoryProfileSession, "handle") + async def test_get_issuer_rev_reg_record(self, mock_handle): + mock_handle.fetch = mock.CoroutineMock(return_value=None) CRED_DEF_ID = f"{self.test_did}:3:CL:1234:default" rec = await self.revoc.init_issuer_registry(CRED_DEF_ID) @@ -144,19 +149,23 @@ async def test_get_issuer_rev_reg_record(self): result = await self.revoc.get_issuer_rev_reg_record(rec.revoc_reg_id) assert result.revoc_reg_id == "dummy" - async def test_list_issuer_registries(self): + @mock.patch.object(InMemoryProfileSession, "handle") + async def test_list_issuer_registries(self, mock_handle): + mock_handle.fetch = mock.CoroutineMock(return_value=None) CRED_DEF_ID = [f"{self.test_did}:3:CL:{i}:default" for i in (1234, 5678)] for cd_id in CRED_DEF_ID: - rec = await self.revoc.init_issuer_registry(cd_id) + await self.revoc.init_issuer_registry(cd_id) assert len(await self.revoc.list_issuer_registries()) == 2 - async def test_decommission_issuer_registries(self): + @mock.patch.object(InMemoryProfileSession, "handle") + async def test_decommission_issuer_registries(self, mock_handle): + mock_handle.fetch = mock.CoroutineMock(return_value=None) CRED_DEF_ID = [f"{self.test_did}:3:CL:{i}:default" for i in (4321, 8765)] for cd_id in CRED_DEF_ID: - rec = await self.revoc.init_issuer_registry(cd_id) + await self.revoc.init_issuer_registry(cd_id) # 2 registries, both in init state (no listener to push into active) recs = await self.revoc.list_issuer_registries() @@ -215,8 +224,6 @@ async def test_decommission_issuer_registries(self): assert rev_reg_ids == decomm_rev_reg_ids async def test_get_ledger_registry(self): - CRED_DEF_ID = "{self.test_did}:3:CL:1234:default" - with mock.patch.object( RevocationRegistry, "from_definition", mock.MagicMock() ) as mock_from_def: diff --git a/acapy_agent/utils/general.py b/acapy_agent/utils/general.py index 7c01793a07..043694dada 100644 --- a/acapy_agent/utils/general.py +++ b/acapy_agent/utils/general.py @@ -1,5 +1,6 @@ """Utility functions for the admin server.""" +import re from hmac import compare_digest @@ -8,3 +9,9 @@ def const_compare(string1, string2): if string1 is None or string2 is None: return False return compare_digest(string1.encode(), string2.encode()) + + +def strip_did_prefix(did: str) -> str | None: + """Strip the DID prefix from a DID.""" + if did: + return re.sub(r"^did:\w+:", "", did) diff --git a/acapy_agent/wallet/askar.py b/acapy_agent/wallet/askar.py index 69b5cb109e..7216383ca6 100644 --- a/acapy_agent/wallet/askar.py +++ b/acapy_agent/wallet/askar.py @@ -17,7 +17,7 @@ from .base import BaseWallet, DIDInfo, KeyInfo from .crypto import sign_message, validate_seed, verify_signed_message from .did_info import INVITATION_REUSE_KEY -from .did_method import SOV, DIDMethod, DIDMethods +from .did_method import INDY, SOV, DIDMethod, DIDMethods from .did_parameters_validation import DIDParametersValidation from .error import WalletDuplicateError, WalletError, WalletNotFoundError from .key_type import BLS12381G2, ED25519, X25519, KeyType, KeyTypes @@ -447,7 +447,7 @@ async def replace_local_did_metadata(self, did: str, metadata: dict): except AskarError as err: raise WalletError("Error updating DID metadata") from err - async def get_public_did(self) -> DIDInfo: + async def get_public_did(self) -> DIDInfo | None: """Retrieve the public DID. Returns: @@ -582,8 +582,10 @@ async def set_did_endpoint( """ did_info = await self.get_local_did(did) - if did_info.method != SOV: - raise WalletError("Setting DID endpoint is only allowed for did:sov DIDs") + if did_info.method != SOV and did_info.method != INDY: + raise WalletError( + "Setting DID endpoint is only allowed for did:sov or did:indy DIDs" + ) metadata = {**did_info.metadata} if not endpoint_type: endpoint_type = EndpointType.ENDPOINT diff --git a/acapy_agent/wallet/base.py b/acapy_agent/wallet/base.py index 2f883fd2ea..c92ecf6bd1 100644 --- a/acapy_agent/wallet/base.py +++ b/acapy_agent/wallet/base.py @@ -214,7 +214,7 @@ async def create_public_did( return await self.set_public_did(did_info) @abstractmethod - async def get_public_did(self) -> DIDInfo: + async def get_public_did(self) -> DIDInfo | None: """Retrieve the public DID. Returns: diff --git a/acapy_agent/wallet/crypto.py b/acapy_agent/wallet/crypto.py index 0ceef63a91..13585d111a 100644 --- a/acapy_agent/wallet/crypto.py +++ b/acapy_agent/wallet/crypto.py @@ -1,5 +1,6 @@ """Cryptography functions used by BasicWallet.""" +import hashlib import re from collections import OrderedDict from typing import Callable, List, Optional, Sequence, Tuple, Union @@ -16,6 +17,7 @@ sign_messages_bls12381g2, verify_signed_messages_bls12381g2, ) +from .did_method import INDY, SOV, DIDMethod from .error import WalletError from .key_type import BLS12381G2, ED25519, KeyType from .util import b58_to_bytes, b64_to_bytes, bytes_to_b58, random_seed @@ -63,11 +65,12 @@ def create_ed25519_keypair(seed: Optional[bytes] = None) -> Tuple[bytes, bytes]: return pk, sk -def seed_to_did(seed: str) -> str: +def seed_to_did(seed: str, method: Optional[DIDMethod] = SOV) -> str: """Derive a DID from a seed value. Args: seed: The seed to derive + method: The DID method to use Returns: The DID derived from the seed @@ -75,8 +78,11 @@ def seed_to_did(seed: str) -> str: """ seed = validate_seed(seed) verkey, _ = create_ed25519_keypair(seed) - did = bytes_to_b58(verkey[:16]) - return did + if method == SOV: + return bytes_to_b58(verkey[:16]) + if method == INDY: + return f"did:indy:{bytes_to_b58(hashlib.sha256(verkey).digest()[:16])}" + raise WalletError(f"Unsupported DID method: {method.method_name}") def did_is_self_certified(did: str, verkey: str) -> bool: diff --git a/acapy_agent/wallet/did_method.py b/acapy_agent/wallet/did_method.py index bf6ff57304..e20dc9eeb6 100644 --- a/acapy_agent/wallet/did_method.py +++ b/acapy_agent/wallet/did_method.py @@ -65,6 +65,12 @@ def holder_defined_did(self) -> HolderDefinedDid: rotation=True, holder_defined_did=HolderDefinedDid.ALLOWED, ) +INDY = DIDMethod( + name="indy", + key_types=[ED25519], + rotation=True, + holder_defined_did=HolderDefinedDid.ALLOWED, +) KEY = DIDMethod( name="key", key_types=[ED25519, BLS12381G2], @@ -98,6 +104,7 @@ def __init__(self) -> None: """Construct did method registry.""" self._registry: Dict[str, DIDMethod] = { SOV.method_name: SOV, + INDY.method_name: INDY, KEY.method_name: KEY, WEB.method_name: WEB, PEER2.method_name: PEER2, diff --git a/acapy_agent/wallet/did_parameters_validation.py b/acapy_agent/wallet/did_parameters_validation.py index baf492a080..5eb28ebf76 100644 --- a/acapy_agent/wallet/did_parameters_validation.py +++ b/acapy_agent/wallet/did_parameters_validation.py @@ -1,9 +1,11 @@ """Tooling to validate DID creation parameters.""" +import hashlib from typing import Optional from acapy_agent.did.did_key import DIDKey from acapy_agent.wallet.did_method import ( + INDY, KEY, SOV, DIDMethod, @@ -60,5 +62,7 @@ def validate_or_derive_did( return DIDKey.from_public_key(verkey, key_type).did elif method == SOV: return bytes_to_b58(verkey[:16]) if not did else did + elif method == INDY: + return bytes_to_b58(hashlib.sha256(verkey).digest()[:16]) if not did else did return did diff --git a/acapy_agent/wallet/in_memory.py b/acapy_agent/wallet/in_memory.py index f798e2264a..2b77eb1afa 100644 --- a/acapy_agent/wallet/in_memory.py +++ b/acapy_agent/wallet/in_memory.py @@ -440,7 +440,7 @@ def _get_private_key(self, verkey: str) -> bytes: raise WalletError("Private key not found for verkey: {}".format(verkey)) - async def get_public_did(self) -> DIDInfo: + async def get_public_did(self) -> DIDInfo | None: """Retrieve the public DID. Returns: diff --git a/acapy_agent/wallet/routes.py b/acapy_agent/wallet/routes.py index 00bc48cfb1..595fb3fa6f 100644 --- a/acapy_agent/wallet/routes.py +++ b/acapy_agent/wallet/routes.py @@ -71,7 +71,16 @@ ) from .base import BaseWallet from .did_info import DIDInfo -from .did_method import KEY, PEER2, PEER4, SOV, DIDMethod, DIDMethods, HolderDefinedDid +from .did_method import ( + INDY, + KEY, + PEER2, + PEER4, + SOV, + DIDMethod, + DIDMethods, + HolderDefinedDid, +) from .did_posture import DIDPosture from .error import WalletError, WalletNotFoundError from .key_type import BLS12381G2, ED25519, KeyTypes @@ -584,6 +593,12 @@ async def wallet_create_did(request: web.BaseRequest): reason=f"method {body.get('method')} is not supported by the agent." ) + # Don't support Indy DID method from this endpoint + if method.method_name == INDY.method_name: + raise web.HTTPForbidden( + reason="Indy did method is supported from /did/indy/create endpoint." + ) + key_types = session.inject(KeyTypes) # set default method and key type for backwards compat key_type = ( diff --git a/acapy_agent/wallet/tests/test_askar.py b/acapy_agent/wallet/tests/test_askar.py new file mode 100644 index 0000000000..dcc567f60f --- /dev/null +++ b/acapy_agent/wallet/tests/test_askar.py @@ -0,0 +1,58 @@ +from unittest import IsolatedAsyncioTestCase + +from acapy_agent.core.in_memory.profile import ( + InMemoryProfile, + InMemoryProfileSession, +) +from acapy_agent.ledger.base import BaseLedger +from acapy_agent.tests import mock +from acapy_agent.wallet.askar import AskarWallet +from acapy_agent.wallet.did_info import DIDInfo +from acapy_agent.wallet.did_method import INDY, SOV, WEB +from acapy_agent.wallet.error import WalletError +from acapy_agent.wallet.key_type import ED25519 + + +class TestAskar(IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.profile = InMemoryProfile() + self.session = InMemoryProfileSession(self.profile) + + def test_init(self): + wallet = AskarWallet(self.session) + assert wallet.session == self.session + + async def test_set_did_endpoint(self): + wallet = AskarWallet(self.session) + wallet.replace_local_did_metadata = mock.CoroutineMock() + + # Set endpoint for a Sov DID + sov_did_info = DIDInfo("example123", "verkey", {}, SOV, ED25519.key_type) + wallet.get_local_did = mock.CoroutineMock(return_value=sov_did_info) + wallet.get_public_did = mock.CoroutineMock(return_value=sov_did_info) + await wallet.set_did_endpoint( + "did:example:123", + "http://example.com", + mock.MagicMock(BaseLedger, autospec=True), + ) + + # Set endpoint for an Indy DID + indy_did_info = DIDInfo("did:indy:example", "verkey", {}, INDY, ED25519.key_type) + wallet.get_local_did = mock.CoroutineMock(return_value=indy_did_info) + wallet.get_public_did = mock.CoroutineMock(return_value=indy_did_info) + await wallet.set_did_endpoint( + "did:example:123", + "http://example.com", + mock.MagicMock(BaseLedger, autospec=True), + ) + + # Set endpoint for a Web DID should fail + web_did_info = DIDInfo("did:web:example:123", "verkey", {}, WEB, ED25519.key_type) + wallet.get_local_did = mock.CoroutineMock(return_value=web_did_info) + wallet.get_public_did = mock.CoroutineMock(return_value=web_did_info) + with self.assertRaises(WalletError): + await wallet.set_did_endpoint( + "did:example:123", + "http://example.com", + mock.MagicMock(BaseLedger, autospec=True), + ) diff --git a/acapy_agent/wallet/tests/test_routes.py b/acapy_agent/wallet/tests/test_routes.py index c24ae77ef2..cba36ffa5c 100644 --- a/acapy_agent/wallet/tests/test_routes.py +++ b/acapy_agent/wallet/tests/test_routes.py @@ -168,6 +168,13 @@ async def test_create_did_unsupported_key_type(self): with self.assertRaises(test_module.web.HTTPForbidden): await test_module.wallet_create_did(self.request) + async def test_create_did_indy(self): + self.request.json = mock.CoroutineMock( + return_value={"method": "indy", "options": {"key_type": ED25519.key_type}} + ) + with self.assertRaises(test_module.web.HTTPForbidden): + await test_module.wallet_create_did(self.request) + async def test_create_did_method_requires_user_defined_did(self): # given did_custom = DIDMethod( diff --git a/scenarios/examples/did_indy_issuance_and_revocation/docker-compose.yml b/scenarios/examples/did_indy_issuance_and_revocation/docker-compose.yml new file mode 100644 index 0000000000..67e4424893 --- /dev/null +++ b/scenarios/examples/did_indy_issuance_and_revocation/docker-compose.yml @@ -0,0 +1,89 @@ + services: + alice: + image: acapy-test + ports: + - "3001:3001" + command: > + start + --label Alice + --inbound-transport http 0.0.0.0 3000 + --outbound-transport http + --endpoint http://alice:3000 + --admin 0.0.0.0 3001 + --admin-insecure-mode + --tails-server-base-url http://tails:6543 + --genesis-url http://test.bcovrin.vonx.io/genesis + --wallet-type askar + --wallet-name alice + --wallet-key insecure + --auto-provision + --log-level info + --debug-webhooks + --notify-revocation + healthcheck: + test: curl -s -o /dev/null -w '%{http_code}' "http://localhost:3001/status/live" | grep "200" > /dev/null + start_period: 30s + interval: 7s + timeout: 5s + retries: 5 + depends_on: + tails: + condition: service_started + + bob: + image: acapy-test + ports: + - "3002:3001" + command: > + start + --label Bob + --inbound-transport http 0.0.0.0 3000 + --outbound-transport http + --endpoint http://bob:3000 + --admin 0.0.0.0 3001 + --admin-insecure-mode + --tails-server-base-url http://tails:6543 + --genesis-url http://test.bcovrin.vonx.io/genesis + --wallet-type askar + --wallet-name bob + --wallet-key insecure + --auto-provision + --log-level info + --debug-webhooks + --monitor-revocation-notification + healthcheck: + test: curl -s -o /dev/null -w '%{http_code}' "http://localhost:3001/status/live" | grep "200" > /dev/null + start_period: 30s + interval: 7s + timeout: 5s + retries: 5 + + example: + container_name: controller + build: + context: ../.. + environment: + - ALICE=http://alice:3001 + - BOB=http://bob:3001 + volumes: + - ./example.py:/usr/src/app/example.py:ro,z + command: python -m example + depends_on: + alice: + condition: service_healthy + bob: + condition: service_healthy + + tails: + image: ghcr.io/bcgov/tails-server:latest + ports: + - 6543:6543 + environment: + - GENESIS_URL=http://test.bcovrin.vonx.io/genesis + command: > + tails-server + --host 0.0.0.0 + --port 6543 + --storage-path /tmp/tails-files + --log-level INFO + diff --git a/scenarios/examples/did_indy_issuance_and_revocation/example.py b/scenarios/examples/did_indy_issuance_and_revocation/example.py new file mode 100644 index 0000000000..407de5e2a5 --- /dev/null +++ b/scenarios/examples/did_indy_issuance_and_revocation/example.py @@ -0,0 +1,122 @@ +"""Minimal reproducible example script. + +This script is for you to use to reproduce a bug or demonstrate a feature. +""" + +import asyncio +import json +from dataclasses import dataclass +from os import getenv + +from acapy_controller import Controller +from acapy_controller.logging import logging_to_stdout +from acapy_controller.models import V20PresExRecord +from acapy_controller.protocols import ( + DIDResult, + didexchange, + indy_anoncred_credential_artifacts, + indy_anoncreds_publish_revocation, + indy_anoncreds_revoke, + indy_issue_credential_v2, + indy_present_proof_v2, + params, +) +from aiohttp import ClientSession + +ALICE = getenv("ALICE", "http://alice:3001") +BOB = getenv("BOB", "http://bob:3001") + + +def summary(presentation: V20PresExRecord) -> str: + """Summarize a presentation exchange record.""" + request = presentation.pres_request + return "Summary: " + json.dumps( + { + "state": presentation.state, + "verified": presentation.verified, + "presentation_request": request.dict(by_alias=True) if request else None, + }, + indent=2, + sort_keys=True, + ) + + +@dataclass +class IndyDidCreateResponse: + """Response from creating a DID.""" + + did: str + verkey: str + + +async def main(): + """Test Controller protocols.""" + async with Controller(base_url=ALICE) as alice, Controller(base_url=BOB) as bob: + # Connecting + alice_conn, bob_conn = await didexchange(alice, bob) + + # Issuance prep + config = (await alice.get("/status/config"))["config"] + genesis_url = config.get("ledger.genesis_url") + public_did = (await alice.get("/wallet/did/public", response=DIDResult)).result + if not public_did: + public_did = await alice.post( + "/did/indy/create", + json={}, + response=IndyDidCreateResponse, + ) + assert public_did + + async with ClientSession() as session: + register_url = genesis_url.replace("/genesis", "/register") + async with session.post( + register_url, + json={ + "did": public_did.did, + "verkey": public_did.verkey, + "alias": None, + "role": "ENDORSER", + }, + ) as resp: + assert resp.ok + + await alice.post("/wallet/did/public", params=params(did=public_did.did)) + _, cred_def = await indy_anoncred_credential_artifacts( + alice, + ["firstname", "lastname"], + support_revocation=True, + ) + + # Issue a credential + alice_cred_ex, _ = await indy_issue_credential_v2( + alice, + bob, + alice_conn.connection_id, + bob_conn.connection_id, + cred_def.credential_definition_id, + {"firstname": "Bob", "lastname": "Builder"}, + ) + + # Present the the credential's attributes + await indy_present_proof_v2( + bob, + alice, + bob_conn.connection_id, + alice_conn.connection_id, + requested_attributes=[{"name": "firstname"}], + ) + + # Revoke credential + await indy_anoncreds_revoke( + alice, + cred_ex=alice_cred_ex, + holder_connection_id=alice_conn.connection_id, + notify=True, + ) + await indy_anoncreds_publish_revocation(alice, cred_ex=alice_cred_ex) + await bob.record(topic="revocation-notification") + + +if __name__ == "__main__": + logging_to_stdout() + asyncio.run(main())