From 5c8013fa29fee9a0ece9bb6574e766bf752aabf6 Mon Sep 17 00:00:00 2001 From: Daniel Bluhm Date: Fri, 23 Feb 2024 16:06:36 -0500 Subject: [PATCH] feat: add DID resolution support for did:peer and did:key Signed-off-by: Daniel Bluhm --- poetry.lock | 46 +++- proxy_mediator/doc_normalization.py | 238 ++++++++++++++++++++ proxy_mediator/message_retriever.py | 40 ++-- proxy_mediator/protocols/oob_didexchange.py | 126 +++++++++-- proxy_mediator/resolver.py | 123 ++++++++++ pyproject.toml | 3 + 6 files changed, 537 insertions(+), 39 deletions(-) create mode 100644 proxy_mediator/doc_normalization.py create mode 100644 proxy_mediator/resolver.py diff --git a/poetry.lock b/poetry.lock index 2cb31ac..f4ddbaa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -453,6 +453,34 @@ files = [ [package.extras] toml = ["tomli"] +[[package]] +name = "did-peer-2" +version = "0.1.2" +description = "An implementation of did:peer:2" +optional = false +python-versions = ">=3.9" +files = [ + {file = "did_peer_2-0.1.2-py3-none-any.whl", hash = "sha256:d5908cda2d52b7c34428a421044507d7847fd79b78dc8360441c408f4507d612"}, + {file = "did_peer_2-0.1.2.tar.gz", hash = "sha256:af8623f62022732e9fadc0289dfb886fd8267767251c4fa0b63694ecd29a7086"}, +] + +[package.dependencies] +base58 = ">=2.1.1" + +[[package]] +name = "did-peer-4" +version = "0.1.4" +description = "An implementation of did:peer:4" +optional = false +python-versions = ">=3.9" +files = [ + {file = "did_peer_4-0.1.4-py3-none-any.whl", hash = "sha256:4c2bb42a55e4fec08fe008a1585db2f11fe19e36121f8919991add027d7c816f"}, + {file = "did_peer_4-0.1.4.tar.gz", hash = "sha256:b367922067b428d33458ca36158eaed40c863cde2fbab6a18a523dccad533c8e"}, +] + +[package.dependencies] +base58 = ">=2.1.1" + [[package]] name = "distlib" version = "0.3.8" @@ -984,6 +1012,22 @@ typing-extensions = ">=4.2.0" dotenv = ["python-dotenv (>=0.10.4)"] email = ["email-validator (>=1.0.3)"] +[[package]] +name = "pydid" +version = "0.4.3" +description = "Python library for validating, constructing, and representing DIDs and DID Documents" +optional = false +python-versions = ">=3.8.0,<4.0.0" +files = [ + {file = "pydid-0.4.3-py3-none-any.whl", hash = "sha256:39a586b4f26c41277b93db2aaf0a2db298f48ccc413bdfc71b7dd010045f31f4"}, + {file = "pydid-0.4.3.tar.gz", hash = "sha256:1a48a6940bae8279083ebb7c5ab5fe0249e9ba3ea638de9cf8c127487b96b2ef"}, +] + +[package.dependencies] +inflection = ">=0.5.1,<0.6.0" +pydantic = ">=1.10.0,<2.0.0" +typing-extensions = ">=4.5.0,<5.0.0" + [[package]] name = "pynacl" version = "1.5.0" @@ -1370,4 +1414,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "72e58c8bd02bde3be1ce8dacd70916f0f3b2cd537e1fe0ee0fffbd9005d96d2d" +content-hash = "5bc40d0ea96b46a2c8ca1130309a8fdcfce8bf4c75070f2526a5d1f09fc11676" diff --git a/proxy_mediator/doc_normalization.py b/proxy_mediator/doc_normalization.py new file mode 100644 index 0000000..99e16a2 --- /dev/null +++ b/proxy_mediator/doc_normalization.py @@ -0,0 +1,238 @@ +"""Helpers for normalizing legacy DID Documents.""" + +from copy import deepcopy +from typing import List +from .resolver import DIDKey + + +class LegacyDocCorrections: + """Legacy peer DID document corrections. + + Borrowed from: https://github.com/hyperledger/aries-cloudagent-python/blob/73dd7edad8b7c2373b2ce16397d664606695125a/aries_cloudagent/resolver/default/legacy_peer.py#L27 + + These corrections align the document with updated DID spec and DIDComm + conventions. This also helps with consistent processing of DID Docs. + + Input example: + { + "@context": "https://w3id.org/did/v1", + "id": "did:sov:JNKL9kJxQi5pNCfA8QBXdJ", + "publicKey": [ + { + "id": "did:sov:JNKL9kJxQi5pNCfA8QBXdJ#1", + "type": "Ed25519VerificationKey2018", + "controller": "did:sov:JNKL9kJxQi5pNCfA8QBXdJ", + "publicKeyBase58": "AU2FFjtkVzjFuirgWieqGGqtNrAZWS9LDuB8TDp6EUrG" + } + ], + "authentication": [ + { + "type": "Ed25519SignatureAuthentication2018", + "publicKey": "did:sov:JNKL9kJxQi5pNCfA8QBXdJ#1" + } + ], + "service": [ + { + "id": "did:sov:JNKL9kJxQi5pNCfA8QBXdJ;indy", + "type": "IndyAgent", + "priority": 0, + "recipientKeys": [ + "AU2FFjtkVzjFuirgWieqGGqtNrAZWS9LDuB8TDp6EUrG" + ], + "routingKeys": ["9NnKFUZoYcCqYC2PcaXH3cnaGsoRfyGgyEHbvbLJYh8j"], + "serviceEndpoint": "http://bob:3000" + } + ] + } + + Output example: + { + "@context": "https://w3id.org/did/v1", + "id": "did:sov:JNKL9kJxQi5pNCfA8QBXdJ", + "verificationMethod": [ + { + "id": "did:sov:JNKL9kJxQi5pNCfA8QBXdJ#1", + "type": "Ed25519VerificationKey2018", + "controller": "did:sov:JNKL9kJxQi5pNCfA8QBXdJ", + "publicKeyBase58": "AU2FFjtkVzjFuirgWieqGGqtNrAZWS9LDuB8TDp6EUrG" + } + ], + "authentication": ["did:sov:JNKL9kJxQi5pNCfA8QBXdJ#1"], + "service": [ + { + "id": "did:sov:JNKL9kJxQi5pNCfA8QBXdJ#didcomm", + "type": "did-communication", + "priority": 0, + "recipientKeys": ["did:sov:JNKL9kJxQi5pNCfA8QBXdJ#1"], + "routingKeys": [ + "did:key:z6Mknq3MqipEt9hJegs6J9V7tiLa6T5H5rX3fFCXksJKTuv7#z6Mknq3MqipEt9hJegs6J9V7tiLa6T5H5rX3fFCXksJKTuv7" + ], + "serviceEndpoint": "http://bob:3000" + } + ] + } + """ + + @staticmethod + def public_key_is_verification_method(value: dict) -> dict: + """Replace publicKey with verificationMethod.""" + if "publicKey" in value: + value["verificationMethod"] = value.pop("publicKey") + return value + + @staticmethod + def authentication_is_list_of_verification_methods_and_refs(value: dict) -> dict: + """Update authentication to be a list of methods and references.""" + if "authentication" in value: + modified = [] + for authn in value["authentication"]: + if isinstance(authn, dict) and "publicKey" in authn: + modified.append(authn["publicKey"]) + else: + modified.append(authn) + # TODO more checks? + value["authentication"] = modified + return value + + @staticmethod + def didcomm_services_use_updated_conventions(value: dict) -> dict: + """Update DIDComm services to use updated conventions.""" + if "service" in value: + for index, service in enumerate(value["service"]): + if "type" in service and service["type"] == "IndyAgent": + service["type"] = "did-communication" + if ";" in service["id"]: + service["id"] = value["id"] + f"#didcomm-{index}" + if "#" not in service["id"]: + service["id"] += f"#didcomm-{index}" + if "priority" in service and service["priority"] is None: + service.pop("priority") + return value + + @staticmethod + def recip_base58_to_ref(vms: List[dict], recip: str) -> str: + """Convert base58 public key to ref.""" + for vm in vms: + if "publicKeyBase58" in vm and vm["publicKeyBase58"] == recip: + return vm["id"] + return recip + + @classmethod + def did_key_to_did_key_ref(cls, key: str): + """Convert did:key to did:key ref.""" + # Check if key is already a ref + if key.rfind("#") != -1: + return key + # Get the value after removing did:key: + value = key.replace("did:key:", "") + + return key + "#" + value + + @classmethod + def didcomm_services_recip_keys_are_refs_routing_keys_are_did_key_ref( + cls, + value: dict, + ) -> dict: + """Update DIDComm service recips to use refs and routingKeys to use did:key.""" + vms = value.get("verificationMethod", []) + if "service" in value: + for service in value["service"]: + if "type" in service and service["type"] == "did-communication": + service["recipientKeys"] = [ + cls.recip_base58_to_ref(vms, recip) + for recip in service.get("recipientKeys", []) + ] + if "routingKeys" in service: + service["routingKeys"] = [ + ( + DIDKey.from_public_key_b58(key, "ed25519-pub").key_id + if "did:key:" not in key + else cls.did_key_to_did_key_ref(key) + ) + for key in service["routingKeys"] + ] + return value + + @staticmethod + def qualified(did_or_did_url: str) -> str: + """Make sure DID or DID URL is fully qualified.""" + if not did_or_did_url.startswith("did:"): + return f"did:sov:{did_or_did_url}" + return did_or_did_url + + @classmethod + def fully_qualified_ids_and_controllers(cls, value: dict) -> dict: + """Make sure IDs and controllers are fully qualified.""" + + def _make_qualified(value: dict) -> dict: + if "id" in value: + ident = value["id"] + value["id"] = cls.qualified(ident) + if "controller" in value: + controller = value["controller"] + value["controller"] = cls.qualified(controller) + return value + + value = _make_qualified(value) + vms = [] + for verification_method in value.get("verificationMethod", []): + vms.append(_make_qualified(verification_method)) + + services = [] + for service in value.get("service", []): + services.append(_make_qualified(service)) + + auths = [] + for authn in value.get("authentication", []): + if isinstance(authn, dict): + auths.append(_make_qualified(authn)) + elif isinstance(authn, str): + auths.append(cls.qualified(authn)) + else: + raise ValueError("Unexpected authentication value type") + + value["authentication"] = auths + value["verificationMethod"] = vms + value["service"] = services + return value + + @staticmethod + def remove_verification_method( + vms: List[dict], public_key_base58: str + ) -> List[dict]: + """Remove the verification method with the given key.""" + return [vm for vm in vms if vm["publicKeyBase58"] != public_key_base58] + + @classmethod + def remove_routing_keys_from_verification_method(cls, value: dict) -> dict: + """Remove routing keys from verification methods. + + This was an old convention; routing keys were added to the public keys + of the doc even though they're usually not owned by the doc sender. + + This correction should be applied before turning the routing keys into + did keys. + """ + vms = value.get("verificationMethod", []) + for service in value.get("service", []): + if "routingKeys" in service: + for routing_key in service["routingKeys"]: + vms = cls.remove_verification_method(vms, routing_key) + value["verificationMethod"] = vms + return value + + @classmethod + def apply(cls, value: dict) -> dict: + """Apply all corrections to the given DID document.""" + value = deepcopy(value) + for correction in ( + cls.public_key_is_verification_method, + cls.authentication_is_list_of_verification_methods_and_refs, + cls.fully_qualified_ids_and_controllers, + cls.didcomm_services_use_updated_conventions, + cls.remove_routing_keys_from_verification_method, + cls.didcomm_services_recip_keys_are_refs_routing_keys_are_did_key_ref, + ): + value = correction(value) + + return value diff --git a/proxy_mediator/message_retriever.py b/proxy_mediator/message_retriever.py index 9e5db54..72d149e 100644 --- a/proxy_mediator/message_retriever.py +++ b/proxy_mediator/message_retriever.py @@ -56,6 +56,28 @@ def __init__(self, conn: Connection, poll_interval: float = 5.0): self.poll_task: Optional[asyncio.Task] = None self.ws_task: Optional[asyncio.Task] = None + async def handle_ws( + self, socket: aiohttp.ClientWebSocketResponse, msg: aiohttp.WSMessage + ): + """Handle a message from the websocket.""" + LOGGER.debug("Received ws message: %s", msg) + if msg.type == aiohttp.WSMsgType.BINARY: + try: + unpacked = self.connection.unpack(msg.data) + LOGGER.debug( + "Unpacked message from websocket: %s", + unpacked.pretty_print(), + ) + await self.connection.dispatch(unpacked) + except Exception: + LOGGER.exception("Failed to handle message") + + elif msg.type == aiohttp.WSMsgType.ERROR: + LOGGER.error( + "ws connection closed with exception %s", + socket.exception(), + ) + async def ws(self): """Open websocket and handle messages.""" LOGGER.debug("Starting websocket to %s", self.endpoint) @@ -64,23 +86,7 @@ async def ws(self): async with session.ws_connect(self.endpoint) as socket: self.socket = socket async for msg in socket: - LOGGER.debug("Received ws message: %s", msg) - if msg.type == aiohttp.WSMsgType.BINARY: - try: - unpacked = self.connection.unpack(msg.data) - LOGGER.debug( - "Unpacked message from websocket: %s", - unpacked.pretty_print(), - ) - await self.connection.dispatch(unpacked) - except Exception: - LOGGER.exception("Failed to handle message") - - elif msg.type == aiohttp.WSMsgType.ERROR: - LOGGER.error( - "ws connection closed with exception %s", - socket.exception(), - ) + await self.handle_ws(socket, msg) except Exception: LOGGER.exception("Websocket connection error") self.socket = None diff --git a/proxy_mediator/protocols/oob_didexchange.py b/proxy_mediator/protocols/oob_didexchange.py index 5640edb..31a1f1b 100644 --- a/proxy_mediator/protocols/oob_didexchange.py +++ b/proxy_mediator/protocols/oob_didexchange.py @@ -2,17 +2,28 @@ import json import logging -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Tuple import uuid from aries_staticagent import Message, ModuleRouter, Target, crypto +import base58 from multiformats import multibase, multicodec +from pydid import DIDDocument, Service, VerificationMethod, deserialize_document +from pydid.service import DIDCommV1Service +from pydid.verification_method import ( + Ed25519VerificationKey2018, + Ed25519VerificationKey2020, + Multikey, +) + +from ..doc_normalization import LegacyDocCorrections from ..connection import Connection, ConnectionMachine from ..encode import b64_to_bytes, b64_to_dict, bytes_to_b64, dict_to_b64, unpad from ..error import ProtocolError from .connections import Connections from .constants import DIDCOMM, DIDCOMM_OLD +from ..resolver import DIDResolver LOGGER = logging.getLogger(__name__) @@ -178,6 +189,90 @@ async def receive_invite(self, invite: Message, *, endpoint: Optional[str] = Non return new_connection + def vm_to_verkey(self, vm: VerificationMethod) -> bytes: + """Convert a verification method to a verkey.""" + LOGGER.debug("Converting VM to verkey: %s", vm.__class__.__name__) + if isinstance(vm, Ed25519VerificationKey2018): + return base58.b58decode(vm.public_key_base58) + elif isinstance(vm, Ed25519VerificationKey2020): + if vm.public_key_multibase.startswith("z6Mk"): + codec, verkey = multicodec.unwrap( + multibase.decode(vm.public_key_multibase) + ) + assert codec.name == "ed25519-pub" + return verkey + elif vm.public_key_multibase.startswith("z"): + return multibase.decode(vm.public_key_multibase) + else: + raise ValueError( + f"Unsupported multibase encoded value: {vm.public_key_multibase}" + ) + elif isinstance(vm, Multikey): + codec, verkey = multicodec.unwrap(multibase.decode(vm.public_key_multibase)) + if codec.name == "ed25519-pub": + return verkey + + raise ValueError(f"Unsupported multicodec: {codec.name}") + raise ValueError(f"Unsupported verification method: {vm.type}") + + async def target_from_doc( + self, + doc: DIDDocument, + type: Optional[str] = None, + protocol: Optional[str] = None, + ) -> Target: + """Create a target from a DID Document.""" + + def _filter(service: Service) -> bool: + """Filter services.""" + if not isinstance(service, DIDCommV1Service): + return False + + return ( + service.type == type + or "did-communication" + and service.service_endpoint.startswith(protocol or "http") + ) + + service = next(filter(_filter, doc.service or [])) + assert isinstance(service, DIDCommV1Service) + vm = doc.dereference(service.recipient_keys[0]) + if not isinstance(vm, VerificationMethod): + raise ProtocolError( + "Invalid verification method reference in recipient keys" + ) + verkey = self.vm_to_verkey(vm) + + return Target( + their_vk=verkey, + endpoint=service.service_endpoint, + ) + + async def doc_from_request_or_response( + self, message: Message + ) -> Tuple[DIDDocument, bytes]: + """Extract DID Document from a DID Exchange Request or Response.""" + if "did_doc~attach" in message: + verified, signer = self.verify_signed_attachment(message["did_doc~attach"]) + if not verified: + raise ProtocolError("Invalid signature on DID Doc") + + doc = b64_to_dict(message["did_doc~attach"]["data"]["base64"]) + normalized = LegacyDocCorrections.apply(doc) + return deserialize_document(normalized), signer + + elif "did_rotate~attach" in message: + verified, signer = self.verify_signed_attachment( + message["did_rotate~attach"] + ) + if not verified: + raise ProtocolError("Invalid signature on DID Rotattion") + + resolver = DIDResolver() + return await resolver.resolve_and_parse(message["did"]), signer + + raise ProtocolError("No DID Doc or DID Rotation attachment") + @route @route(doc_uri=DIDCOMM_OLD) async def request(self, msg: Message, invite_connection: Connection): @@ -189,23 +284,17 @@ async def request(self, msg: Message, invite_connection: Connection): if not invite_connection.multiuse: self.connections.pop(invite_connection.verkey_b58) - verified, signer = self.verify_signed_attachment(msg["did_doc~attach"]) - if not verified: - raise ProtocolError("Invalid signature on DID Doc") - - doc = b64_to_dict(msg["did_doc~attach"]["data"]["base64"]) + doc, signer = await self.doc_from_request_or_response(msg) + target = await self.target_from_doc(doc) ConnectionMachine(invite_connection).receive_request() # Create relationship connection connection = self.new_connection( invite_connection=invite_connection, - target=Target( - endpoint=doc["service"][0]["serviceEndpoint"], - recipients=doc["service"][0]["recipientKeys"], - ), + target=target, ) - connection.diddoc = doc + connection.diddoc = doc.serialize() ConnectionMachine(connection).send_response() @@ -232,24 +321,19 @@ async def response(self, msg: Message, conn: Connection): LOGGER.debug("Received response: %s", msg.pretty_print()) ConnectionMachine(conn).receive_response() - verified, signer = self.verify_signed_attachment(msg["did_doc~attach"]) - if not verified: - raise ProtocolError("Invalid signature on DID Doc") + doc, signer = await self.doc_from_request_or_response(msg) + target = await self.target_from_doc(doc) - doc = b64_to_dict(msg["did_doc~attach"]["data"]["base64"]) assert conn.invitation_key if not signer == didkey_to_verkey(conn.invitation_key): raise ProtocolError("Connection response not signed by invitation key") - LOGGER.debug("Attached DID Doc: %s", json.dumps(doc, indent=2)) + LOGGER.debug("Attached DID Doc: %s", json.dumps(doc.serialize(), indent=2)) # Update connection assert conn.target - conn.target.update( - recipients=doc["service"][0]["recipientKeys"], - endpoint=doc["service"][0]["serviceEndpoint"], - ) - conn.diddoc = doc + conn.target = target + conn.diddoc = doc.serialize() conn.complete() complete = Message.parse_obj( diff --git a/proxy_mediator/resolver.py b/proxy_mediator/resolver.py new file mode 100644 index 0000000..919c30f --- /dev/null +++ b/proxy_mediator/resolver.py @@ -0,0 +1,123 @@ +"""DID Resolver.""" + +from typing import Literal +import base58 +from multiformats import multibase, multicodec +from pydid import DIDDocument, DIDUrl, Resource, VerificationMethod +from did_peer_2 import resolve as resolve_peer2 +from did_peer_4 import resolve as resolve_peer4 + + +class DIDResolutionError(Exception): + """Represents an error from a DID Resolver.""" + + +class DIDNotFound(DIDResolutionError): + """Represents a DID not found error.""" + + +class DIDMethodNotSupported(DIDResolutionError): + """Represents a DID method not supported error.""" + + +class DIDResolver: + """DID Resolver. + + Supports did:peer:2 and did:peer:4. + """ + + def __init__(self): + """Initialize the resolver.""" + self.resolvers = { + "did:peer:2": resolve_peer2, + "did:peer:4": resolve_peer4, + "did:key:": DIDKey.resolve, + } + + async def resolve(self, did: str) -> dict: + """Resolve a DID.""" + for prefix, resolver in self.resolvers.items(): + if did.startswith(prefix): + return resolver(did) + + raise DIDMethodNotSupported(f"No resolver found for DID {did}") + + async def resolve_and_parse(self, did: str) -> DIDDocument: + """Resolve a DID and parse the DID document.""" + doc = await self.resolve(did) + return DIDDocument.deserialize(doc) + + async def resolve_and_dereference(self, did_url: str) -> Resource: + """Resolve a DID URL and dereference the identifier.""" + url = DIDUrl.parse(did_url) + if not url.did: + raise DIDResolutionError("Invalid DID URL; must be absolute") + + doc = await self.resolve_and_parse(url.did) + return doc.dereference(url) + + async def resolve_and_dereference_verification_method( + self, did_url: str + ) -> VerificationMethod: + """Resolve a DID URL and dereference the identifier.""" + resource = await self.resolve_and_dereference(did_url) + if not isinstance(resource, VerificationMethod): + raise DIDResolutionError("Resource is not a verification method") + + return resource + + +class DIDKey: + """DID Key resolver and helper class.""" + + @staticmethod + def resolve(did: str) -> dict: + """Resolve a did:key DID.""" + if not did.startswith("did:key:"): + raise ValueError(f"Invalid did:key: {did}") + + multikey = did.split("did:key:", 1)[1] + key_id = f"{did}#{multikey}" + + verification_method = { + "id": key_id, + "type": "Multikey", + "controller": did, + "publicKeyMultibase": multikey, + } + + if multikey.startswith("z6Mk"): + return { + "@context": "https://www.w3.org/ns/did/v1", + "id": did, + "verificationMethod": [verification_method], + "authentication": [key_id], + "assertionMethod": [key_id], + } + elif multikey.startswith("z6LS"): + return { + "@context": "https://www.w3.org/ns/did/v1", + "id": did, + "verificationMethod": [verification_method], + "keyAgreement": [key_id], + } + + raise ValueError(f"Unsupported key type: {multikey}") + + def __init__(self, multikey: str): + """Initialize the DID Key.""" + self.multikey = multikey + + @classmethod + def from_public_key_b58( + cls, public_key: str, key_type: Literal["ed25519-pub", "x25519-pub"] + ) -> "DIDKey": + """Create a DID Key from a public key.""" + key_bytes = base58.b58decode(public_key) + multikey = multibase.encode(multicodec.wrap(key_type, key_bytes), "base58btc") + return cls(multikey) + + @property + def key_id(self): + """Get the key ID.""" + return f"did:key:{self.multikey}#{self.multikey}" diff --git a/pyproject.toml b/pyproject.toml index 6b2c0f7..02fe711 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ inflection = "^0.5.1" ConfigArgParse = "^1.5.3" aries-askar = "^0.2.2" multiformats = "^0.3.1" +did-peer-2 = "^0.1.2" +did-peer-4 = "^0.1.4" +pydid = "^0.4.3" [tool.poetry.dev-dependencies] pre-commit = "^2.15.0"