diff --git a/aries_cloudagent/config/argparse.py b/aries_cloudagent/config/argparse.py index c7f24c8a62..9b8f33316f 100644 --- a/aries_cloudagent/config/argparse.py +++ b/aries_cloudagent/config/argparse.py @@ -1166,19 +1166,6 @@ def add_arguments(self, parser: ArgumentParser): "using unencrypted rather than encrypted tags" ), ) - parser.add_argument( - "--emit-did-peer-2", - action="store_true", - env_var="ACAPY_EMIT_DID_PEER_2", - help=("Emit did:peer:2 DIDs in DID Exchange Protocol"), - ) - - parser.add_argument( - "--emit-did-peer-4", - action="store_true", - env_var="ACAPY_EMIT_DID_PEER_4", - help=("Emit did:peer:4 DIDs in DID Exchange Protocol"), - ) def get_settings(self, args: Namespace) -> dict: """Get protocol settings.""" @@ -1246,11 +1233,6 @@ def get_settings(self, args: Namespace) -> dict: settings["exch_use_unencrypted_tags"] = True environ["EXCH_UNENCRYPTED_TAGS"] = "True" - if args.emit_did_peer_2: - settings["emit_did_peer_2"] = True - if args.emit_did_peer_4: - settings["emit_did_peer_4"] = True - return settings diff --git a/aries_cloudagent/connections/base_manager.py b/aries_cloudagent/connections/base_manager.py index f771752e46..dc59d54c49 100644 --- a/aries_cloudagent/connections/base_manager.py +++ b/aries_cloudagent/connections/base_manager.py @@ -53,7 +53,7 @@ from ..wallet.base import BaseWallet from ..wallet.crypto import create_keypair, seed_to_did from ..wallet.did_info import INVITATION_REUSE_KEY, DIDInfo, KeyInfo -from ..wallet.did_method import PEER2, PEER4, SOV +from ..wallet.did_method import PEER2, PEER4, SOV, DIDMethod from ..wallet.error import WalletNotFoundError from ..wallet.key_type import ED25519 from ..wallet.util import b64_to_bytes, bytes_to_b58 @@ -245,19 +245,20 @@ async def create_did_peer_2( async def fetch_invitation_reuse_did( self, - did_method: str, - ) -> DIDDoc: + did_method: DIDMethod, + ) -> Optional[DIDInfo]: """Fetch a DID from the wallet to use across multiple invitations. Args: did_method: The DID method used (e.g. PEER2 or PEER4) Returns: - The `DIDDoc` instance, or "None" if no DID is found + The `DIDInfo` instance, or "None" if no DID is found """ did_info = None async with self._profile.session() as session: wallet = session.inject(BaseWallet) + # TODO Iterating through all DIDs is problematic did_list = await wallet.get_local_dids() for did in did_list: if did.method == did_method and INVITATION_REUSE_KEY in did.metadata: diff --git a/aries_cloudagent/core/tests/test_conductor.py b/aries_cloudagent/core/tests/test_conductor.py index 5414c07bea..55a8ab0c4b 100644 --- a/aries_cloudagent/core/tests/test_conductor.py +++ b/aries_cloudagent/core/tests/test_conductor.py @@ -1149,6 +1149,8 @@ async def test_print_invite_connection(self): "debug.print_connections_invitation": True, "invite_base_url": "http://localhost", "wallet.type": "askar", + "default_endpoint": "http://localhost", + "default_label": "test", } ) conductor = test_module.Conductor(builder) diff --git a/aries_cloudagent/messaging/agent_message.py b/aries_cloudagent/messaging/agent_message.py index ccb5895183..9aee776528 100644 --- a/aries_cloudagent/messaging/agent_message.py +++ b/aries_cloudagent/messaging/agent_message.py @@ -335,7 +335,7 @@ def assign_thread_from(self, msg: "AgentMessage"): pthid = thread and thread.pthid self.assign_thread_id(thid, pthid) - def assign_thread_id(self, thid: str, pthid: Optional[str] = None): + def assign_thread_id(self, thid: Optional[str] = None, pthid: Optional[str] = None): """Assign a specific thread ID. Args: @@ -450,7 +450,6 @@ class Meta: # Avoid clobbering keywords _type = fields.Str( data_key="@type", - dump_only=True, required=False, metadata={ "description": "Message type", diff --git a/aries_cloudagent/messaging/tests/test_agent_message.py b/aries_cloudagent/messaging/tests/test_agent_message.py index bf3ee6fa85..8e67e1e63b 100644 --- a/aries_cloudagent/messaging/tests/test_agent_message.py +++ b/aries_cloudagent/messaging/tests/test_agent_message.py @@ -210,12 +210,12 @@ class BadImplementationClass(AgentMessageSchema): def test_extract_decorators_x(self): for serial in [ { - "@type": "signed-agent-message", + "@type": "doc/proto/1.0/signed-agent-message", "@id": "030ac9e6-0d60-49d3-a8c6-e7ce0be8df5a", "value": "Test value", }, { - "@type": "signed-agent-message", + "@type": "doc/proto/1.0/signed-agent-message", "@id": "030ac9e6-0d60-49d3-a8c6-e7ce0be8df5a", "value": "Test value", "value~sig": { @@ -231,7 +231,7 @@ def test_extract_decorators_x(self): }, }, { - "@type": "signed-agent-message", + "@type": "doc/proto/1.0/signed-agent-message", "@id": "030ac9e6-0d60-49d3-a8c6-e7ce0be8df5a", "superfluous~sig": { "@type": DIDCommPrefix.qualify_current( @@ -251,7 +251,7 @@ def test_extract_decorators_x(self): def test_serde(self): serial = { - "@type": "signed-agent-message", + "@type": "doc/proto/1.0/signed-agent-message", "@id": "030ac9e6-0d60-49d3-a8c6-e7ce0be8df5a", "value~sig": { "@type": DIDCommPrefix.qualify_current( diff --git a/aries_cloudagent/messaging/util.py b/aries_cloudagent/messaging/util.py index 7c924f6ef3..ac2feb5cae 100644 --- a/aries_cloudagent/messaging/util.py +++ b/aries_cloudagent/messaging/util.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta, timezone from hashlib import sha256 from math import floor -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Union LOGGER = logging.getLogger(__name__) @@ -151,7 +151,7 @@ def canon(raw_attr_name: str) -> str: def get_proto_default_version( versions: List[Dict[str, Any]], major_version: int = 1 -) -> Optional[str]: +) -> str: """Return default protocol version from version definition list.""" for version in versions: @@ -160,4 +160,4 @@ def get_proto_default_version( default_minor_version = version["current_minor_version"] return f"{default_major_version}.{default_minor_version}" - return None + return "1.0" diff --git a/aries_cloudagent/multitenant/admin/routes.py b/aries_cloudagent/multitenant/admin/routes.py index 889caefaf2..b2f8210270 100644 --- a/aries_cloudagent/multitenant/admin/routes.py +++ b/aries_cloudagent/multitenant/admin/routes.py @@ -36,8 +36,6 @@ "ACAPY_AUTO_VERIFY_PRESENTATION": "debug.auto_verify_presentation", "ACAPY_AUTO_WRITE_TRANSACTIONS": "endorser.auto_write", "ACAPY_CREATE_REVOCATION_TRANSACTIONS": "endorser.auto_create_rev_reg", - "ACAPY_EMIT_DID_PEER_2": "emit_did_peer_2", - "ACAPY_EMIT_DID_PEER_4": "emit_did_peer_4", "ACAPY_ENDORSER_ALIAS": "endorser.endorser_alias", "ACAPY_ENDORSER_INVITATION": "endorser.endorser_invitation", "ACAPY_ENDORSER_PUBLIC_DID": "endorser.endorser_public_did", @@ -63,8 +61,6 @@ "auto-respond-messages": "debug.auto_respond_messages", "auto-verify-presentation": "debug.auto_verify_presentation", "auto-write-transactions": "endorser.auto_write", - "emit-did-peer-2": "emit_did_peer_2", - "emit-did-peer-4": "emit_did_peer_4", "endorser-alias": "endorser.endorser_alias", "endorser-invitation": "endorser.endorser_invitation", "endorser-protocol-role": "endorser.protocol_role", diff --git a/aries_cloudagent/protocols/coordinate_mediation/v1_0/route_manager.py b/aries_cloudagent/protocols/coordinate_mediation/v1_0/route_manager.py index 4a21fa8c59..b3fb04cb29 100644 --- a/aries_cloudagent/protocols/coordinate_mediation/v1_0/route_manager.py +++ b/aries_cloudagent/protocols/coordinate_mediation/v1_0/route_manager.py @@ -227,9 +227,16 @@ async def route_invitation( raise ValueError("Expected connection to have invitation_key") - async def route_verkey(self, profile: Profile, verkey: str): + async def route_verkey( + self, + profile: Profile, + verkey: str, + mediation_record: Optional[MediationRecord] = None, + ): """Establish routing for a public DID.""" - return await self._route_for_key(profile, verkey, skip_if_exists=True) + return await self._route_for_key( + profile, verkey, mediation_record, skip_if_exists=True + ) async def route_public_did(self, profile: Profile, verkey: str): """Establish routing for a public DID. diff --git a/aries_cloudagent/protocols/coordinate_mediation/v1_0/tests/test_route_manager.py b/aries_cloudagent/protocols/coordinate_mediation/v1_0/tests/test_route_manager.py index ac23e8c4ef..9dfaf7d611 100644 --- a/aries_cloudagent/protocols/coordinate_mediation/v1_0/tests/test_route_manager.py +++ b/aries_cloudagent/protocols/coordinate_mediation/v1_0/tests/test_route_manager.py @@ -432,7 +432,7 @@ async def test_route_public_did(profile: Profile, route_manager: RouteManager): async def test_route_verkey(profile: Profile, route_manager: RouteManager): await route_manager.route_verkey(profile, "test-verkey") route_manager._route_for_key.assert_called_once_with( - profile, "test-verkey", skip_if_exists=True + profile, "test-verkey", None, skip_if_exists=True ) diff --git a/aries_cloudagent/protocols/didexchange/v1_0/manager.py b/aries_cloudagent/protocols/didexchange/v1_0/manager.py index b10f326772..0b12931e12 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/manager.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/manager.py @@ -51,6 +51,8 @@ class LegacyHandlingFallback(DIDXManagerError): class DIDXManager(BaseConnectionManager): """Class for managing connections under RFC 23 (DID exchange).""" + SUPPORTED_USE_DID_METHODS = ("did:peer:2", "did:peer:4") + def __init__(self, profile: Profile): """Initialize a DIDXManager. @@ -191,6 +193,8 @@ async def create_request_implicit( goal: Optional[str] = None, auto_accept: bool = False, protocol: Optional[str] = None, + use_did: Optional[str] = None, + use_did_method: Optional[str] = None, ) -> ConnRecord: """Create and send a request against a public DID only (no explicit invitation). @@ -208,32 +212,49 @@ async def create_request_implicit( The new `ConnRecord` instance """ - my_public_info = None - if use_public_did: - async with self.profile.session() as session: - wallet = session.inject(BaseWallet) - my_public_info = await wallet.get_public_did() - if not my_public_info: + + if use_did and use_did_method: + raise DIDXManagerError("Cannot specify both use_did and use_did_method") + + if use_public_did and use_did: + raise DIDXManagerError("Cannot specify both use_public_did and use_did") + + if use_public_did and use_did_method: + raise DIDXManagerError( + "Cannot specify both use_public_did and use_did_method" + ) + + my_info = None + async with self.profile.session() as session: + wallet = session.inject(BaseWallet) + if use_public_did: + my_info = await wallet.get_public_did() + if not my_info: raise WalletError("No public DID configured") if ( - my_public_info.did == their_public_did - or f"did:sov:{my_public_info.did}" == their_public_did + my_info.did == their_public_did + or f"did:sov:{my_info.did}" == their_public_did ): raise DIDXManagerError( "Cannot connect to yourself through public DID" ) + elif use_did: + my_info = await wallet.get_local_did(use_did) + + if my_info: try: await ConnRecord.retrieve_by_did( session, their_did=their_public_did, - my_did=my_public_info.did, + my_did=my_info.did, ) raise DIDXManagerError( "Connection already exists for their_did " - f"{their_public_did} and my_did {my_public_info.did}" + f"{their_public_did} and my_did {my_info.did}" ) except StorageNotFoundError: pass + auto_accept = bool( auto_accept or ( @@ -244,7 +265,7 @@ async def create_request_implicit( protocol = protocol or DIDEX_1_0 conn_rec = ConnRecord( my_did=( - my_public_info.did if my_public_info else None + my_info.did if my_info else None ), # create-request will fill in on local DID creation their_did=their_public_did, their_label=None, @@ -263,6 +284,7 @@ async def create_request_implicit( mediation_id=mediation_id, goal_code=goal_code, goal=goal, + use_did_method=use_did_method, ) conn_rec.request_id = request._id conn_rec.state = ConnRecord.State.REQUEST.rfc160 @@ -282,6 +304,7 @@ async def create_request( mediation_id: Optional[str] = None, goal_code: Optional[str] = None, goal: Optional[str] = None, + use_did_method: Optional[str] = None, ) -> DIDXRequest: """Create a new connection request for a previously-received invitation. @@ -297,6 +320,12 @@ async def create_request( A new `DIDXRequest` message to send to the other agent """ + if use_did_method and use_did_method not in self.SUPPORTED_USE_DID_METHODS: + raise DIDXManagerError( + f"Unsupported use_did_method: {use_did_method}. Supported methods: " + f"{self.SUPPORTED_USE_DID_METHODS}" + ) + # Mediation Support mediation_records = await self._route_manager.mediation_records_for_connection( self.profile, @@ -331,15 +360,16 @@ async def create_request( conn_rec, my_endpoints, mediation_records ) else: - emit_did_peer_2 = bool(self.profile.settings.get("emit_did_peer_2")) - emit_did_peer_4 = bool(self.profile.settings.get("emit_did_peer_4")) + if conn_rec.accept == ConnRecord.ACCEPT_AUTO or use_did_method is None: + # If we're auto accepting or engaging in 1.1 without setting a + # use_did_method, default to did:peer:4 + use_did_method = "did:peer:4" try: did, attach = await self._qualified_did_with_fallback( conn_rec, my_endpoints, mediation_records, - emit_did_peer_2, - emit_did_peer_4, + use_did_method, ) except LegacyHandlingFallback: did, attach = await self._legacy_did_with_attached_doc( @@ -377,20 +407,13 @@ async def _qualified_did_with_fallback( conn_rec: ConnRecord, my_endpoints: Sequence[str], mediation_records: List[MediationRecord], - emit_did_peer_2: bool, - emit_did_peer_4: bool, + use_did_method: Optional[str] = None, signing_key: Optional[str] = None, ) -> Tuple[str, Optional[AttachDecorator]]: """Create DID Exchange request using a qualified DID. Fall back to unqualified DID if settings don't cause did:peer emission. """ - if emit_did_peer_2 and emit_did_peer_4: - self._logger.warning( - "emit_did_peer_2 and emit_did_peer_4 both set, \ - using did:peer:4" - ) - if conn_rec.my_did: # DID should be public or qualified async with self.profile.session() as session: wallet = session.inject(BaseWallet) @@ -404,13 +427,14 @@ async def _qualified_did_with_fallback( raise LegacyHandlingFallback( "DID has been previously set and not public or qualified" ) - elif emit_did_peer_4: + elif use_did_method == "did:peer:4": my_info = await self.create_did_peer_4(my_endpoints, mediation_records) conn_rec.my_did = my_info.did - elif emit_did_peer_2: + elif use_did_method == "did:peer:2": my_info = await self.create_did_peer_2(my_endpoints, mediation_records) conn_rec.my_did = my_info.did else: + # We shouldn't hit this condition in practice raise LegacyHandlingFallback( "Use of qualified DIDs not set according to settings" ) @@ -771,14 +795,12 @@ async def create_response( my_endpoints.append(default_endpoint) my_endpoints.extend(self.profile.settings.get("additional_endpoints", [])) - respond_with_did_peer_2 = bool( - self.profile.settings.get("emit_did_peer_2") - or (conn_rec.their_did and conn_rec.their_did.startswith("did:peer:2")) - ) - respond_with_did_peer_4 = bool( - self.profile.settings.get("emit_did_peer_4") - or (conn_rec.their_did and conn_rec.their_did.startswith("did:peer:4")) - ) + if conn_rec.their_did and conn_rec.their_did.startswith("did:peer:2"): + use_did_method = "did:peer:2" + elif conn_rec.their_did and conn_rec.their_did.startswith("did:peer:4"): + use_did_method = "did:peer:4" + else: + use_did_method = None if use_public_did: async with self.profile.session() as session: @@ -804,8 +826,7 @@ async def create_response( conn_rec, my_endpoints, mediation_records, - respond_with_did_peer_2, - respond_with_did_peer_4, + use_did_method=use_did_method, signing_key=conn_rec.invitation_key, ) response = DIDXResponse(did=did, did_rotate_attach=attach) diff --git a/aries_cloudagent/protocols/didexchange/v1_0/routes.py b/aries_cloudagent/protocols/didexchange/v1_0/routes.py index 8f81bd715f..075089366b 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/routes.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/routes.py @@ -10,7 +10,6 @@ request_schema, response_schema, ) - from marshmallow import fields, validate from ....admin.request_context import AdminRequestContext @@ -26,6 +25,7 @@ UUID4_VALIDATE, ) from ....storage.error import StorageError, StorageNotFoundError +from ....wallet.base import BaseWallet from ....wallet.error import WalletError from .manager import DIDXManager, DIDXManagerError from .message_types import DIDEX_1_0, DIDEX_1_1, SPEC_URI @@ -44,6 +44,21 @@ class DIDXAcceptInvitationQueryStringSchema(OpenAPISchema): required=False, metadata={"description": "Label for connection request", "example": "Broker"}, ) + use_did = fields.Str( + required=False, + metadata={ + "description": "The DID to use to for this connection", + "example": "did:example:1234", + }, + ) + use_did_method = fields.Str( + required=False, + validate=validate.OneOf(DIDXManager.SUPPORTED_USE_DID_METHODS), + metadata={ + "description": "The DID method to use to generate a DID for this connection", + "example": "did:peer:4", + }, + ) class DIDXCreateRequestImplicitQueryStringSchema(OpenAPISchema): @@ -86,6 +101,21 @@ class DIDXCreateRequestImplicitQueryStringSchema(OpenAPISchema): use_public_did = fields.Boolean( required=False, metadata={"description": "Use public DID for this connection"} ) + use_did = fields.Str( + required=False, + metadata={ + "description": "The DID to use to for this connection", + "example": "did:example:1234", + }, + ) + use_did_method = fields.Str( + required=False, + validate=validate.OneOf(DIDXManager.SUPPORTED_USE_DID_METHODS), + metadata={ + "description": "The DID method to use to generate a DID for this connection", + "example": "did:peer:4", + }, + ) goal_code = fields.Str( required=False, metadata={ @@ -208,7 +238,7 @@ class DIDXRejectRequestSchema(OpenAPISchema): @match_info_schema(DIDXConnIdMatchInfoSchema()) @querystring_schema(DIDXAcceptInvitationQueryStringSchema()) @response_schema(ConnRecordSchema(), 200, description="") -async def didx_accept_invitation(request: web.BaseRequest): +async def didx_accept_invitation(request: web.Request): """Request handler for accepting a stored connection invitation. Args: @@ -225,17 +255,33 @@ async def didx_accept_invitation(request: web.BaseRequest): my_label = request.query.get("my_label") or None my_endpoint = request.query.get("my_endpoint") or None mediation_id = request.query.get("mediation_id") or None + use_did = request.query.get("use_did") or None + use_did_method = request.query.get("use_did_method") or None + + if use_did and use_did_method: + raise web.HTTPBadRequest( + reason="use_did and use_did_method are mutually exclusive" + ) profile = context.profile didx_mgr = DIDXManager(profile) try: async with profile.session() as session: conn_rec = await ConnRecord.retrieve_by_id(session, connection_id) + if use_did: + wallet = session.inject(BaseWallet) + did_info = await wallet.get_local_did(use_did) + conn_rec.my_did = did_info.did + await conn_rec.save( + session, reason="Set my_did from use_did on invite accept" + ) + didx_request = await didx_mgr.create_request( conn_rec=conn_rec, my_label=my_label, my_endpoint=my_endpoint, mediation_id=mediation_id, + use_did_method=use_did_method, ) result = conn_rec.serialize() except StorageNotFoundError as err: @@ -272,6 +318,8 @@ async def didx_create_request_implicit(request: web.BaseRequest): mediation_id = request.query.get("mediation_id") or None alias = request.query.get("alias") or None use_public_did = json.loads(request.query.get("use_public_did", "null")) + use_did = request.query.get("use_did") or None + use_did_method = request.query.get("use_did_method") or None goal_code = request.query.get("goal_code") or None goal = request.query.get("goal") or None auto_accept = json.loads(request.query.get("auto_accept", "null")) @@ -286,6 +334,8 @@ async def didx_create_request_implicit(request: web.BaseRequest): my_endpoint=my_endpoint, mediation_id=mediation_id, use_public_did=use_public_did, + use_did=use_did, + use_did_method=use_did_method, alias=alias, goal_code=goal_code, goal=goal, diff --git a/aries_cloudagent/protocols/didexchange/v1_0/tests/test_manager.py b/aries_cloudagent/protocols/didexchange/v1_0/tests/test_manager.py index feb7020ce2..5066aaf9dd 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/tests/test_manager.py @@ -5,6 +5,7 @@ from aries_cloudagent.tests import mock +from .. import manager as test_module from .....admin.server import AdminResponder from .....cache.base import BaseCache from .....cache.in_memory import InMemoryCache @@ -23,7 +24,7 @@ from .....storage.error import StorageNotFoundError from .....transport.inbound.receipt import MessageReceipt from .....wallet.did_info import DIDInfo -from .....wallet.did_method import PEER2, PEER4, SOV, DIDMethods +from .....wallet.did_method import DIDMethods, PEER2, PEER4, SOV from .....wallet.error import WalletError from .....wallet.in_memory import InMemoryWallet from .....wallet.key_type import ED25519 @@ -35,9 +36,10 @@ from ....out_of_band.v1_0.manager import OutOfBandManager from ....out_of_band.v1_0.messages.invitation import HSProto, InvitationMessage from ....out_of_band.v1_0.messages.service import Service as OOBService -from .. import manager as test_module from ..manager import DIDXManager, DIDXManagerError +from ..message_types import DIDEX_1_0, DIDEX_1_1 from ..messages.problem_report import DIDXProblemReport, ProblemReportReason +from ..messages.request import DIDXRequest class TestConfig: @@ -467,7 +469,6 @@ async def test_create_request_mediation_id(self): await mediation_record.save(session) invi = InvitationMessage( - comment="test", handshake_protocols=[ pfx.qualify(HSProto.RFC23.name) for pfx in DIDCommPrefix ], @@ -551,15 +552,13 @@ async def test_create_request_emit_did_peer_2(self): key_type=ED25519, ) - self.profile.context.update_settings({"emit_did_peer_2": True}) - with mock.patch.object( self.manager, "create_did_peer_2", mock.AsyncMock(return_value=mock_did_info), ) as mock_create_did_peer_2: request = await self.manager.create_request( - mock_conn_rec, + mock_conn_rec, use_did_method="did:peer:2" ) assert request.did_doc_attach is None mock_create_did_peer_2.assert_called_once() @@ -583,15 +582,13 @@ async def test_create_request_emit_did_peer_4(self): key_type=ED25519, ) - self.profile.context.update_settings({"emit_did_peer_4": True}) - with mock.patch.object( self.manager, "create_did_peer_4", mock.AsyncMock(return_value=mock_did_info), ) as mock_create_did_peer_4: request = await self.manager.create_request( - mock_conn_rec, + mock_conn_rec, use_did_method="did:peer:4" ) assert request.did_doc_attach is None mock_create_did_peer_4.assert_called_once() @@ -1489,7 +1486,6 @@ async def test_create_response_mediation_id(self): await mediation_record.save(session) invi = InvitationMessage( - comment="test", handshake_protocols=[ pfx.qualify(HSProto.RFC23.name) for pfx in DIDCommPrefix ], @@ -1542,7 +1538,6 @@ async def test_create_response_mediation_id_invalid_conn_state(self): await mediation_record.save(session) invi = InvitationMessage( - comment="test", handshake_protocols=[ pfx.qualify(HSProto.RFC23.name) for pfx in DIDCommPrefix ], @@ -2269,3 +2264,37 @@ async def test_receive_problem_report_x_unrecognized_code(self): with self.assertRaises(DIDXManagerError) as context: await self.manager.receive_problem_report(mock_conn, report) assert "unrecognized problem report" in str(context.exception) + + def test_handshake_proto_to_use(self): + request = DIDXRequest(_version="1.0") + assert self.manager._handshake_protocol_to_use(request) == DIDEX_1_0 + request = DIDXRequest(_version="1.1") + assert self.manager._handshake_protocol_to_use(request) == DIDEX_1_1 + + raw_request = { + "@type": "https://didcomm.org/didexchange/1.0/request", + "@id": "fe838693-d51d-4225-a52b-30c38c2ec396", + "~thread": { + "thid": "fe838693-d51d-4225-a52b-30c38c2ec396", + "pthid": "09dce45f-aeff-4101-bee1-5a577a11d30f", + }, + "label": "Robert Sr", + "did": "BsXa64NdRhXhRM3uDWwT45", + "did_doc~attach": { + "@id": "8c0a141c-a394-4c1b-86a7-122fc0ce383e", + "mime-type": "application/json", + "data": { + "base64": "eyJAY29udGV4dCI6ICJodHRwczovL3czaWQub3JnL2RpZC92MSIsICJpZCI6ICJkaWQ6c292OkJzWGE2NE5kUmhYaFJNM3VEV3dUNDUiLCAicHVibGljS2V5IjogW3siaWQiOiAiZGlkOnNvdjpCc1hhNjROZFJoWGhSTTN1RFd3VDQ1IzEiLCAidHlwZSI6ICJFZDI1NTE5VmVyaWZpY2F0aW9uS2V5MjAxOCIsICJjb250cm9sbGVyIjogImRpZDpzb3Y6QnNYYTY0TmRSaFhoUk0zdURXd1Q0NSIsICJwdWJsaWNLZXlCYXNlNTgiOiAiNnZmQ3B5dWF2dHdDS0xKSlFocjV4TmNhVGVaYUx5b3RjVWRlYlN3UWVzWTkifV0sICJhdXRoZW50aWNhdGlvbiI6IFt7InR5cGUiOiAiRWQyNTUxOVNpZ25hdHVyZUF1dGhlbnRpY2F0aW9uMjAxOCIsICJwdWJsaWNLZXkiOiAiZGlkOnNvdjpCc1hhNjROZFJoWGhSTTN1RFd3VDQ1IzEifV0sICJzZXJ2aWNlIjogW3siaWQiOiAiZGlkOnNvdjpCc1hhNjROZFJoWGhSTTN1RFd3VDQ1O2luZHkiLCAidHlwZSI6ICJJbmR5QWdlbnQiLCAicHJpb3JpdHkiOiAwLCAicmVjaXBpZW50S2V5cyI6IFsiNnZmQ3B5dWF2dHdDS0xKSlFocjV4TmNhVGVaYUx5b3RjVWRlYlN3UWVzWTkiXSwgInNlcnZpY2VFbmRwb2ludCI6ICJodHRwOi8vcm9iZXJ0OjMwMDAifV19", + "jws": { + "header": { + "kid": "did:key:z6MkkNvFREA2GSRfRq916GovoUAaHDqRks4FJVYaRiuRa6KX" + }, + "protected": "eyJhbGciOiAiRWREU0EiLCAia2lkIjogImRpZDprZXk6ejZNa2tOdkZSRUEyR1NSZlJxOTE2R292b1VBYUhEcVJrczRGSlZZYVJpdVJhNktYIiwgImp3ayI6IHsia3R5IjogIk9LUCIsICJjcnYiOiAiRWQyNTUxOSIsICJ4IjogIldBbHFNYk5lLUVsRk1jQU1NSm1uR3IwenhHUVR0TXlCU2lFcnhHT1NuazQiLCAia2lkIjogImRpZDprZXk6ejZNa2tOdkZSRUEyR1NSZlJxOTE2R292b1VBYUhEcVJrczRGSlZZYVJpdVJhNktYIn19", + "signature": "JEROrpnqqHMWbxV8d3fl5MYVPVZuS2vT44esf0dbYnV5BYsv5U25qoeFUuPspq2DXdWb4xDV0J8mFhq8gpz1Ag", + }, + }, + }, + } + request = DIDXRequest.deserialize(raw_request) + assert request._version == "1.0" + assert self.manager._handshake_protocol_to_use(request) == DIDEX_1_0 diff --git a/aries_cloudagent/protocols/out_of_band/v1_0/manager.py b/aries_cloudagent/protocols/out_of_band/v1_0/manager.py index 382f7aecfb..590a4a70b0 100644 --- a/aries_cloudagent/protocols/out_of_band/v1_0/manager.py +++ b/aries_cloudagent/protocols/out_of_band/v1_0/manager.py @@ -3,43 +3,49 @@ import asyncio import logging import re -from typing import Mapping, Optional, Sequence, Union, Text +from typing import List, Mapping, NamedTuple, Optional, Sequence, Text, Union import uuid +from aries_cloudagent.protocols.coordinate_mediation.v1_0.route_manager import ( + RouteManager, +) +from aries_cloudagent.wallet.error import WalletNotFoundError -from ....messaging.decorators.service_decorator import ServiceDecorator -from ....core.event_bus import EventBus from ....connections.base_manager import BaseConnectionManager from ....connections.models.conn_record import ConnRecord from ....core.error import BaseError +from ....core.event_bus import EventBus from ....core.oob_processor import OobMessageProcessor from ....core.profile import Profile from ....did.did_key import DIDKey +from ....messaging.decorators.attach_decorator import AttachDecorator +from ....messaging.decorators.service_decorator import ServiceDecorator from ....messaging.responder import BaseResponder from ....messaging.valid import IndyDID from ....storage.error import StorageNotFoundError from ....transport.inbound.receipt import MessageReceipt from ....wallet.base import BaseWallet -from ....wallet.did_info import INVITATION_REUSE_KEY +from ....wallet.did_info import INVITATION_REUSE_KEY, DIDInfo from ....wallet.did_method import PEER2, PEER4 from ....wallet.key_type import ED25519 from ...connections.v1_0.manager import ConnectionManager from ...connections.v1_0.messages.connection_invitation import ConnectionInvitation +from ...coordinate_mediation.v1_0.models.mediation_record import MediationRecord from ...didcomm_prefix import DIDCommPrefix from ...didexchange.v1_0.manager import DIDXManager from ...issue_credential.v1_0.models.credential_exchange import V10CredentialExchange from ...issue_credential.v2_0.models.cred_ex_record import V20CredExRecord from ...present_proof.v1_0.models.presentation_exchange import V10PresentationExchange from ...present_proof.v2_0.models.pres_exchange import V20PresExRecord +from .message_types import DEFAULT_VERSION from .messages.invitation import HSProto, InvitationMessage from .messages.problem_report import OOBProblemReport from .messages.reuse import HandshakeReuse from .messages.reuse_accept import HandshakeReuseAccept from .messages.service import Service as ServiceMessage +from .messages.service import Service from .models.invitation import InvitationRecord from .models.oob_record import OobRecord -from .messages.service import Service -from .message_types import DEFAULT_VERSION LOGGER = logging.getLogger(__name__) REUSE_WEBHOOK_TOPIC = "acapy::webhook::connection_reuse" @@ -54,93 +60,48 @@ class OutOfBandManagerNotImplementedError(BaseError): """Out of band error for unimplemented functionality.""" -class OutOfBandManager(BaseConnectionManager): - """Class for managing out of band messages.""" - - def __init__(self, profile: Profile): - """Initialize a OutOfBandManager. - - Args: - profile: The profile for this out of band manager - """ - self._profile = profile - super().__init__(self._profile) - - @property - def profile(self) -> Profile: - """Accessor for the current profile. +class InvitationCreator: + """Class for creating an out of band invitation.""" - Returns: - The profile for this connection manager + class CreateResult(NamedTuple): + """Result from creating an invitation.""" - """ - return self._profile + invitation_url: str + invitation: InvitationMessage + our_recipient_key: str + connection: Optional[ConnRecord] + service: Optional[ServiceDecorator] - async def create_invitation( + def __init__( self, - my_label: str = None, - my_endpoint: str = None, - auto_accept: bool = None, + profile: Profile, + route_manager: RouteManager, + oob: "OutOfBandManager", + my_label: Optional[str] = None, + my_endpoint: Optional[str] = None, + auto_accept: Optional[bool] = None, public: bool = False, - did_peer_2: bool = False, - did_peer_4: bool = False, - hs_protos: Sequence[HSProto] = None, + use_did: Optional[str] = None, + use_did_method: Optional[str] = None, + hs_protos: Optional[Sequence[HSProto]] = None, multi_use: bool = False, create_unique_did: bool = False, - alias: str = None, - attachments: Sequence[Mapping] = None, - metadata: dict = None, - mediation_id: str = None, + alias: Optional[str] = None, + attachments: Optional[Sequence[Mapping]] = None, + metadata: Optional[dict] = None, + mediation_id: Optional[str] = None, service_accept: Optional[Sequence[Text]] = None, protocol_version: Optional[Text] = None, goal_code: Optional[Text] = None, goal: Optional[Text] = None, - ) -> InvitationRecord: - """Generate new connection invitation. - - This interaction represents an out-of-band communication channel. In the future - and in practice, these sort of invitations will be received over any number of - channels such as SMS, Email, QR Code, NFC, etc. - - Args: - my_label: label for this connection - my_endpoint: endpoint where other party can reach me - auto_accept: auto-accept a corresponding connection request - (None to use config) - public: set to create an invitation from the public DID - hs_protos: list of handshake protocols to include - multi_use: set to True to create an invitation for multiple-use connection - alias: optional alias to apply to connection for later use - attachments: list of dicts in form of {"id": ..., "type": ...} - service_accept: Optional list of mime types in the order of preference of - the sender that the receiver can use in responding to the message - protocol_version: OOB protocol version [1.0, 1.1] - goal_code: Optional self-attested code for receiver logic - goal: Optional self-attested string for receiver logic - Returns: - Invitation record - - """ - mediation_record = await self._route_manager.mediation_record_if_id( - self.profile, - mediation_id, - or_default=True, - ) - image_url = self.profile.context.settings.get("image_url") - + ): + """Initialize the invitation creator.""" if not (hs_protos or attachments): raise OutOfBandManagerError( "Invitation must include handshake protocols, " "request attachments, or both" ) - auto_accept = bool( - auto_accept - or ( - auto_accept is None - and self.profile.settings.get("debug.auto_accept_requests") - ) - ) if not hs_protos and metadata: raise OutOfBandManagerError( "Cannot store metadata without handshake protocols" @@ -151,361 +112,531 @@ async def create_invitation( "Cannot create multi use invitation with attachments" ) - invitation_message_id = str(uuid.uuid4()) + if public and use_did: + raise OutOfBandManagerError("use_did and public are mutually exclusive") - message_attachments = [] - for atch in attachments or []: - a_type = atch.get("type") - a_id = atch.get("id") + if public and use_did_method: + raise OutOfBandManagerError( + "use_did_method and public are mutually exclusive" + ) + + if use_did and use_did_method: + raise OutOfBandManagerError( + "use_did and use_did_method are mutually exclusive" + ) + + if create_unique_did and not use_did_method: + LOGGER.error( + "create_unique_did: `%s`, use_did_method: `%s`", + create_unique_did, + use_did_method, + ) + raise OutOfBandManagerError( + "create_unique_did can only be used with use_did_method" + ) + + if ( + use_did_method + and use_did_method not in DIDXManager.SUPPORTED_USE_DID_METHODS + ): + raise OutOfBandManagerError(f"Unsupported use_did_method: {use_did_method}") + + self.profile = profile + self.route_manager = route_manager + self.oob = oob + + self.msg_id = str(uuid.uuid4()) + self.attachments = attachments + + self.handshake_protocols = [ + DIDCommPrefix.qualify_current(hsp.name) for hsp in hs_protos or [] + ] or None + + if not my_endpoint: + my_endpoint = self.profile.settings.get("default_endpoint") + assert my_endpoint + self.my_endpoint = my_endpoint - message = None + self.version = protocol_version or DEFAULT_VERSION + if not my_label: + my_label = self.profile.settings.get("default_label") + assert my_label + self.my_label = my_label + + self.accept = service_accept if protocol_version != "1.0" else None + self.invitation_mode = ( + ConnRecord.INVITATION_MODE_MULTI + if multi_use + else ConnRecord.INVITATION_MODE_ONCE + ) + self.alias = alias + + auto_accept = bool( + auto_accept + or ( + auto_accept is None + and self.profile.settings.get("debug.auto_accept_requests") + ) + ) + self.auto_accept = ( + ConnRecord.ACCEPT_AUTO if auto_accept else ConnRecord.ACCEPT_MANUAL + ) + self.goal = goal + self.goal_code = goal_code + self.public = public + self.use_did = use_did + self.use_did_method = use_did_method + self.multi_use = multi_use + self.create_unique_did = create_unique_did + self.image_url = self.profile.context.settings.get("image_url") + + self.mediation_id = mediation_id + self.metadata = metadata + + async def create_attachment( + self, attachment: Mapping, pthid: str + ) -> AttachDecorator: + """Create attachment for OOB invitation.""" + a_type = attachment.get("type") + a_id = attachment.get("id") + + if not a_type or not a_id: + raise OutOfBandManagerError("Attachment must include type and id") + + async with self.profile.session() as session: if a_type == "credential-offer": try: - async with self.profile.session() as session: - cred_ex_rec = await V10CredentialExchange.retrieve_by_id( - session, - a_id, - ) - message = cred_ex_rec.credential_offer_dict.serialize() + cred_ex_rec = await V10CredentialExchange.retrieve_by_id( + session, + a_id, + ) + message = cred_ex_rec.credential_offer_dict except StorageNotFoundError: - async with self.profile.session() as session: - cred_ex_rec = await V20CredExRecord.retrieve_by_id( - session, - a_id, - ) - message = cred_ex_rec.cred_offer.serialize() + cred_ex_rec = await V20CredExRecord.retrieve_by_id( + session, + a_id, + ) + message = cred_ex_rec.cred_offer elif a_type == "present-proof": try: - async with self.profile.session() as session: - pres_ex_rec = await V10PresentationExchange.retrieve_by_id( - session, - a_id, - ) - message = pres_ex_rec.presentation_request_dict.serialize() + pres_ex_rec = await V10PresentationExchange.retrieve_by_id( + session, + a_id, + ) + message = pres_ex_rec.presentation_request_dict except StorageNotFoundError: - async with self.profile.session() as session: - pres_ex_rec = await V20PresExRecord.retrieve_by_id( - session, - a_id, - ) - message = pres_ex_rec.pres_request.serialize() + pres_ex_rec = await V20PresExRecord.retrieve_by_id( + session, + a_id, + ) + message = pres_ex_rec.pres_request else: raise OutOfBandManagerError(f"Unknown attachment type: {a_type}") - # Assign pthid to the attached message - message["~thread"] = { - **message.get("~thread", {}), - "pthid": invitation_message_id, - } - message_attachments.append(InvitationMessage.wrap_message(message)) + message.assign_thread_id(pthid=pthid) + return InvitationMessage.wrap_message(message.serialize()) - handshake_protocols = [ - DIDCommPrefix.qualify_current(hsp.name) for hsp in hs_protos or [] - ] or None - # Handshake protocol list should be ordered by preference by caller - connection_protocol = ( - hs_protos[0].name if hs_protos and len(hs_protos) >= 1 else None + async def create_attachments( + self, + invitation_msg_id: str, + attachments: Optional[Sequence[Mapping]] = None, + ) -> List[AttachDecorator]: + """Create attachments for OOB invitation.""" + return [ + await self.create_attachment(attachment, invitation_msg_id) + for attachment in attachments or [] + ] + + async def create(self) -> InvitationRecord: + """Create the invitation, returning the result as an InvitationRecord.""" + attachments = await self.create_attachments(self.msg_id, self.attachments) + mediation_record = await self.oob._route_manager.mediation_record_if_id( + self.profile, self.mediation_id, or_default=True ) - our_recipient_key = None - our_service = None - conn_rec = None + if self.public: + result = await self.handle_public(attachments, mediation_record) + elif self.use_did: + result = await self.handle_use_did(attachments, mediation_record) + elif self.use_did_method: + result = await self.handle_use_did_method(attachments, mediation_record) + else: + result = await self.handle_legacy_invite_key(attachments, mediation_record) - if public: - if not self.profile.settings.get("public_invites"): - raise OutOfBandManagerError("Public invitations are not enabled") + oob_record = OobRecord( + role=OobRecord.ROLE_SENDER, + state=OobRecord.STATE_AWAIT_RESPONSE, + connection_id=( + result.connection.connection_id if result.connection else None + ), + invi_msg_id=self.msg_id, + invitation=result.invitation, + our_recipient_key=result.our_recipient_key, + our_service=result.service, + multi_use=self.multi_use, + ) - async with self.profile.session() as session: - wallet = session.inject(BaseWallet) - public_did = await wallet.get_public_did() + async with self.profile.session() as session: + await oob_record.save(session, reason="Created new oob invitation") - if not public_did: - raise OutOfBandManagerError( - "Cannot create public invitation with no public DID" - ) + return InvitationRecord( + oob_id=oob_record.oob_id, + state=InvitationRecord.STATE_INITIAL, + invi_msg_id=self.msg_id, + invitation=result.invitation, + invitation_url=result.invitation_url, + ) - public_did_did = public_did.did - if bool(IndyDID.PATTERN.match(public_did.did)): - public_did_did = f"did:sov:{public_did.did}" - - invi_msg = InvitationMessage( # create invitation message - _id=invitation_message_id, - label=my_label or self.profile.settings.get("default_label"), - handshake_protocols=handshake_protocols, - requests_attach=message_attachments, - services=[public_did_did], - accept=service_accept if protocol_version != "1.0" else None, - version=protocol_version or DEFAULT_VERSION, - image_url=image_url, - ) + async def handle_handshake_protos( + self, + invitation_key: str, + msg: InvitationMessage, + mediation_record: Optional[MediationRecord], + ) -> ConnRecord: + """Handle handshake protocol options, creating a ConnRecord. + + When handshake protocols are included in the create request, that means + we intend to create a connection for the invitation. When absent, + no connection is created, representing a connectionless exchange. + """ + assert self.handshake_protocols + conn_rec = ConnRecord( + invitation_key=invitation_key, + invitation_msg_id=self.msg_id, + invitation_mode=self.invitation_mode, + their_role=ConnRecord.Role.REQUESTER.rfc23, + state=ConnRecord.State.INVITATION.rfc23, + accept=self.auto_accept, + alias=self.alias, + ) - our_recipient_key = public_did.verkey + async with self.profile.transaction() as session: + await conn_rec.save(session, reason="Created new invitation") + await conn_rec.attach_invitation(session, msg) - endpoint, *_ = await self.resolve_invitation(public_did.did) - invi_url = invi_msg.to_url(endpoint) + if self.metadata: + for key, value in self.metadata.items(): + await conn_rec.metadata_set(session, key, value) - # Only create connection record if hanshake_protocols is defined - if handshake_protocols: - invitation_mode = ( - ConnRecord.INVITATION_MODE_MULTI - if multi_use - else ConnRecord.INVITATION_MODE_ONCE - ) - conn_rec = ConnRecord( # create connection record - invitation_key=public_did.verkey, - invitation_msg_id=invi_msg._id, - invitation_mode=invitation_mode, - their_role=ConnRecord.Role.REQUESTER.rfc23, - state=ConnRecord.State.INVITATION.rfc23, - accept=( - ConnRecord.ACCEPT_AUTO - if auto_accept - else ConnRecord.ACCEPT_MANUAL - ), - alias=alias, - connection_protocol=connection_protocol, - ) + await session.commit() - async with self.profile.session() as session: - await conn_rec.save(session, reason="Created new invitation") - await conn_rec.attach_invitation(session, invi_msg) + await self.route_manager.route_invitation( + self.profile, conn_rec, mediation_record + ) - await conn_rec.attach_invitation(session, invi_msg) + return conn_rec - if metadata: - for key, value in metadata.items(): - await conn_rec.metadata_set(session, key, value) - else: - our_service = ServiceDecorator( - recipient_keys=[our_recipient_key], - endpoint=endpoint, - routing_keys=[], - ).serialize() + def did_key_to_key(self, did_key: str) -> str: + """Convert a DID key to a key.""" + if did_key.startswith("did:key:"): + return DIDKey.from_did(did_key).public_key_b58 + return did_key - elif did_peer_4 or did_peer_2: - mediation_records = [mediation_record] if mediation_record else [] + def did_keys_to_keys(self, did_keys: Sequence[str]) -> List[str]: + """Convert DID keys to keys.""" + return [self.did_key_to_key(did_key) for did_key in did_keys] - if my_endpoint: - my_endpoints = [my_endpoint] - else: - my_endpoints = [] - default_endpoint = self.profile.settings.get("default_endpoint") - if default_endpoint: - my_endpoints.append(default_endpoint) - my_endpoints.extend( - self.profile.settings.get("additional_endpoints", []) - ) + async def handle_did( + self, + did_info: DIDInfo, + attachments: Sequence[AttachDecorator], + mediation_record: Optional[MediationRecord], + ) -> CreateResult: + """Handle use_did invitation creation.""" + invi_msg = InvitationMessage( + _id=self.msg_id, + label=self.my_label, + handshake_protocols=self.handshake_protocols, + requests_attach=attachments or None, + services=[did_info.did], + accept=self.accept, + version=self.version, + image_url=self.image_url, + ) + endpoint, recipient_keys, routing_keys = await self.oob.resolve_invitation( + did_info.did + ) + invi_url = invi_msg.to_url(endpoint) - my_info = None - my_did = None - if not create_unique_did: - # check wallet to see if there is an existing "invitation" DID available - did_method = PEER4 if did_peer_4 else PEER2 - my_info = await self.fetch_invitation_reuse_did(did_method) - if my_info: - my_did = my_info.did - else: - LOGGER.warn("No invitation DID found, creating new DID") - - if not my_did: - did_metadata = ( - {INVITATION_REUSE_KEY: "true"} if not create_unique_did else {} - ) - if did_peer_4: - my_info = await self.create_did_peer_4( - my_endpoints, mediation_records, did_metadata - ) - my_did = my_info.did - else: - my_info = await self.create_did_peer_2( - my_endpoints, mediation_records, did_metadata - ) - my_did = my_info.did - - invi_msg = InvitationMessage( # create invitation message - _id=invitation_message_id, - label=my_label or self.profile.settings.get("default_label"), - handshake_protocols=handshake_protocols, - requests_attach=message_attachments, - services=[my_did], - accept=service_accept if protocol_version != "1.0" else None, - version=protocol_version or DEFAULT_VERSION, - image_url=image_url, + if self.handshake_protocols: + conn_rec = await self.handle_handshake_protos( + did_info.verkey, invi_msg, mediation_record + ) + our_service = None + else: + conn_rec = None + await self.route_manager.route_verkey( + self.profile, did_info.verkey, mediation_record + ) + our_service = ServiceDecorator( + recipient_keys=self.did_keys_to_keys(recipient_keys), + endpoint=self.my_endpoint, + routing_keys=self.did_keys_to_keys(routing_keys), ) - invi_url = invi_msg.to_url() - - our_recipient_key = my_info.verkey - # Only create connection record if hanshake_protocols is defined - if handshake_protocols: - invitation_mode = ( - ConnRecord.INVITATION_MODE_MULTI - if multi_use - else ConnRecord.INVITATION_MODE_ONCE - ) - conn_rec = ConnRecord( # create connection record - invitation_key=our_recipient_key, - invitation_msg_id=invi_msg._id, - invitation_mode=invitation_mode, - their_role=ConnRecord.Role.REQUESTER.rfc23, - state=ConnRecord.State.INVITATION.rfc23, - accept=( - ConnRecord.ACCEPT_AUTO - if auto_accept - else ConnRecord.ACCEPT_MANUAL - ), - alias=alias, - connection_protocol=connection_protocol, - ) + return self.CreateResult( + invitation_url=invi_url, + invitation=invi_msg, + our_recipient_key=did_info.verkey, + connection=conn_rec, + service=our_service, + ) - async with self.profile.session() as session: - await conn_rec.save(session, reason="Created new invitation") - await conn_rec.attach_invitation(session, invi_msg) + async def handle_public( + self, + attachments: Sequence[AttachDecorator], + mediation_record: Optional[MediationRecord] = None, + ) -> CreateResult: + """Handle public invitation creation.""" + assert self.public + if not self.profile.settings.get("public_invites"): + raise OutOfBandManagerError("Public invitations are not enabled") - await conn_rec.attach_invitation(session, invi_msg) + async with self.profile.session() as session: + wallet = session.inject(BaseWallet) + public_did = await wallet.get_public_did() - if metadata: - for key, value in metadata.items(): - await conn_rec.metadata_set(session, key, value) - else: - our_service = ServiceDecorator( - recipient_keys=[our_recipient_key], - endpoint=endpoint, - routing_keys=[], - ).serialize() + if not public_did: + raise OutOfBandManagerError( + "Cannot create public invitation with no public DID" + ) - else: - if not my_endpoint: - my_endpoint = self.profile.settings.get("default_endpoint") + if bool(IndyDID.PATTERN.match(public_did.did)): + public_did = DIDInfo( + did=f"did:sov:{public_did.did}", + verkey=public_did.verkey, + metadata=public_did.metadata, + method=public_did.method, + key_type=public_did.key_type, + ) - # Create and store new key for exchange - async with self.profile.session() as session: - wallet = session.inject(BaseWallet) - connection_key = await wallet.create_signing_key(ED25519) + return await self.handle_did(public_did, attachments, mediation_record) - our_recipient_key = connection_key.verkey + async def handle_use_did( + self, + attachments: Sequence[AttachDecorator], + mediation_record: Optional[MediationRecord], + ) -> CreateResult: + """Handle use_did invitation creation.""" + assert self.use_did + async with self.profile.session() as session: + wallet = session.inject(BaseWallet) + try: + did_info = await wallet.get_local_did(self.use_did) + except WalletNotFoundError: + raise OutOfBandManagerError( + f"Cannot find DID for invitation reuse: {self.use_did}" + ) + return await self.handle_did(did_info, attachments, mediation_record) - # Initializing InvitationMessage here to include - # invitation_msg_id in webhook poyload - invi_msg = InvitationMessage( - _id=invitation_message_id, version=protocol_version or DEFAULT_VERSION + async def handle_use_did_method( + self, + attachments: Sequence[AttachDecorator], + mediation_record: Optional[MediationRecord], + ) -> CreateResult: + """Create an invitation using a DID method, optionally reusing one.""" + assert self.use_did_method + mediation_records = [mediation_record] if mediation_record else [] + + if self.my_endpoint: + my_endpoints = [self.my_endpoint] + else: + my_endpoints = [] + default_endpoint = self.profile.settings.get("default_endpoint") + if default_endpoint: + my_endpoints.append(default_endpoint) + my_endpoints.extend(self.profile.settings.get("additional_endpoints", [])) + + did_peer_4 = self.use_did_method == "did:peer:4" + + my_info = None + if not self.create_unique_did: + # check wallet to see if there is an existing "invitation" DID available + did_method = PEER4 if did_peer_4 else PEER2 + my_info = await self.oob.fetch_invitation_reuse_did(did_method) + if not my_info: + LOGGER.warn("No invitation DID found, creating new DID") + + if not my_info: + did_metadata = ( + {INVITATION_REUSE_KEY: "true"} if not self.create_unique_did else {} ) - - if handshake_protocols: - invitation_mode = ( - ConnRecord.INVITATION_MODE_MULTI - if multi_use - else ConnRecord.INVITATION_MODE_ONCE + if did_peer_4: + my_info = await self.oob.create_did_peer_4( + my_endpoints, mediation_records, did_metadata ) - # Create connection record - conn_rec = ConnRecord( - invitation_key=connection_key.verkey, - their_role=ConnRecord.Role.REQUESTER.rfc23, - state=ConnRecord.State.INVITATION.rfc23, - accept=( - ConnRecord.ACCEPT_AUTO - if auto_accept - else ConnRecord.ACCEPT_MANUAL - ), - invitation_mode=invitation_mode, - alias=alias, - connection_protocol=connection_protocol, - invitation_msg_id=invi_msg._id, + else: + my_info = await self.oob.create_did_peer_2( + my_endpoints, mediation_records, did_metadata ) - async with self.profile.session() as session: - await conn_rec.save(session, reason="Created new connection") + return await self.handle_did(my_info, attachments, mediation_record) - routing_keys, routing_endpoint = await self._route_manager.routing_info( - self.profile, mediation_record - ) - my_endpoint = routing_endpoint or my_endpoint - - if not conn_rec: - our_service = ServiceDecorator( - recipient_keys=[our_recipient_key], - endpoint=my_endpoint, - routing_keys=routing_keys, - ).serialize() - # Need to make sure the created key is routed by the base wallet - await self._route_manager.route_verkey( - self.profile, connection_key.verkey - ) + async def handle_legacy_invite_key( + self, + attachments: Sequence[AttachDecorator], + mediation_record: Optional[MediationRecord], + ) -> CreateResult: + """Create an invitation using legacy bare public key and inline service.""" + async with self.profile.session() as session: + wallet = session.inject(BaseWallet) + connection_key = await wallet.create_signing_key(ED25519) - routing_keys = [ - ( - key - if len(key.split(":")) == 3 - else DIDKey.from_public_key_b58(key, ED25519).key_id - ) - for key in routing_keys or [] - ] + routing_keys, routing_endpoint = await self.route_manager.routing_info( + self.profile, mediation_record + ) + routing_keys = [ + ( + key + if len(key.split(":")) == 3 + else DIDKey.from_public_key_b58(key, ED25519).key_id + ) + for key in routing_keys or [] + ] + recipient_keys = [ + DIDKey.from_public_key_b58(connection_key.verkey, ED25519).key_id + ] - # Create connection invitation message - # Note: Need to split this into two stages to support inbound routing - # of invitations - # Would want to reuse create_did_document and convert the result - invi_msg.label = my_label or self.profile.settings.get("default_label") - invi_msg.handshake_protocols = handshake_protocols - invi_msg.requests_attach = message_attachments - invi_msg.accept = service_accept if protocol_version != "1.0" else None - invi_msg.image_url = image_url - invi_msg.services = [ + my_endpoint = routing_endpoint or self.my_endpoint + + invi_msg = InvitationMessage( + _id=self.msg_id, + label=self.my_label, + handshake_protocols=self.handshake_protocols, + requests_attach=attachments, + accept=self.accept, + image_url=self.image_url, + version=self.version, + services=[ ServiceMessage( _id="#inline", _type="did-communication", - recipient_keys=[ - DIDKey.from_public_key_b58( - connection_key.verkey, ED25519 - ).key_id - ], + recipient_keys=recipient_keys, service_endpoint=my_endpoint, routing_keys=routing_keys, ) - ] - if goal and goal_code: - invi_msg.goal_code = goal_code - invi_msg.goal = goal - - invi_url = invi_msg.to_url() - - # Update connection record - if conn_rec: - async with self.profile.session() as session: - await conn_rec.attach_invitation(session, invi_msg) + ], + goal=self.goal, + goal_code=self.goal_code, + ) - if metadata: - for key, value in metadata.items(): - await conn_rec.metadata_set(session, key, value) + if self.handshake_protocols: + conn_rec = await self.handle_handshake_protos( + connection_key.verkey, invi_msg, mediation_record + ) + our_service = None + else: + await self.route_manager.route_verkey( + self.profile, connection_key.verkey, mediation_record + ) + conn_rec = None + our_service = ServiceDecorator( + recipient_keys=self.did_keys_to_keys(recipient_keys), + endpoint=my_endpoint, + routing_keys=self.did_keys_to_keys(routing_keys), + ) - oob_record = OobRecord( - role=OobRecord.ROLE_SENDER, - state=OobRecord.STATE_AWAIT_RESPONSE, - connection_id=conn_rec.connection_id if conn_rec else None, - invi_msg_id=invi_msg._id, + return self.CreateResult( + invitation_url=invi_msg.to_url(), invitation=invi_msg, - our_recipient_key=our_recipient_key, - our_service=our_service, - multi_use=multi_use, + our_recipient_key=connection_key.verkey, + connection=conn_rec, + service=our_service, ) - async with self.profile.session() as session: - await oob_record.save(session, reason="Created new oob invitation") - if conn_rec: - await self._route_manager.route_invitation( - self.profile, conn_rec, mediation_record - ) +class OutOfBandManager(BaseConnectionManager): + """Class for managing out of band messages.""" - return InvitationRecord( # for return via admin API, not storage - oob_id=oob_record.oob_id, - state=InvitationRecord.STATE_INITIAL, - invi_msg_id=invi_msg._id, - invitation=invi_msg, - invitation_url=invi_url, + def __init__(self, profile: Profile): + """Initialize a OutOfBandManager. + + Args: + profile: The profile for this out of band manager + """ + self._profile = profile + super().__init__(self._profile) + + @property + def profile(self) -> Profile: + """Accessor for the current profile. + + Returns: + The profile for this connection manager + + """ + return self._profile + + async def create_invitation( + self, + my_label: Optional[str] = None, + my_endpoint: Optional[str] = None, + auto_accept: Optional[bool] = None, + public: bool = False, + use_did: Optional[str] = None, + use_did_method: Optional[str] = None, + hs_protos: Optional[Sequence[HSProto]] = None, + multi_use: bool = False, + create_unique_did: bool = False, + alias: Optional[str] = None, + attachments: Optional[Sequence[Mapping]] = None, + metadata: Optional[dict] = None, + mediation_id: Optional[str] = None, + service_accept: Optional[Sequence[Text]] = None, + protocol_version: Optional[Text] = None, + goal_code: Optional[Text] = None, + goal: Optional[Text] = None, + ) -> InvitationRecord: + """Generate new connection invitation. + + This interaction represents an out-of-band communication channel. In the future + and in practice, these sort of invitations will be received over any number of + channels such as SMS, Email, QR Code, NFC, etc. + + Args: + my_label: label for this connection + my_endpoint: endpoint where other party can reach me + auto_accept: auto-accept a corresponding connection request + (None to use config) + public: set to create an invitation from the public DID + hs_protos: list of handshake protocols to include + multi_use: set to True to create an invitation for multiple-use connection + alias: optional alias to apply to connection for later use + attachments: list of dicts in form of {"id": ..., "type": ...} + service_accept: Optional list of mime types in the order of preference of + the sender that the receiver can use in responding to the message + protocol_version: OOB protocol version [1.0, 1.1] + goal_code: Optional self-attested code for receiver logic + goal: Optional self-attested string for receiver logic + Returns: + Invitation record + + """ + creator = InvitationCreator( + self.profile, + self._route_manager, + self, + my_label, + my_endpoint, + auto_accept, + public, + use_did, + use_did_method, + hs_protos, + multi_use, + create_unique_did, + alias, + attachments, + metadata, + mediation_id, + service_accept, + protocol_version, + goal_code, + goal, ) + return await creator.create() async def receive_invitation( self, @@ -961,10 +1092,11 @@ async def _perform_handshake( service.routing_keys = [ DIDKey.from_did(key).public_key_b58 for key in service.routing_keys ] or [] + msg_type = DIDCommPrefix.qualify_current(protocol.name) + "/invitation" connection_invitation = ConnectionInvitation.deserialize( { "@id": invitation._id, - "@type": DIDCommPrefix.qualify_current(protocol.name), + "@type": msg_type, "label": invitation.label, "recipientKeys": service.recipient_keys, "serviceEndpoint": service.service_endpoint, diff --git a/aries_cloudagent/protocols/out_of_band/v1_0/messages/invitation.py b/aries_cloudagent/protocols/out_of_band/v1_0/messages/invitation.py index 53a4e04b75..80ebc284fe 100644 --- a/aries_cloudagent/protocols/out_of_band/v1_0/messages/invitation.py +++ b/aries_cloudagent/protocols/out_of_band/v1_0/messages/invitation.py @@ -120,14 +120,12 @@ class Meta: def __init__( self, - # _id: str = None, *, - comment: str = None, - label: str = None, - image_url: str = None, - handshake_protocols: Sequence[Text] = None, - requests_attach: Sequence[AttachDecorator] = None, - services: Sequence[Union[Service, Text]] = None, + label: Optional[str] = None, + image_url: Optional[str] = None, + handshake_protocols: Optional[Sequence[Text]] = None, + requests_attach: Optional[Sequence[AttachDecorator]] = None, + services: Optional[Sequence[Union[Service, Text]]] = None, accept: Optional[Sequence[Text]] = None, version: str = DEFAULT_VERSION, msg_type: Optional[Text] = None, diff --git a/aries_cloudagent/protocols/out_of_band/v1_0/messages/tests/test_invitation.py b/aries_cloudagent/protocols/out_of_band/v1_0/messages/tests/test_invitation.py index 1db6584ab0..7452b17a28 100644 --- a/aries_cloudagent/protocols/out_of_band/v1_0/messages/tests/test_invitation.py +++ b/aries_cloudagent/protocols/out_of_band/v1_0/messages/tests/test_invitation.py @@ -41,7 +41,6 @@ class TestInvitationMessage(TestCase): def test_init(self): """Test initialization message.""" invi_msg = InvitationMessage( - comment="Hello", label="A label", handshake_protocols=[DIDCommPrefix.qualify_current(DIDEX_1_1)], services=[TEST_DID], @@ -51,7 +50,6 @@ def test_init(self): service = Service(_id="#inline", _type=DID_COMM, did=TEST_DID) invi_msg = InvitationMessage( - comment="Hello", label="A label", handshake_protocols=[DIDCommPrefix.qualify_current(DIDEX_1_1)], services=[service], @@ -112,7 +110,6 @@ def test_url_round_trip(self): service_endpoint="http://1.2.3.4:8080/service", ) invi_msg = InvitationMessage( - comment="Hello", label="A label", handshake_protocols=[DIDCommPrefix.qualify_current(DIDEX_1_1)], services=[service], diff --git a/aries_cloudagent/protocols/out_of_band/v1_0/models/oob_record.py b/aries_cloudagent/protocols/out_of_band/v1_0/models/oob_record.py index 830c523c79..e99201bd4f 100644 --- a/aries_cloudagent/protocols/out_of_band/v1_0/models/oob_record.py +++ b/aries_cloudagent/protocols/out_of_band/v1_0/models/oob_record.py @@ -56,13 +56,13 @@ def __init__( invi_msg_id: str, role: str, invitation: Union[InvitationMessage, Mapping[str, Any]], - their_service: Optional[ServiceDecorator] = None, + their_service: Optional[Union[ServiceDecorator, Mapping[str, Any]]] = None, connection_id: Optional[str] = None, reuse_msg_id: Optional[str] = None, oob_id: Optional[str] = None, attach_thread_id: Optional[str] = None, our_recipient_key: Optional[str] = None, - our_service: Optional[ServiceDecorator] = None, + our_service: Optional[Union[ServiceDecorator, Mapping[str, Any]]] = None, multi_use: bool = False, trace: bool = False, **kwargs, @@ -76,8 +76,8 @@ def __init__( self._invitation = InvitationMessage.serde(invitation) self.connection_id = connection_id self.reuse_msg_id = reuse_msg_id - self.their_service = their_service - self.our_service = our_service + self._their_service = ServiceDecorator.serde(their_service) + self._our_service = ServiceDecorator.serde(our_service) self.attach_thread_id = attach_thread_id self.our_recipient_key = our_recipient_key self.multi_use = multi_use @@ -89,7 +89,7 @@ def oob_id(self) -> str: return self._id @property - def invitation(self) -> InvitationMessage: + def invitation(self) -> Optional[InvitationMessage]: """Accessor; get deserialized view.""" return None if self._invitation is None else self._invitation.de @@ -98,6 +98,21 @@ def invitation(self, value): """Setter; store de/serialized views.""" self._invitation = InvitationMessage.serde(value) + @property + def our_service(self) -> Optional[ServiceDecorator]: + """Accessor; get deserialized view.""" + return None if self._our_service is None else self._our_service.de + + @our_service.setter + def our_service(self, value: Union[ServiceDecorator, Mapping[str, Any]]): + """Setter; store de/serialized views.""" + self._our_service = ServiceDecorator.serde(value) + + @property + def their_service(self) -> Optional[ServiceDecorator]: + """Accessor; get deserialized view.""" + return None if self._their_service is None else self._their_service.de + @property def record_value(self) -> dict: """Accessor for the JSON record value generated for this invitation.""" @@ -109,14 +124,13 @@ def record_value(self) -> dict: "their_service", "connection_id", "role", - "our_service", "invi_msg_id", "multi_use", ) }, **{ prop: getattr(self, f"_{prop}").ser - for prop in ("invitation",) + for prop in ("invitation", "our_service", "their_service") if getattr(self, prop) is not None }, } diff --git a/aries_cloudagent/protocols/out_of_band/v1_0/models/tests/test_invitation.py b/aries_cloudagent/protocols/out_of_band/v1_0/models/tests/test_invitation.py index 50a7fade08..e7d95655a2 100644 --- a/aries_cloudagent/protocols/out_of_band/v1_0/models/tests/test_invitation.py +++ b/aries_cloudagent/protocols/out_of_band/v1_0/models/tests/test_invitation.py @@ -32,7 +32,6 @@ class TestInvitationRecordSchema(IsolatedAsyncioTestCase): def test_make_record(self): """Test making record.""" invi = InvitationMessage( - comment="Hello", label="A label", handshake_protocols=[DIDCommPrefix.qualify_current(DIDEX_1_1)], services=[TEST_DID], diff --git a/aries_cloudagent/protocols/out_of_band/v1_0/routes.py b/aries_cloudagent/protocols/out_of_band/v1_0/routes.py index 210fedd7f9..96aeea265a 100644 --- a/aries_cloudagent/protocols/out_of_band/v1_0/routes.py +++ b/aries_cloudagent/protocols/out_of_band/v1_0/routes.py @@ -20,7 +20,7 @@ from ....messaging.valid import UUID4_EXAMPLE, UUID4_VALIDATE from ....storage.error import StorageError, StorageNotFoundError from ...didcomm_prefix import DIDCommPrefix -from ...didexchange.v1_0.manager import DIDXManagerError +from ...didexchange.v1_0.manager import DIDXManager, DIDXManagerError from .manager import OutOfBandManager, OutOfBandManagerError from .message_types import SPEC_URI from .messages.invitation import HSProto, InvitationMessage, InvitationMessageSchema @@ -106,6 +106,21 @@ class AttachmentDefSchema(OpenAPISchema): "example": False, }, ) + use_did = fields.Str( + required=False, + metadata={ + "description": "DID to use in invitation", + "example": "did:example:123", + }, + ) + use_did_method = fields.Str( + required=False, + validate=validate.OneOf(DIDXManager.SUPPORTED_USE_DID_METHODS), + metadata={ + "description": "DID method to use in invitation", + "example": "did:peer:2", + }, + ) metadata = fields.Dict( required=False, metadata={ @@ -227,6 +242,8 @@ async def invitation_create(request: web.BaseRequest): handshake_protocols = body.get("handshake_protocols", []) service_accept = body.get("accept") use_public_did = body.get("use_public_did", False) + use_did = body.get("use_did") + use_did_method = body.get("use_did_method") metadata = body.get("metadata") my_label = body.get("my_label") alias = body.get("alias") @@ -239,29 +256,16 @@ async def invitation_create(request: web.BaseRequest): auto_accept = json.loads(request.query.get("auto_accept", "null")) create_unique_did = json.loads(request.query.get("create_unique_did", "false")) - if create_unique_did and use_public_did: - raise web.HTTPBadRequest( - reason="create_unique_did cannot be used with use_public_did" - ) - profile = context.profile - emit_did_peer_4 = profile.settings.get("emit_did_peer_4", False) - emit_did_peer_2 = profile.settings.get("emit_did_peer_2", False) - if emit_did_peer_2 and emit_did_peer_4: - LOGGER.warning( - "emit_did_peer_2 and emit_did_peer_4 both set, \ - using did:peer:4" - ) - oob_mgr = OutOfBandManager(profile) try: invi_rec = await oob_mgr.create_invitation( my_label=my_label, auto_accept=auto_accept, public=use_public_did, - did_peer_2=emit_did_peer_2, - did_peer_4=emit_did_peer_4, + use_did=use_did, + use_did_method=use_did_method, hs_protos=[ h for h in [HSProto.get(hsp) for hsp in handshake_protocols] if h ], diff --git a/aries_cloudagent/protocols/out_of_band/v1_0/tests/test_invite_creator.py b/aries_cloudagent/protocols/out_of_band/v1_0/tests/test_invite_creator.py new file mode 100644 index 0000000000..107357dd15 --- /dev/null +++ b/aries_cloudagent/protocols/out_of_band/v1_0/tests/test_invite_creator.py @@ -0,0 +1,26 @@ +"""Test the InvitationCreator class.""" + +from unittest.mock import MagicMock +import pytest + +from ..manager import InvitationCreator, OutOfBandManagerError + + +@pytest.mark.parametrize( + "args", + [ + ({}), + ({"metadata": "test"}), + ({"attachments": "test", "multi_use": True}), + ({"hs_protos": "test", "public": True, "use_did": True}), + ({"hs_protos": "test", "public": True, "use_did_method": True}), + ({"hs_protos": "test", "use_did": True, "use_did_method": True}), + ({"hs_protos": "test", "create_unique_did": True}), + ({"hs_protos": "test", "use_did_method": "some_did_method"}), + ], +) +def test_init_param_checking_x(args): + with pytest.raises(OutOfBandManagerError): + InvitationCreator( + profile=MagicMock(), route_manager=MagicMock(), oob=MagicMock(), **args + ) diff --git a/aries_cloudagent/protocols/out_of_band/v1_0/tests/test_manager.py b/aries_cloudagent/protocols/out_of_band/v1_0/tests/test_manager.py index 05034e9685..3b8d33a51c 100644 --- a/aries_cloudagent/protocols/out_of_band/v1_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/out_of_band/v1_0/tests/test_manager.py @@ -591,9 +591,7 @@ async def test_create_invitation_attachment_v2_0_cred_offer(self): ) mock_retrieve_cxid_v1.side_effect = test_module.StorageNotFoundError() mock_retrieve_cxid_v2.return_value = mock.MagicMock( - cred_offer=mock.MagicMock( - serialize=mock.MagicMock(return_value={"cred": "offer"}) - ) + cred_offer=V20CredOffer() ) invi_rec = await self.manager.create_invitation( my_endpoint=TestConfig.test_endpoint, @@ -606,10 +604,10 @@ async def test_create_invitation_attachment_v2_0_cred_offer(self): mock_retrieve_cxid_v2.assert_called_once_with(ANY, "dummy-id") assert isinstance(invi_rec, InvitationRecord) assert not invi_rec.invitation.handshake_protocols - assert invi_rec.invitation.requests_attach[0].content == { - "cred": "offer", - "~thread": {"pthid": invi_rec.invi_msg_id}, - } + attach = invi_rec.invitation.requests_attach[0].content + assert isinstance(attach, dict) + assert "~thread" in attach and "pthid" in attach["~thread"] + assert attach["~thread"]["pthid"] == invi_rec.invi_msg_id async def test_create_invitation_attachment_present_proof_v1_0(self): self.profile.context.update_settings({"public_invites": True}) @@ -759,7 +757,15 @@ async def test_create_invitation_attachment_x(self): public=False, hs_protos=[test_module.HSProto.RFC23], multi_use=False, - attachments=[{"having": "attachment", "is": "no", "good": "here"}], + attachments=[ + { + "type": "asdf", + "id": "asdf", + "having": "attachment", + "is": "no", + "good": "here", + } + ], ) assert "Unknown attachment type" in str(context.exception) @@ -1613,7 +1619,9 @@ async def test_request_attach_oob_message_processor_connectionless(self): mock.CoroutineMock(), ) as mock_service_decorator_from_service: mock_create_signing_key.return_value = KeyInfo( - verkey="a-verkey", metadata={}, key_type=ED25519 + verkey="H3C2AVvLMv6gmMNam3uVAjZpfkcJCwDwnZn6z3wXmqPV", + metadata={}, + key_type=ED25519, ) mock_service_decorator_from_service.return_value = mock_service_decorator oob_invitation = InvitationMessage( @@ -1626,7 +1634,10 @@ async def test_request_attach_oob_message_processor_connectionless(self): oob_invitation, use_existing_connection=True ) - assert oob_record.our_recipient_key == "a-verkey" + assert ( + oob_record.our_recipient_key + == "H3C2AVvLMv6gmMNam3uVAjZpfkcJCwDwnZn6z3wXmqPV" + ) assert oob_record.our_service assert oob_record.state == OobRecord.STATE_PREPARE_RESPONSE diff --git a/aries_cloudagent/protocols/out_of_band/v1_0/tests/test_routes.py b/aries_cloudagent/protocols/out_of_band/v1_0/tests/test_routes.py index a6ea7eb8c0..7a9384f1cc 100644 --- a/aries_cloudagent/protocols/out_of_band/v1_0/tests/test_routes.py +++ b/aries_cloudagent/protocols/out_of_band/v1_0/tests/test_routes.py @@ -52,8 +52,8 @@ async def test_invitation_create(self): my_label=None, auto_accept=True, public=True, - did_peer_2=False, - did_peer_4=False, + use_did_method=None, + use_did=None, multi_use=True, create_unique_did=False, hs_protos=[test_module.HSProto.RFC23], @@ -112,8 +112,8 @@ async def test_invitation_create_with_accept(self): my_label=None, auto_accept=True, public=True, - did_peer_2=False, - did_peer_4=False, + use_did_method=None, + use_did=None, multi_use=True, create_unique_did=False, hs_protos=[test_module.HSProto.RFC23], diff --git a/aries_cloudagent/utils/tests/test_outofband.py b/aries_cloudagent/utils/tests/test_outofband.py index 81d5763ca7..72bcf6d7e6 100644 --- a/aries_cloudagent/utils/tests/test_outofband.py +++ b/aries_cloudagent/utils/tests/test_outofband.py @@ -13,9 +13,7 @@ class TestOutOfBand(TestCase): test_did_info = DIDInfo(test_did, test_verkey, None, method=SOV, key_type=ED25519) def test_serialize_oob(self): - invi = InvitationMessage( - comment="my sister", label="ma sœur", services=[TestOutOfBand.test_did] - ) + invi = InvitationMessage(label="ma sœur", services=[TestOutOfBand.test_did]) result = test_module.serialize_outofband( invi, TestOutOfBand.test_did_info, "http://1.2.3.4:8081" diff --git a/aries_cloudagent/utils/tests/test_tracing.py b/aries_cloudagent/utils/tests/test_tracing.py index b9c7278383..55cd60465b 100644 --- a/aries_cloudagent/utils/tests/test_tracing.py +++ b/aries_cloudagent/utils/tests/test_tracing.py @@ -23,9 +23,7 @@ def test_get_timer(self): assert test_module.get_timer() > 0.0 def test_tracing_enabled(self): - invi = InvitationMessage( - comment="no comment", label="cable guy", services=[TestTracing.test_did] - ) + invi = InvitationMessage(label="cable guy", services=[TestTracing.test_did]) assert not test_module.tracing_enabled({}, invi) invi._trace = TraceDecorator(target="message") assert test_module.tracing_enabled({}, invi) @@ -71,9 +69,7 @@ def test_tracing_enabled(self): assert test_module.tracing_enabled({}, outbound_message) def test_decode_inbound_message(self): - invi = InvitationMessage( - comment="no comment", label="cable guy", services=[TestTracing.test_did] - ) + invi = InvitationMessage(label="cable guy", services=[TestTracing.test_did]) message = OutboundMessage(payload=invi) assert invi == test_module.decode_inbound_message(message) diff --git a/demo/features/0160-connection.feature b/demo/features/0160-connection.feature index 85d2652161..6befe878cd 100644 --- a/demo/features/0160-connection.feature +++ b/demo/features/0160-connection.feature @@ -15,16 +15,16 @@ Feature: RFC 0160 Aries agent connection functions @GHA @UnqualifiedDids Examples: | Acme_capabilities | Acme_extra | Bob_capabilities | Bob_extra | - | --public-did --did-exchange | --emit-did-peer-2 | --did-exchange | --emit-did-peer-2 | - | --public-did --did-exchange | --emit-did-peer-4 | --did-exchange | --emit-did-peer-4 | - | --public-did --did-exchange --reuse-connections | --emit-did-peer-4 | --did-exchange --reuse-connections | --emit-did-peer-4 | + | --public-did --did-exchange --emit-did-peer-2 | | --did-exchange --emit-did-peer-2 | | + | --public-did --did-exchange --emit-did-peer-4 | | --did-exchange --emit-did-peer-4 | | + | --public-did --did-exchange --reuse-connections --emit-did-peer-4 | | --did-exchange --reuse-connections --emit-did-peer-4 | | @UnqualifiedDids Examples: | Acme_capabilities | Acme_extra | Bob_capabilities | Bob_extra | - | --public-did --did-exchange | --emit-did-peer-2 | --did-exchange | --emit-did-peer-4 | - | --public-did --did-exchange --reuse-connections | --emit-did-peer-4 | --did-exchange --reuse-connections | --emit-did-peer-2 | - | --public-did --did-exchange | --emit-did-peer-4 | --did-exchange | --emit-did-peer-2 | + | --public-did --did-exchange --emit-did-peer-2 | | --did-exchange --emit-did-peer-4 | | + | --public-did --did-exchange --reuse-connections --emit-did-peer-4 | | --did-exchange --reuse-connections --emit-did-peer-2 | | + | --public-did --did-exchange --emit-did-peer-4 | | --did-exchange --emit-did-peer-2 | | @PublicDidReuse Examples: @@ -35,31 +35,31 @@ Feature: RFC 0160 Aries agent connection functions @DidPeerConnectionReuse Examples: | Acme_capabilities | Acme_extra | Bob_capabilities | Bob_extra | - | --did-exchange | --emit-did-peer-2 | | --emit-did-peer-2 | - | --did-exchange --reuse-connections | --emit-did-peer-2 | --reuse-connections | --emit-did-peer-2 | - | --did-exchange | --emit-did-peer-4 | | --emit-did-peer-4 | - | --did-exchange --reuse-connections | --emit-did-peer-4 | --reuse-connections | --emit-did-peer-4 | + | --did-exchange --emit-did-peer-2 | | --emit-did-peer-2 | | + | --did-exchange --reuse-connections --emit-did-peer-2 | | --reuse-connections --emit-did-peer-2 | | + | --did-exchange --emit-did-peer-4 | | --emit-did-peer-4 | | + | --did-exchange --reuse-connections --emit-did-peer-4 | | --reuse-connections --emit-did-peer-4 | | @GHA @MultiUseConnectionReuse Examples: | Acme_capabilities | Acme_extra | Bob_capabilities | Bob_extra | - | --did-exchange --multi-use-invitations | --emit-did-peer-2 | | --emit-did-peer-2 | - | --did-exchange --multi-use-invitations --reuse-connections | --emit-did-peer-4 | --reuse-connections | --emit-did-peer-4 | - | --public-did --did-exchange --multi-use-invitations | --emit-did-peer-2 | --did-exchange | --emit-did-peer-4 | - | --public-did --did-exchange --multi-use-invitations --reuse-connections | --emit-did-peer-4 | --did-exchange --reuse-connections | --emit-did-peer-2 | + | --did-exchange --multi-use-invitations --emit-did-peer-2 | | --emit-did-peer-2 | | + | --did-exchange --multi-use-invitations --reuse-connections --emit-did-peer-4 | | --reuse-connections --emit-did-peer-4 | | + | --public-did --did-exchange --multi-use-invitations --emit-did-peer-2 | | --did-exchange --emit-did-peer-4 | | + | --public-did --did-exchange --multi-use-invitations --reuse-connections --emit-did-peer-4 | | --did-exchange --reuse-connections --emit-did-peer-2 | | @MultiUseConnectionReuse Examples: | Acme_capabilities | Acme_extra | Bob_capabilities | Bob_extra | - | --did-exchange --multi-use-invitations --reuse-connections | --emit-did-peer-2 | --reuse-connections | --emit-did-peer-2 | - | --did-exchange --multi-use-invitations | --emit-did-peer-4 | | --emit-did-peer-4 | - | --public-did --did-exchange --multi-use-invitations | --emit-did-peer-4 | --did-exchange | --emit-did-peer-2 | - | --public-did --did-exchange --multi-use-invitations --reuse-connections | --emit-did-peer-2 | --did-exchange --reuse-connections | --emit-did-peer-4 | + | --did-exchange --multi-use-invitations --reuse-connections --emit-did-peer-2 | | --reuse-connections --emit-did-peer-2 | | + | --did-exchange --multi-use-invitations --emit-did-peer-4 | | --emit-did-peer-4 | | + | --public-did --did-exchange --multi-use-invitations --emit-did-peer-4 | | --did-exchange --emit-did-peer-2 | | + | --public-did --did-exchange --multi-use-invitations --reuse-connections --emit-did-peer-2 | | --did-exchange --reuse-connections --emit-did-peer-4 | | @GHA @WalletType_Askar_AnonCreds Examples: | Acme_capabilities | Acme_extra | Bob_capabilities | Bob_extra | - | --public-did --did-exchange --wallet-type askar-anoncreds | --emit-did-peer-2 | --did-exchange --wallet-type askar-anoncreds | --emit-did-peer-2 | - | --public-did --did-exchange --wallet-type askar-anoncreds --reuse-connections | --emit-did-peer-4 | --did-exchange --wallet-type askar-anoncreds --reuse-connections | --emit-did-peer-4 | - | --did-exchange --wallet-type askar-anoncreds | --emit-did-peer-2 | --wallet-type askar-anoncreds | --emit-did-peer-2 | - | --did-exchange --wallet-type askar-anoncreds --reuse-connections | --emit-did-peer-4 | --wallet-type askar-anoncreds --reuse-connections | --emit-did-peer-4 | + | --public-did --did-exchange --wallet-type askar-anoncreds --emit-did-peer-2 | | --did-exchange --wallet-type askar-anoncreds --emit-did-peer-2 | | + | --public-did --did-exchange --wallet-type askar-anoncreds --reuse-connections --emit-did-peer-4 | | --did-exchange --wallet-type askar-anoncreds --reuse-connections --emit-did-peer-4 | | + | --did-exchange --wallet-type askar-anoncreds --emit-did-peer-2 | | --wallet-type askar-anoncreds --emit-did-peer-2 | | + | --did-exchange --wallet-type askar-anoncreds --reuse-connections --emit-did-peer-4 | | --wallet-type askar-anoncreds --reuse-connections --emit-did-peer-4 | | diff --git a/demo/features/0453-issue-credential.feature b/demo/features/0453-issue-credential.feature index 245144a2ff..0085a393e9 100644 --- a/demo/features/0453-issue-credential.feature +++ b/demo/features/0453-issue-credential.feature @@ -37,15 +37,15 @@ Feature: RFC 0453 Aries agent issue credential @GHA @WalletType_Askar @ConnectionTests Examples: - | Acme_capabilities | Bob_capabilities | Schema_name | Credential_data | Acme_extra | Bob_extra | - | --did-exchange | --did-exchange | driverslicense | Data_DL_NormalizedValues | --emit-did-peer-4 | --emit-did-peer-4 | - | --did-exchange --reuse-connections | --did-exchange --reuse-connections | driverslicense | Data_DL_NormalizedValues | --emit-did-peer-4 | --emit-did-peer-4 | + | Acme_capabilities | Bob_capabilities | Schema_name | Credential_data | Acme_extra | Bob_extra | + | --did-exchange --emit-did-peer-4 | --did-exchange --emit-did-peer-4 | driverslicense | Data_DL_NormalizedValues | | | + | --did-exchange --reuse-connections --emit-did-peer-4 | --did-exchange --reuse-connections --emit-did-peer-4 | driverslicense | Data_DL_NormalizedValues | | | @GHA @WalletType_Askar_AnonCreds @ConnectionTests Examples: | Acme_capabilities | Bob_capabilities | Schema_name | Credential_data | Acme_extra | Bob_extra | - | --did-exchange --wallet-type askar-anoncreds | --did-exchange --wallet-type askar-anoncreds | driverslicense | Data_DL_NormalizedValues | --emit-did-peer-4 | --emit-did-peer-4 | - | --did-exchange --wallet-type askar-anoncreds --reuse-connections | --did-exchange --wallet-type askar-anoncreds --reuse-connections | driverslicense | Data_DL_NormalizedValues | --emit-did-peer-4 | --emit-did-peer-4 | + | --did-exchange --wallet-type askar-anoncreds --emit-did-peer-4 | --did-exchange --wallet-type askar-anoncreds --emit-did-peer-4 | driverslicense | Data_DL_NormalizedValues | | | + | --did-exchange --wallet-type askar-anoncreds --reuse-connections --emit-did-peer-4| --did-exchange --wallet-type askar-anoncreds --reuse-connections --emit-did-peer-4| driverslicense | Data_DL_NormalizedValues | | | @T003-RFC0453 Scenario Outline: Holder accepts a deleted credential offer diff --git a/demo/features/0454-present-proof.feature b/demo/features/0454-present-proof.feature index 7a76ec771a..8787541703 100644 --- a/demo/features/0454-present-proof.feature +++ b/demo/features/0454-present-proof.feature @@ -41,12 +41,12 @@ Feature: RFC 0454 Aries agent present proof @WalletType_Askar Examples: | issuer | Acme_capabilities | Acme_extra | Bob_capabilities | Bob_extra | Schema_name | Credential_data | Proof_request | - | Faber | --public-did --did-exchange | --emit-did-peer-2 | --did-exchange | --emit-did-peer-2 | driverslicense_v2 | Data_DL_MaxValues | DL_age_over_19_v2 | + | Faber | --public-did --did-exchange --emit-did-peer-2 | | --did-exchange --emit-did-peer-2 | | driverslicense_v2 | Data_DL_MaxValues | DL_age_over_19_v2 | @WalletType_Askar_AnonCreds Examples: | issuer | Acme_capabilities | Acme_extra | Bob_capabilities | Bob_extra | Schema_name | Credential_data | Proof_request | - | Faber | --public-did --wallet-type askar-anoncreds | --emit-did-peer-2 | --wallet-type askar-anoncreds | --emit-did-peer-2 | driverslicense_v2 | Data_DL_MaxValues | DL_age_over_19_v2 | + | Faber | --public-did --wallet-type askar-anoncreds --emit-did-peer-2 | | --wallet-type askar-anoncreds --emit-did-peer-2 | | driverslicense_v2 | Data_DL_MaxValues | DL_age_over_19_v2 | @T001.1-RFC0454 diff --git a/demo/runners/agent_container.py b/demo/runners/agent_container.py index 8e16c9004c..38ee4e739e 100644 --- a/demo/runners/agent_container.py +++ b/demo/runners/agent_container.py @@ -613,6 +613,8 @@ async def generate_invitation( reuse_connections: bool = False, multi_use_invitations: bool = False, public_did_connections: bool = False, + emit_did_peer_2: bool = False, + emit_did_peer_4: bool = False, wait: bool = False, ): self._connection_ready = asyncio.Future() @@ -627,6 +629,8 @@ async def generate_invitation( reuse_connections=reuse_connections, multi_use_invitations=multi_use_invitations, public_did_connections=public_did_connections, + emit_did_peer_2=emit_did_peer_2, + emit_did_peer_4=emit_did_peer_4, ) if display_qr: @@ -728,6 +732,8 @@ def __init__( reuse_connections: bool = False, multi_use_invitations: bool = False, public_did_connections: bool = False, + emit_did_peer_2: bool = False, + emit_did_peer_4: bool = False, taa_accept: bool = False, anoncreds_legacy_revocation: str = None, log_file: str = None, @@ -769,6 +775,8 @@ def __init__( self.reuse_connections = reuse_connections self.multi_use_invitations = multi_use_invitations self.public_did_connections = public_did_connections + self.emit_did_peer_2 = emit_did_peer_2 + self.emit_did_peer_4 = emit_did_peer_4 self.exchange_tracing = False # local agent(s) @@ -1173,6 +1181,8 @@ async def generate_invitation( reuse_connections=reuse_connections, multi_use_invitations=multi_use_invitations, public_did_connections=public_did_connections, + emit_did_peer_2=self.emit_did_peer_2, + emit_did_peer_4=self.emit_did_peer_4, wait=wait, ) @@ -1432,6 +1442,16 @@ def arg_parser(ident: str = None, port: int = 8020): "('debug', 'info', 'warning', 'error', 'critical')" ), ) + parser.add_argument( + "--emit-did-peer-2", + action="store_true", + help="Emit did:peer:2 DID in DID exchange", + ) + parser.add_argument( + "--emit-did-peer-4", + action="store_true", + help="Emit did:peer:4 DID in DID exchange", + ) return parser @@ -1537,6 +1557,9 @@ async def create_agent_with_args(args, ident: str = None, extra_args: list = Non if "anoncreds_legacy_revocation" in args and args.anoncreds_legacy_revocation: anoncreds_legacy_revocation = args.anoncreds_legacy_revocation + emit_did_peer_2 = "emit_did_peer_2" in args and args.emit_did_peer_2 + emit_did_peer_4 = "emit_did_peer_4" in args and args.emit_did_peer_4 + agent = AgentContainer( genesis_txns=genesis, genesis_txn_list=multi_ledger_config_path, @@ -1559,6 +1582,8 @@ async def create_agent_with_args(args, ident: str = None, extra_args: list = Non reuse_connections=reuse_connections, multi_use_invitations=multi_use_invitations, public_did_connections=public_did_connections, + emit_did_peer_2=emit_did_peer_2, + emit_did_peer_4=emit_did_peer_4, taa_accept=args.taa_accept, anoncreds_legacy_revocation=anoncreds_legacy_revocation, log_file=log_file, diff --git a/demo/runners/support/agent.py b/demo/runners/support/agent.py index 90dee157b2..54aa629d50 100644 --- a/demo/runners/support/agent.py +++ b/demo/runners/support/agent.py @@ -4,11 +4,11 @@ import json import logging import os -import random import subprocess import sys from concurrent.futures import ThreadPoolExecutor from timeit import default_timer +from secrets import token_hex import asyncpg import yaml @@ -207,12 +207,8 @@ def __init__( seed = None elif self.endorser_role and not seed: seed = "random" - rand_name = str(random.randint(100_000, 999_999)) - self.seed = ( - ("my_seed_000000000000000000000000" + rand_name)[-32:] - if seed == "random" - else seed - ) + rand_name = token_hex(4) + self.seed = token_hex(16) if seed == "random" else seed self.storage_type = params.get("storage_type") self.wallet_type = params.get("wallet_type") or "askar" self.wallet_name = ( @@ -1455,11 +1451,24 @@ async def get_invite( reuse_connections: bool = False, multi_use_invitations: bool = False, public_did_connections: bool = False, + emit_did_peer_2: bool = False, + emit_did_peer_4: bool = False, ): self.connection_id = None if use_did_exchange: # TODO can mediation be used with DID exchange connections? - create_unique_did = (not reuse_connections) and (not public_did_connections) + if emit_did_peer_2: + use_did_method = "did:peer:2" + elif emit_did_peer_4: + use_did_method = "did:peer:4" + else: + use_did_method = None + + create_unique_did = ( + use_did_method is not None + and (not reuse_connections) + and (not public_did_connections) + ) invi_params = { "auto_accept": json.dumps(auto_accept), "multi_use": json.dumps(multi_use_invitations), @@ -1471,6 +1480,8 @@ async def get_invite( } if self.mediation: payload["mediation_id"] = self.mediator_request_id + if use_did_method: + payload["use_did_method"] = use_did_method print("Calling /out-of-band/create-invitation with:", payload, invi_params) invi_rec = await self.admin_POST( "/out-of-band/create-invitation", diff --git a/docs/demo/README.md b/docs/demo/README.md index 2764ffa14c..5aa0b86601 100644 --- a/docs/demo/README.md +++ b/docs/demo/README.md @@ -265,11 +265,13 @@ You can enable DID Exchange using the `--did-exchange` parameter for the `alice` This will use the new DID Exchange protocol when establishing connections between the agents, rather than the older Connection protocol. There is no other affect on the operation of the agents. -With DID Exchange, you can also enable use of the inviter's public DID for invitations, multi-use invitations, and connection re-use: +With DID Exchange, you can also enable use of the inviter's public DID for invitations, multi-use invitations, connection re-use, and use of qualified DIDs: - `--public-did-connections` - use the inviter's public DID in invitations, and allow use of implicit invitations - `--reuse-connections` - support connection re-use (invitee will reuse an existing connection if it uses the same DID as in the new invitation) - `--multi-use-invitations` - inviter will issue multi-use invitations +- `--emit-did-peer-4` - participants will prefer use of did:peer:4 for their pairwise connection DIDs +- `--emit-did-peer-2` - participants will prefer use of did:peer:2 for their pairwise connection DIDs ### Endorser diff --git a/docs/demo/ReusingAConnection.md b/docs/demo/ReusingAConnection.md index 2c7e94dfd6..497075e7aa 100644 --- a/docs/demo/ReusingAConnection.md +++ b/docs/demo/ReusingAConnection.md @@ -134,14 +134,14 @@ For example, to run faber with connection reuse using a non-public DID: ./run_demo faber --reuse-connections --events ``` -To run faber using a `did_peer` and reusable connections: +To run faber using a `did:peer` and reusable connections: ``` bash -DEMO_EXTRA_AGENT_ARGS="[\"--emit-did-peer-2\"]" ./run_demo faber --reuse-connections --events +./run_demo faber --reuse-connections --emit-did-peer-2 --events ``` To run this demo using a multi-use invitation (from Faber): ``` bash -DEMO_EXTRA_AGENT_ARGS="[\"--emit-did-peer-2\"]" ./run_demo faber --reuse-connections --multi-use-invitations --events +./run_demo faber --reuse-connections --emit-did-peer-2 --multi-use-invitations --events ``` diff --git a/docs/features/QualifiedDIDs.md b/docs/features/QualifiedDIDs.md new file mode 100644 index 0000000000..bec33b519e --- /dev/null +++ b/docs/features/QualifiedDIDs.md @@ -0,0 +1,49 @@ +# Qualified DIDs In ACA-Py + +## Context + +In the past, ACA-Py has used "unqualified" DIDs by convention established early on in the Aries ecosystem, before the concept of Peer DIDs, or DIDs that existed only between peers and were not (necessarily) published to a distributed ledger, fully matured. These "unqualified" DIDs were effectively Indy Nyms that had not been published to an Indy network. Key material and service endpoints were communicated by embedding the DID Document for the "DID" in DID Exchange request and response messages. + +For those familiar with the DID Core Specification, it is a stretch to refer to these unqualified DIDs as DIDs. Usage of these DIDs will be phased out, as dictated by [Aries RFC 0793: Unqualified DID Transition][rfc0793]. These DIDs will be phased out in favor of the `did:peer` DID Method. ACA-Py's support for this method and it's use in DID Exchange and DID Rotation is dictated below. + +[rfc0793]: https://github.com/hyperledger/aries-rfcs/blob/50d148b812c45af3fc847c1e7033b084683dceb7/features/0793-unqualfied-dids-transition/README.md + +## DID Exchange + +When using DID Exchange as initiated by an Out-of-Band invitation: + +- `POST /out-of-band/create-invitation` accepts two parameters (in addition to others): + - `use_did_method`: a DID Method (options: `did:peer:2` `did:peer:4`) indicating that a DID of that type is created (if necessary), and used in the invitation. If a DID of the type has to be created, it is flagged as the "invitation" DID and used in all future invitations so that connection reuse is the default behaviour. + - This is the recommend approach, and we further recommend using `did:peer:4`. + - `use_did`: a complete DID, which will be used for the invitation being established. This supports the edge case of an entity wanting to use a new DID for every invitation. It is the responsibility of the controller to create the DID before passing it in. + - If not provided, the 0.11.0 behaviour of an unqualified DID is used. + - We expect this behaviour will change in a later release to be that `use_did_method="did:peer:4"` is the default, which is created and (re)used. +- The provided handshake protocol list must also include `didexchange/1.1`. Optionally, `didexchage/1.0` may also be provided, thus enabling backwards compatibility with agents that do not yet support `didexchage/1.0` and use of unqualified DIDs. + +When receiving an OOB invitation or creating a DID Exchange request to a known Public DID: + +- `POST /didexchange/create-request` and `POST /didexchange/{conn_id}/accept-invitation` accepts two parameters (in addition to others): + - `use_did_method`: a DID Method (options: `did:peer:2` `did:peer:4`) indicating that a DID of that type should be created and used for the connection. + - This is the recommend approach, and we further recommend using `did:peer:4`. + - `use_did`: a complete DID, which will be used for the connection being established. This supports the edge case of an entity wanting to use the same DID for more than one connection. It is the responsibility of the controller to create the DID before passing it in. + - If neither option is provided, the 0.11.0 behaviour of an unqualified DID is created if DID Exchange 1.0 is used, and a DID Peer 4 is used if DID Exchange 1.1 is used. + - We expect this behaviour will change in a later release to be that a `did:peer:4` is created and DID Exchange 1.1 is always used. +- When `auto-accept` is used with DID Exchange, then an unqualified DID is created if DID Exchange 1.0 is being used, and a DID Peer 4 is used if DID Exchange 1.1 is used. + +With these changes, an existing ACA-Py installation using unqualified DIDs can upgrade to use qualified DIDs: + +- Reactively in 0.12.0, by using like DIDs from the other agent. +- Proactively, by adding the `use_did` or `use_did_method` parameter on the `POST /out-of-band/create-invitation`, `POST /didexchange/create-request`. and `POST /didexchange/{conn_id}/accept_invitation` endpoints and specifying `did:peer:2` or `did_peer:4`. + - The other agent must be able to process the selected DID Method. +- Proactively, by updating to use DID Exchange v1.1 and having the other side `auto-accept` the connection. + +## DID Rotation + +As part of the transition to qualified DIDs, existing connections may be updated to qualified DIDs using the DID Rotate protocol. This is not strictly required; since DIDComm v1 depends on recipient keys for correlating a received message back to a connection, the DID itself is mostly ignored. However, as we transition to DIDComm v2 or if it is desired to update the keys associated with a connection, DID Rotate may be used to update keys and service endpoints. + +The steps to do so are: + +- The rotating party creates a new DID using `POST /wallet/did/create` (or through the endpoints provided by a plugged in DID Method, if relevant). + - For example, the rotating party will likely create a new `did:peer:4`. +- The rotating party initiates the rotation with `POST /did-rotate/{conn_id}/rotate` providing the created DID as the `to_did` in the body of the Admin API request. +- If the receiving party supports DID rotation, a `did_rotate` webhook will be emitted indicating success.