From 8dec969d1c5b4c397232136c11575c8bec69ca0a Mon Sep 17 00:00:00 2001 From: Daniel Bluhm Date: Mon, 30 Oct 2023 23:07:44 -0400 Subject: [PATCH] feat: emit did:peer:2 DID in DID Exchange Signed-off-by: Daniel Bluhm --- aries_cloudagent/config/argparse.py | 10 ++ aries_cloudagent/connections/base_manager.py | 69 ++++++++++++- .../messaging/decorators/attach_decorator.py | 34 +++++++ .../protocols/didexchange/v1_0/manager.py | 96 ++++++++++++++----- .../didexchange/v1_0/messages/response.py | 8 ++ aries_cloudagent/wallet/did_method.py | 9 +- 6 files changed, 199 insertions(+), 27 deletions(-) diff --git a/aries_cloudagent/config/argparse.py b/aries_cloudagent/config/argparse.py index 4247ffa725..0cdd78cddc 100644 --- a/aries_cloudagent/config/argparse.py +++ b/aries_cloudagent/config/argparse.py @@ -1231,6 +1231,12 @@ 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"), + ) def get_settings(self, args: Namespace) -> dict: """Get protocol settings.""" @@ -1297,6 +1303,10 @@ def get_settings(self, args: Namespace) -> dict: if args.exch_use_unencrypted_tags: settings["exch_use_unencrypted_tags"] = True environ["EXCH_UNENCRYPTED_TAGS"] = "True" + + if args.emit_did_peer_2: + settings["emit_did_peer_2"] = True + return settings diff --git a/aries_cloudagent/connections/base_manager.py b/aries_cloudagent/connections/base_manager.py index 7cc63cd691..372d587b9b 100644 --- a/aries_cloudagent/connections/base_manager.py +++ b/aries_cloudagent/connections/base_manager.py @@ -6,6 +6,8 @@ import logging from typing import List, Optional, Sequence, Text, Tuple, Union +from base58 import b58decode +from did_peer_2 import KeySpec, generate from pydid import ( BaseDIDDocument as ResolvedDocument, DIDCommService, @@ -44,8 +46,8 @@ from ..utils.multiformats import multibase, multicodec from ..wallet.base import BaseWallet from ..wallet.crypto import create_keypair, seed_to_did -from ..wallet.did_info import DIDInfo -from ..wallet.did_method import SOV +from ..wallet.did_info import DIDInfo, KeyInfo +from ..wallet.did_method import PEER2, SOV from ..wallet.error import WalletNotFoundError from ..wallet.key_type import ED25519 from ..wallet.util import b64_to_bytes, bytes_to_b58 @@ -77,6 +79,69 @@ def __init__(self, profile: Profile): logger_name=__name__, ) + @staticmethod + def _key_info_to_multikey(key_info: KeyInfo) -> str: + """Convert a KeyInfo to a multikey.""" + return multibase.encode( + multicodec.wrap("ed25519-pub", b58decode(key_info.verkey)), "base58btc" + ) + + async def create_did_peer_2( + self, + svc_endpoints: Optional[Sequence[str]] = None, + mediation_records: Optional[List[MediationRecord]] = None, + ) -> DIDInfo: + """Create a did:peer:2 DID for a connection. + + Args: + svc_endpoints: Custom endpoints for the DID Document + mediation_record: The record for mediation that contains routing_keys and + service endpoint + + Returns: + The new `DIDInfo` instance + """ + routing_keys: List[str] = [] + if mediation_records: + for mediation_record in mediation_records: + ( + mediator_routing_keys, + endpoint, + ) = await self._route_manager.routing_info( + self._profile, mediation_record + ) + routing_keys = [*routing_keys, *(mediator_routing_keys or [])] + if endpoint: + svc_endpoints = [endpoint] + + services = [] + for index, endpoint in enumerate(svc_endpoints or []): + services.append( + { + "id": f"#didcomm-{index}", + "type": "did-communication", + "priority": index, + "recipientKeys": ["#keys-1"], + "routingKeys": routing_keys, + "serviceEndpoint": endpoint, + } + ) + + async with self._profile.session() as session: + wallet = session.inject(BaseWallet) + key = await wallet.create_key(ED25519) + + did = generate( + [KeySpec.verification(self._key_info_to_multikey(key))], services + ) + + did_info = DIDInfo( + did=did, method=PEER2, verkey=key.verkey, metadata={}, key_type=ED25519 + ) + await wallet.store_did(did_info) + + return did_info + async def create_did_document( self, did_info: DIDInfo, diff --git a/aries_cloudagent/messaging/decorators/attach_decorator.py b/aries_cloudagent/messaging/decorators/attach_decorator.py index d27d9a9924..fa09ddac26 100644 --- a/aries_cloudagent/messaging/decorators/attach_decorator.py +++ b/aries_cloudagent/messaging/decorators/attach_decorator.py @@ -591,6 +591,40 @@ def content(self) -> Union[Mapping, Tuple[Sequence[str], str]]: else: return None + @classmethod + def data_base65_string( + cls, + content: str, + *, + ident: str = None, + description: str = None, + filename: str = None, + lastmod_time: str = None, + byte_count: int = None, + ): + """Create `AttachDecorator` instance on base64-encoded string data. + + Given string content, base64-encode, and embed it as data; mark + `text/string` MIME type. + + Args: + content: string content + ident: optional attachment identifier (default random UUID4) + description: optional attachment description + filename: optional attachment filename + lastmod_time: optional attachment last modification time + byte_count: optional attachment byte count + """ + return AttachDecorator( + ident=ident or str(uuid.uuid4()), + description=description, + filename=filename, + mime_type="text/string", + lastmod_time=lastmod_time, + byte_count=byte_count, + data=AttachDecoratorData(base64_=bytes_to_b64(content.encode())), + ) + @classmethod def data_base64( cls, diff --git a/aries_cloudagent/protocols/didexchange/v1_0/manager.py b/aries_cloudagent/protocols/didexchange/v1_0/manager.py index 1f4bc3a590..af4f41d8fa 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/manager.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/manager.py @@ -293,10 +293,24 @@ async def create_request( my_info = None + # Create connection request message + 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", [])) + + emit_did_peer_2 = self.profile.settings.get("emit_did_peer_2") if conn_rec.my_did: async with self.profile.session() as session: wallet = session.inject(BaseWallet) my_info = await wallet.get_local_did(conn_rec.my_did) + elif emit_did_peer_2: + my_info = await self.create_did_peer_2(my_endpoints, mediation_records) + conn_rec.my_did = my_info.did else: # Create new DID for connection async with self.profile.session() as session: @@ -307,17 +321,7 @@ async def create_request( ) conn_rec.my_did = my_info.did - # Create connection request message - 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", [])) - - if use_public_did: + if use_public_did or emit_did_peer_2: # Omit DID Doc attachment if we're using a public DID did_doc = None attach = None @@ -598,6 +602,16 @@ async def create_response( async with self.profile.session() as session: request = await conn_rec.retrieve_request(session) + 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", [])) + + emit_did_peer_2 = self.profile.settings.get("emit_did_peer_2") if conn_rec.my_did: async with self.profile.session() as session: wallet = session.inject(BaseWallet) @@ -613,6 +627,10 @@ async def create_response( did = my_info.did if not did.startswith("did:"): did = f"did:sov:{did}" + elif emit_did_peer_2: + my_info = await self.create_did_peer_2(my_endpoints, mediation_records) + conn_rec.my_did = my_info.did + did = my_info.did else: async with self.profile.session() as session: wallet = session.inject(BaseWallet) @@ -628,20 +646,16 @@ async def create_response( self.profile, conn_rec, mediation_records ) - # Create connection response message - 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", [])) - - if use_public_did: + if use_public_did or emit_did_peer_2: # Omit DID Doc attachment if we're using a public DID - did_doc = None attach = None + if conn_rec.invitation_msg_id is None: + # Rotation needed + attach = AttachDecorator.data_base65_string(did) + async with self.profile.session() as session: + wallet = session.inject(BaseWallet) + await attach.data.sign(conn_rec.invitation_key, wallet) + response = DIDXResponse(did=did, did_rotate_attach=attach) else: did_doc = await self.create_did_document( my_info, @@ -652,8 +666,8 @@ async def create_response( async with self.profile.session() as session: wallet = session.inject(BaseWallet) await attach.data.sign(conn_rec.invitation_key, wallet) + response = DIDXResponse(did=did, did_doc_attach=attach) - response = DIDXResponse(did=did, did_doc_attach=attach) # Assign thread information response.assign_thread_from(request) response.assign_trace_from(request) @@ -770,6 +784,23 @@ async def accept_response( if response.did is None: raise DIDXManagerError("No DID in response") + if response.did_rotate_attach is None: + raise DIDXManagerError( + "did_rotate~attach required if no signed doc attachment" + ) + + self._logger.debug("did_rotate~attach found; verifying signature") + async with self.profile.session() as session: + wallet = session.inject(BaseWallet) + signed_did = await self.verify_rotate( + wallet, response.did_rotate_attach, conn_rec.invitation_key + ) + if their_did != response.did: + raise DIDXManagerError( + f"Connection DID {their_did} " + f"does not match singed DID rotate {signed_did}" + ) + self._logger.debug( "No DID Doc attachment in response; doc will be resolved from DID" ) @@ -942,6 +973,23 @@ async def verify_diddoc( return DIDDoc.deserialize(json.loads(signed_diddoc_bytes.decode())) + async def verify_rotate( + self, + wallet: BaseWallet, + attached: AttachDecorator, + invi_key: str = None, + ) -> str: + """Verify a signed DID rotate attachment and return did.""" + signed_diddoc_bytes = attached.data.signed + if not signed_diddoc_bytes: + raise DIDXManagerError("DID rotate attachment is not signed.") + if not await attached.data.verify(wallet, invi_key): + raise DIDXManagerError( + "DID rotate attachment signature failed verification" + ) + + return signed_diddoc_bytes.decode() + async def get_resolved_did_document(self, qualified_did: str) -> ResolvedDocument: """Return resolved DID document.""" resolver = self._profile.inject(DIDResolver) diff --git a/aries_cloudagent/protocols/didexchange/v1_0/messages/response.py b/aries_cloudagent/protocols/didexchange/v1_0/messages/response.py index ae00ac2181..927a038dec 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/messages/response.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/messages/response.py @@ -29,6 +29,7 @@ def __init__( *, did: str = None, did_doc_attach: Optional[AttachDecorator] = None, + did_rotate_attach: Optional[AttachDecorator] = None, **kwargs, ): """Initialize DID exchange response object under RFC 23. @@ -40,6 +41,7 @@ def __init__( super().__init__(**kwargs) self.did = did self.did_doc_attach = did_doc_attach + self.did_rotate_attach = did_rotate_attach class DIDXResponseSchema(AgentMessageSchema): @@ -61,3 +63,9 @@ class Meta: data_key="did_doc~attach", metadata={"description": "As signed attachment, DID Doc associated with DID"}, ) + did_rotate_attach = fields.Nested( + AttachDecoratorSchema, + required=False, + data_key="did_rotate~attach", + metadata={"description": "As signed attachment, DID signed by invitation key"}, + ) diff --git a/aries_cloudagent/wallet/did_method.py b/aries_cloudagent/wallet/did_method.py index cbe1361b39..6985c07cc6 100644 --- a/aries_cloudagent/wallet/did_method.py +++ b/aries_cloudagent/wallet/did_method.py @@ -4,7 +4,7 @@ from typing import Dict, List, Mapping, Optional from .error import BaseError -from .key_type import BLS12381G2, ED25519, KeyType +from .key_type import BLS12381G2, ED25519, X25519, KeyType class HolderDefinedDid(Enum): @@ -70,6 +70,12 @@ def holder_defined_did(self) -> HolderDefinedDid: key_types=[ED25519, BLS12381G2], rotation=False, ) +PEER2 = DIDMethod( + name="did:peer:2", + key_types=[ED25519, X25519], + rotation=False, + holder_defined_did=HolderDefinedDid.NO, +) class DIDMethods: @@ -80,6 +86,7 @@ def __init__(self) -> None: self._registry: Dict[str, DIDMethod] = { SOV.method_name: SOV, KEY.method_name: KEY, + PEER2.method_name: PEER2, } def registered(self, method: str) -> bool: