From 599e0fdac38c86116ca721249d530b24addf8fa4 Mon Sep 17 00:00:00 2001 From: Andrew Whitehead Date: Thu, 27 Jun 2019 17:35:08 -0700 Subject: [PATCH 1/3] merge DIDDoc support from von_anchor Signed-off-by: Andrew Whitehead --- .../messaging/connections/manager.py | 5 +- .../messages/tests/test_connection_request.py | 6 +- .../tests/test_connection_response.py | 9 +- .../connections/models/connection_detail.py | 4 +- .../connections/models/diddoc/__init__.py | 23 ++ .../connections/models/diddoc/diddoc.py | 324 ++++++++++++++++++ .../connections/models/diddoc/publickey.py | 200 +++++++++++ .../connections/models/diddoc/service.py | 137 ++++++++ .../connections/models/diddoc/util.py | 114 ++++++ docs/conf.py | 2 +- requirements.txt | 1 - 11 files changed, 808 insertions(+), 17 deletions(-) create mode 100644 aries_cloudagent/messaging/connections/models/diddoc/__init__.py create mode 100644 aries_cloudagent/messaging/connections/models/diddoc/diddoc.py create mode 100644 aries_cloudagent/messaging/connections/models/diddoc/publickey.py create mode 100644 aries_cloudagent/messaging/connections/models/diddoc/service.py create mode 100644 aries_cloudagent/messaging/connections/models/diddoc/util.py diff --git a/aries_cloudagent/messaging/connections/manager.py b/aries_cloudagent/messaging/connections/manager.py index a1cc864f7f..57aba31c95 100644 --- a/aries_cloudagent/messaging/connections/manager.py +++ b/aries_cloudagent/messaging/connections/manager.py @@ -6,10 +6,6 @@ from typing import Tuple -from von_anchor.a2a import DIDDoc -from von_anchor.a2a.publickey import PublicKey, PublicKeyType -from von_anchor.a2a.service import Service - from ...cache.base import BaseCache from ...error import BaseError from ...config.base import InjectorError @@ -32,6 +28,7 @@ from .models.connection_detail import ConnectionDetail from .models.connection_record import ConnectionRecord from .models.connection_target import ConnectionTarget +from .models.diddoc import DIDDoc, PublicKey, PublicKeyType, Service class ConnectionManagerError(BaseError): diff --git a/aries_cloudagent/messaging/connections/messages/tests/test_connection_request.py b/aries_cloudagent/messaging/connections/messages/tests/test_connection_request.py index 14798eb890..c0f017e057 100644 --- a/aries_cloudagent/messaging/connections/messages/tests/test_connection_request.py +++ b/aries_cloudagent/messaging/connections/messages/tests/test_connection_request.py @@ -2,12 +2,10 @@ from asynctest import TestCase as AsyncTestCase -from von_anchor.a2a import DIDDoc -from von_anchor.a2a.publickey import PublicKey, PublicKeyType -from von_anchor.a2a.service import Service +from ...models.diddoc import DIDDoc, PublicKey, PublicKeyType, Service +from ...message_types import CONNECTION_REQUEST from ..connection_request import ConnectionRequest, ConnectionDetail -from ...message_types import CONNECTION_REQUEST class TestConfig: diff --git a/aries_cloudagent/messaging/connections/messages/tests/test_connection_response.py b/aries_cloudagent/messaging/connections/messages/tests/test_connection_response.py index 4e2deccbe1..a2bc082956 100644 --- a/aries_cloudagent/messaging/connections/messages/tests/test_connection_response.py +++ b/aries_cloudagent/messaging/connections/messages/tests/test_connection_response.py @@ -2,13 +2,12 @@ from asynctest import TestCase as AsyncTestCase -from von_anchor.a2a import DIDDoc -from von_anchor.a2a.publickey import PublicKey, PublicKeyType -from von_anchor.a2a.service import Service +from .....wallet.basic import BasicWallet -from ..connection_response import ConnectionResponse, ConnectionDetail +from ...models.diddoc import DIDDoc, PublicKey, PublicKeyType, Service from ...message_types import CONNECTION_RESPONSE -from .....wallet.basic import BasicWallet + +from ..connection_response import ConnectionResponse, ConnectionDetail class TestConfig: diff --git a/aries_cloudagent/messaging/connections/models/connection_detail.py b/aries_cloudagent/messaging/connections/models/connection_detail.py index 6307dae7ee..1420cb8714 100644 --- a/aries_cloudagent/messaging/connections/models/connection_detail.py +++ b/aries_cloudagent/messaging/connections/models/connection_detail.py @@ -2,10 +2,10 @@ from marshmallow import fields -from von_anchor.a2a import DIDDoc - from ...models.base import BaseModel, BaseModelSchema +from ..models.diddoc import DIDDoc + class DIDDocWrapper(fields.Field): """Field that loads and serializes DIDDoc.""" diff --git a/aries_cloudagent/messaging/connections/models/diddoc/__init__.py b/aries_cloudagent/messaging/connections/models/diddoc/__init__.py new file mode 100644 index 0000000000..e41b003cc8 --- /dev/null +++ b/aries_cloudagent/messaging/connections/models/diddoc/__init__.py @@ -0,0 +1,23 @@ +""" +DID Document model support. + +Copyright 2017-2019 Government of Canada +Public Services and Procurement Canada - buyandsell.gc.ca + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +from .diddoc import DIDDoc +from .publickey import LinkedDataKeySpec, PublicKey, PublicKeyType +from .service import Service diff --git a/aries_cloudagent/messaging/connections/models/diddoc/diddoc.py b/aries_cloudagent/messaging/connections/models/diddoc/diddoc.py new file mode 100644 index 0000000000..69bcdae3af --- /dev/null +++ b/aries_cloudagent/messaging/connections/models/diddoc/diddoc.py @@ -0,0 +1,324 @@ +""" +DID Document classes. + +Copyright 2017-2019 Government of Canada +Public Services and Procurement Canada - buyandsell.gc.ca + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +import json +import logging + +from typing import List, Sequence, Union + +from .publickey import PublicKey, PublicKeyType +from .service import Service +from .util import canon_did, canon_ref, ok_did, resource + + +LOGGER = logging.getLogger(__name__) + + +class DIDDoc: + """ + DID document, grouping a DID with verification keys and services. + + Retains DIDs as raw values (orientated toward indy-facing operations), + everything else as URIs (oriented toward W3C-facing operations). + """ + + CONTEXT = "https://w3id.org/did/v1" + + def __init__(self, did: str = None) -> None: + """ + Initialize the DIDDoc instance. + + Retain DID ('id' in DIDDoc context); initialize verification keys + and services to empty lists. + + Args: + did: DID for current DIDdoc + + Raises: + ValueError: for bad input DID. + + """ + + self._did = canon_did(did) if did else None # allow specification post-hoc + self._pubkey = {} + self._service = {} + + @property + def did(self) -> str: + """Accessor for DID.""" + + return self._did + + @did.setter + def did(self, value: str) -> None: + """ + Set DID ('id' in DIDDoc context). + + Args: + value: DID + + Raises: + ValueError: for bad input DID. + + """ + + self._did = canon_did(value) if value else None + + @property + def pubkey(self) -> dict: + """Accessor for public keys by identifier.""" + + return self._pubkey + + @property + def authnkey(self) -> dict: + """Accessor for public keys marked as authentication keys, by identifier.""" + + return {k: self._pubkey[k] for k in self._pubkey if self._pubkey[k].authn} + + @property + def service(self) -> dict: + """Accessor for services by identifier.""" + + return self._service + + def set(self, item: Union[Service, PublicKey]) -> "DIDDoc": + """ + Add or replace service or public key; return current DIDDoc. + + Raises: + ValueError: if input item is neither service nor public key. + + Args: + item: service or public key to set + + Returns: the current DIDDoc + + """ + + if isinstance(item, Service): + self.service[item.id] = item + elif isinstance(item, PublicKey): + self.pubkey[item.id] = item + else: + raise ValueError( + "Cannot add item {} to DIDDoc on DID {}".format(item, self.did) + ) + + def serialize(self) -> str: + """ + Dump current object to a JSON-compatible dictionary. + + Returns: + dict representation of current DIDDoc + + """ + + return { + "@context": DIDDoc.CONTEXT, + "id": canon_ref(self.did, self.did), + "publicKey": [pubkey.to_dict() for pubkey in self.pubkey.values()], + "authentication": [ + { + "type": pubkey.type.authn_type, + "publicKey": canon_ref(self.did, pubkey.id), + } + for pubkey in self.pubkey.values() + if pubkey.authn + ], + "service": [service.to_dict() for service in self.service.values()], + } + + def to_json(self) -> str: + """ + Dump current object as json (JSON-LD). + + Returns: + json representation of current DIDDoc + + """ + + return json.dumps(self.serialize()) + + def add_service_pubkeys( + self, service: dict, tags: Union[Sequence[str], str] + ) -> List[PublicKey]: + """ + Add public keys specified in service. Return public keys so discovered. + + Args: + service: service from DID document + tags: potential tags marking public keys of type of interest + (the standard is still coalescing) + + Raises: + ValueError: for public key reference not present in DID document. + + Returns: list of public keys from the document service specification + + """ + + rv = [] + for tag in [tags] if isinstance(tags, str) else list(tags): + + for svc_key in service.get(tag, {}): + canon_key = canon_ref(self.did, svc_key) + pubkey = None + + if "#" in svc_key: + if canon_key in self.pubkey: + pubkey = self.pubkey[canon_key] + else: # service key refers to another DID doc + LOGGER.debug( + "DID document %s has no public key %s", self.did, svc_key + ) + raise ValueError( + "DID document {} has no public key {}".format( + self.did, svc_key + ) + ) + else: + for existing_pubkey in self.pubkey.values(): + if existing_pubkey.value == svc_key: + pubkey = existing_pubkey + break + else: + pubkey = PublicKey( + self.did, + ident=svc_key[-9:-1], # industrial-grade uniqueness + value=svc_key, + ) + self._pubkey[pubkey.id] = pubkey + + if ( + pubkey and pubkey not in rv + ): # perverse case: could specify same key multiple ways; append once + rv.append(pubkey) + + return rv + + @classmethod + def deserialize(cls, did_doc: dict) -> "DIDDoc": + """ + Construct DIDDoc object from dict representation. + + Args: + did_doc: DIDDoc dict representation + + Raises: + ValueError: for bad DID or missing mandatory item. + + Returns: DIDDoc from input json + + """ + + rv = None + if "id" in did_doc: + rv = DIDDoc(did_doc["id"]) + else: + # heuristic: get DID to serve as DID document identifier from + # the first OK-looking public key + for section in ("publicKey", "authentication"): + if rv is None and section in did_doc: + for key_spec in did_doc[section]: + try: + pubkey_did = canon_did(resource(key_spec.get("id", ""))) + if ok_did(pubkey_did): + rv = DIDDoc(pubkey_did) + break + except ValueError: # no identifier here, move on to next + break + if rv is None: + LOGGER.debug("no identifier in DID document") + raise ValueError("No identifier in DID document") + + for pubkey in did_doc.get( + "publicKey", {} + ): # include all public keys, authentication pubkeys by reference + pubkey_type = PublicKeyType.get(pubkey["type"]) + authn = any( + canon_ref(rv.did, ak.get("publicKey", "")) + == canon_ref(rv.did, pubkey["id"]) + for ak in did_doc.get("authentication", {}) + if isinstance(ak.get("publicKey", None), str) + ) + key = PublicKey( # initialization canonicalizes id + rv.did, + pubkey["id"], + pubkey[pubkey_type.specifier], + pubkey_type, + canon_did(pubkey["controller"]), + authn, + ) + rv.pubkey[key.id] = key + + for akey in did_doc.get( + "authentication", {} + ): # include embedded authentication keys + if "publicKey" not in akey: # not yet got it with public keys + pubkey_type = PublicKeyType.get(akey["type"]) + key = PublicKey( # initialization canonicalized id + rv.did, + akey["id"], + akey[pubkey_type.specifier], + pubkey_type, + canon_did(akey["controller"]), + True, + ) + rv.pubkey[key.id] = key + + for service in did_doc.get("service", {}): + endpoint = service["serviceEndpoint"] + svc = Service( # initialization canonicalizes id + rv.did, + service.get( + "id", + canon_ref( + rv.did, "assigned-service-{}".format(len(rv.service)), ";" + ), + ), + service["type"], + rv.add_service_pubkeys(service, "recipientKeys"), + rv.add_service_pubkeys(service, ["mediatorKeys", "routingKeys"]), + canon_ref(rv.did, endpoint, ";") if ";" in endpoint else endpoint, + service.get("priority", None), + ) + rv.service[svc.id] = svc + + return rv + + @classmethod + def from_json(cls, did_doc_json: str) -> "DIDDoc": + """ + Construct DIDDoc object from json representation. + + Args: + did_doc_json: DIDDoc json representation + + Returns: DIDDoc from input json + + """ + + return cls.deserialize(json.loads(did_doc_json)) + + def __str__(self) -> str: + """Return string representation for abbreviated display.""" + + return "DIDDoc({})".format(self.did) diff --git a/aries_cloudagent/messaging/connections/models/diddoc/publickey.py b/aries_cloudagent/messaging/connections/models/diddoc/publickey.py new file mode 100644 index 0000000000..25aa8874fb --- /dev/null +++ b/aries_cloudagent/messaging/connections/models/diddoc/publickey.py @@ -0,0 +1,200 @@ +""" +DID Document Public Key classes. + +Copyright 2017-2019 Government of Canada +Public Services and Procurement Canada - buyandsell.gc.ca + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +from collections import namedtuple +from enum import Enum + +from .util import canon_did, canon_ref + + +LinkedDataKeySpec = namedtuple("LinkedDataKeySpec", "ver_type authn_type specifier") + + +class PublicKeyType(Enum): + """Class encapsulating public key types.""" + + RSA_SIG_2018 = LinkedDataKeySpec( + "RsaVerificationKey2018", "RsaSignatureAuthentication2018", "publicKeyPem" + ) + ED25519_SIG_2018 = LinkedDataKeySpec( + "Ed25519VerificationKey2018", + "Ed25519SignatureAuthentication2018", + "publicKeyBase58", + ) + EDDSA_SA_SIG_SECP256K1 = LinkedDataKeySpec( + "Secp256k1VerificationKey2018", + "Secp256k1SignatureAuthenticationKey2018", + "publicKeyHex", + ) + + @staticmethod + def get(val: str) -> "PublicKeyType": + """ + Find enum instance corresponding to input value (RsaVerificationKey2018 etc). + + Args: + val: input value marking public key type + + Returns: the public key type + + """ + + for pktype in PublicKeyType: + if val in (pktype.ver_type, pktype.authn_type): + return pktype + return None + + @property + def ver_type(self) -> str: + """Accessor for the verification type identifier.""" + + return self.value.ver_type + + @property + def authn_type(self) -> str: + """Accessor for the authentication type identifier.""" + + return self.value.authn_type + + @property + def specifier(self) -> str: + """Accessor for the value specifier.""" + + return self.value.specifier + + def specification(self, val: str) -> str: + """ + Return specifier and input value for use in public key specification. + + Args: + val: value of public key + + Returns: dict mapping applicable specifier to input value + + """ + + return {self.specifier: val} + + +class PublicKey: + """ + Public key specification to embed in DID document. + + Retains DIDs as raw values (orientated toward indy-facing operations), + everything else as URIs (oriented toward W3C-facing operations). + """ + + def __init__( + self, + did: str, + ident: str, + value: str, + pk_type: PublicKeyType = None, + controller: str = None, + authn: bool = False, + ) -> None: + """ + Retain key specification particulars. + + Args: + did: DID of DID document embedding public key + ident: identifier for public key + value: key content, encoded as key specification requires + pk_type: public key type (enum), default ED25519_SIG_2018 + controller: controller DID (default DID of DID document) + authn: whether key as has DID authentication privilege (default False) + + Raises: + ValueError: on any bad input DID. + + """ + + self._did = canon_did(did) + self._id = canon_ref(self._did, ident) + self._value = value + self._type = pk_type or PublicKeyType.ED25519_SIG_2018 + self._controller = canon_did(controller) if controller else self._did + self._authn = authn + + @property + def did(self) -> str: + """Accessor for the DID.""" + + return self._did + + @property + def id(self) -> str: + """Accessor for the public key identifier.""" + + return self._id + + @property + def type(self) -> PublicKeyType: + """Accessor for the public key type.""" + + return self._type + + @property + def value(self) -> str: + """Accessor for the public key value.""" + + return self._value + + @property + def controller(self) -> str: + """Accessor for the controller DID.""" + + return self._controller + + @property + def authn(self) -> bool: + """Accessor for the authentication marker. + + Returns: whether public key is marked as having DID authentication privilege + """ + + return self._authn + + @authn.setter + def authn(self, value: bool) -> None: + """Setter for the authentication marker. + + Args: + value: authentication marker + """ + + self._authn = value + + def to_dict(self) -> dict: + """Return dict representation of public key to embed in DID document.""" + + return { + "id": self.id, + "type": str(self.type.ver_type), + "controller": canon_ref(self.did, self.controller), + **self.type.specification(self.value), + } + + def __repr__(self) -> str: + """Return string representation of the public key instance.""" + + return "PublicKey({}, {}, {}, {}, {}, {})".format( + self.did, self.id, self.value, self.type, self.controller, self.authn + ) diff --git a/aries_cloudagent/messaging/connections/models/diddoc/service.py b/aries_cloudagent/messaging/connections/models/diddoc/service.py new file mode 100644 index 0000000000..07dc981758 --- /dev/null +++ b/aries_cloudagent/messaging/connections/models/diddoc/service.py @@ -0,0 +1,137 @@ +""" +DID Document Service classes. + +Copyright 2017-2019 Government of Canada +Public Services and Procurement Canada - buyandsell.gc.ca + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +from typing import List, Sequence, Union + +from .util import canon_did, canon_ref +from .publickey import PublicKey + + +class Service: + """ + Service specification to embed in DID document. + + Retains DIDs as raw values (orientated toward indy-facing operations), + everything else as URIs (oriented toward W3C-facing operations). + """ + + def __init__( + self, + did: str, + ident: str, + typ: str, + recip_keys: Union[Sequence, PublicKey], + routing_keys: Union[Sequence, PublicKey], + endpoint: str, + priority: int = 0, + ): + """ + Initialize the Service instance. + + Retain service specification particulars. + + Args: + did: DID of DID document embedding service, specified raw + (operation converts to URI) + ident: identifier for service + typ: service type + recip_keys: recipient key or keys + routing_keys: routing key or keys + endpoint: service endpoint + priority: service priority + + Raises: + ValueError: on bad input controller DID + + """ + + self._did = canon_did(did) + self._id = canon_ref(self._did, ident, ";") + self._type = typ + self._recip_keys = ( + [recip_keys] + if isinstance(recip_keys, PublicKey) + else list(recip_keys) + if recip_keys + else None + ) + self._routing_keys = ( + [routing_keys] + if isinstance(routing_keys, PublicKey) + else list(routing_keys) + if routing_keys + else None + ) + self._endpoint = endpoint + self._priority = priority + + @property + def did(self) -> str: + """Accessor for the DID value.""" + + return self._did + + @property + def id(self) -> str: + """Accessor for the service identifier.""" + + return self._id + + @property + def type(self) -> str: + """Accessor for the service type.""" + + return self._type + + @property + def recip_keys(self) -> List[PublicKey]: + """Accessor for the recipient keys.""" + + return self._recip_keys + + @property + def routing_keys(self) -> List[PublicKey]: + """Accessor for the routing keys.""" + + return self._routing_keys + + @property + def endpoint(self) -> str: + """Accessor for the endpoint value.""" + + return self._endpoint + + @property + def priority(self) -> int: + """Accessor for the priority value.""" + + return self._priority + + def to_dict(self) -> dict: + """Return dict representation of service to embed in DID document.""" + + rv = {"id": self.id, "type": self.type, "priority": self.priority} + if self.recip_keys: + rv["recipientKeys"] = [k.value for k in self.recip_keys] + if self.routing_keys: + rv["routingKeys"] = [k.value for k in self.routing_keys] + rv["serviceEndpoint"] = self.endpoint + + return rv diff --git a/aries_cloudagent/messaging/connections/models/diddoc/util.py b/aries_cloudagent/messaging/connections/models/diddoc/util.py new file mode 100644 index 0000000000..5c860bd6b1 --- /dev/null +++ b/aries_cloudagent/messaging/connections/models/diddoc/util.py @@ -0,0 +1,114 @@ +""" +DIDDoc utility methods. + +Copyright 2017-2019 Government of Canada +Public Services and Procurement Canada - buyandsell.gc.ca + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +from base58 import b58decode +from urllib.parse import urlparse + + +def resource(ref: str, delimiter: str = None) -> str: + """ + Extract the resource for an identifier. + + Given a (URI) reference, return up to its delimiter (exclusively), or all of it if + there is none. + + Args: + ref: reference + delimiter: delimiter character + (default None maps to '#', or ';' introduces identifiers) + """ + + return ref.split(delimiter if delimiter else "#")[0] + + +def canon_did(uri: str) -> str: + """ + Convert a URI into a DID if need be, left-stripping 'did:sov:' if present. + + Args: + uri: input URI or DID + + Raises: + ValueError: for invalid input. + + """ + + if ok_did(uri): + return uri + + if uri.startswith("did:sov:"): + rv = uri[8:] + if ok_did(rv): + return rv + raise ValueError( + "Bad specification {} does not correspond to a sovrin DID".format(uri) + ) + + +def canon_ref(did: str, ref: str, delimiter: str = None): + """ + Given a reference in a DID document, return it in its canonical form of a URI. + + Args: + did: DID acting as the identifier of the DID document + ref: reference to canonicalize, either a DID or a fragment pointing to a + location in the DID doc + delimiter: delimiter character marking fragment (default '#') or + introducing identifier (';') against DID resource + """ + + if not ok_did(did): + raise ValueError("Bad DID {} cannot act as DID document identifier".format(did)) + + if ok_did(ref): # e.g., LjgpST2rjsoxYegQDRm7EL + return "did:sov:{}".format(did) + + if ok_did(resource(ref, delimiter)): # e.g., LjgpST2rjsoxYegQDRm7EL#keys-1 + return "did:sov:{}".format(ref) + + if ref.startswith( + "did:sov:" + ): # e.g., did:sov:LjgpST2rjsoxYegQDRm7EL, did:sov:LjgpST2rjsoxYegQDRm7EL#3 + rv = ref[8:] + if ok_did(resource(rv, delimiter)): + return ref + raise ValueError("Bad URI {} does not correspond to a sovrin DID".format(ref)) + + if urlparse(ref).scheme: # e.g., https://example.com/messages/8377464 + return ref + + return "did:sov:{}{}{}".format(did, delimiter if delimiter else "#", ref) # e.g., 3 + + +def ok_did(token: str) -> bool: + """ + Whether input token looks like a valid distributed identifier. + + Args: + token: candidate string + + Returns: whether input token looks like a valid schema identifier + + """ + + try: + return len(b58decode(token)) == 16 if token else False + except ValueError: + return False diff --git a/docs/conf.py b/docs/conf.py index f90eed5f82..9097b5ffa7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,7 @@ import sys sys.path.insert(0, os.path.abspath('..')) -autodoc_mock_imports = ["von_anchor", "setup", "pysodium", "indy", "aiohttp_cors"] +autodoc_mock_imports = ["setup", "pysodium", "indy", "aiohttp_cors"] # -- Project information ----------------------------------------------------- diff --git a/requirements.txt b/requirements.txt index c7172438a2..3bbd2a3b95 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,3 @@ base58 marshmallow==3.0.0rc3 msgpack>=0.6.1<0.7 pysodium>=0.7.1<0.8 -von_anchor==1.9.2 From 85369af192c10524d2677fe90b1652bb85ada26c Mon Sep 17 00:00:00 2001 From: Andrew Whitehead Date: Fri, 28 Jun 2019 11:48:30 -0700 Subject: [PATCH 2/3] add DIDDoc unit tests Signed-off-by: Andrew Whitehead --- .../connections/tests/test_diddoc.py | 485 ++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 aries_cloudagent/messaging/connections/tests/test_diddoc.py diff --git a/aries_cloudagent/messaging/connections/tests/test_diddoc.py b/aries_cloudagent/messaging/connections/tests/test_diddoc.py new file mode 100644 index 0000000000..1f1c196063 --- /dev/null +++ b/aries_cloudagent/messaging/connections/tests/test_diddoc.py @@ -0,0 +1,485 @@ +""" +Copyright 2017-2019 Government of Canada +Public Services and Procurement Canada - buyandsell.gc.ca + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +import json + +from asynctest import TestCase as AsyncTestCase, mock as async_mock + +from ..models.diddoc import DIDDoc, PublicKey, PublicKeyType, Service +from ..models.diddoc.util import canon_ref, canon_did + + +class TestDIDDoc(AsyncTestCase): + async def test_basic(self): + + # One authn key by reference + dd_in = { + '@context': 'https://w3id.org/did/v1', + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKey': [ + { + 'id': '3', + 'type': 'RsaVerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyPem': '-----BEGIN PUBLIC X...' + }, + { + 'id': '4', + 'type': 'RsaVerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyPem': '-----BEGIN PUBLIC 9...' + }, + { + 'id': '6', + 'type': 'RsaVerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyPem': '-----BEGIN PUBLIC A...' + } + ], + 'authentication': [ + { + 'type': 'RsaSignatureAuthentication2018', + 'publicKey': 'did:sov:LjgpST2rjsoxYegQDRm7EL#4' + } + ], + 'service': [ + { + 'id': '0', + 'type': 'Agency', + 'serviceEndpoint': 'did:sov:Q4zqM7aXqm7gDQkUVLng9h' + } + ] + } + + dd = DIDDoc.deserialize(dd_in) + assert len(dd.pubkey) == len(dd_in['publicKey']) + assert len(dd.authnkey) == len(dd_in['authentication']) + + dd_out = dd.serialize() + #print('\n\n== 1 == DID Doc {} on abbreviated identifiers: {}'.format(dd, ppjson(dd_out))) + + # Exercise JSON, de/serialization + dd_json = dd.to_json() + dd_copy = dd.from_json(dd_json) + assert dd_copy.did == dd.did + assert all(dd_copy.authnkey[k].to_dict() == dd.authnkey[k].to_dict() for k in dd_copy.authnkey) + assert {k for k in dd_copy.authnkey} == {k for k in dd.authnkey} + assert all(dd_copy.pubkey[k].to_dict() == dd.pubkey[k].to_dict() for k in dd_copy.pubkey) + assert {k for k in dd_copy.pubkey} == {k for k in dd.pubkey} + assert all(dd_copy.service[k].to_dict() == dd.service[k].to_dict() for k in dd_copy.service) + assert {k for k in dd_copy.service} == {k for k in dd.service} + # print('\n\n== 2 == DID Doc de/serialization operates OK:') + + # Exercise accessors + dd.did = dd_out['id'] + assert dd.did == canon_did(dd_out['id']) + with self.assertRaises(ValueError): + dd.set(['neither a service', 'nor a public key']) + assert dd.service[[k for k in dd.service][0]].did == dd.did + #print('\n\n== 3 == DID Doc accessors operate OK') + + def test_embedded_authkey(self): + # One authn key embedded, all possible refs canonical + dd_in = { + '@context': 'https://w3id.org/did/v1', + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKey': [ + { + 'id': '3', + 'type': 'RsaVerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyPem': '-----BEGIN PUBLIC X...' + }, + { + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL#4', + 'type': 'RsaVerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyPem': '-----BEGIN PUBLIC 9...' + } + ], + 'authentication': [ + { + 'type': 'RsaSignatureAuthentication2018', + 'publicKey': 'did:sov:LjgpST2rjsoxYegQDRm7EL#4' + }, + { + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL#6', + 'type': 'RsaVerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyPem': '-----BEGIN PUBLIC A...' + } + ], + 'service': [ + { + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL;0', + 'type': 'Agency', + 'serviceEndpoint': 'https://www.von.ca' + } + ] + } + + dd = DIDDoc.deserialize(dd_in) + assert len(dd.pubkey) == len(dd_in['publicKey']) + 1 + assert len(dd.authnkey) == len(dd_in['authentication']) + + dd_out = dd.serialize() + #print('\n\n== 4 == DID Doc on mixed reference styles, embedded and ref style authn keys: {}'.format(ppjson(dd_out))) + + def test_reference_authkey(self): + # All references canonical where possible; one authn key embedded and one by reference + dd_in = { + '@context': 'https://w3id.org/did/v1', + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKey': [ + { + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL#3', + 'type': 'RsaVerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyPem': '-----BEGIN PUBLIC X...' + }, + { + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL#4', + 'type': 'RsaVerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyPem': '-----BEGIN PUBLIC 9...' + } + ], + 'authentication': [ + { + 'type': 'RsaSignatureAuthentication2018', + 'publicKey': 'did:sov:LjgpST2rjsoxYegQDRm7EL#4' + }, + { + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL#6', + 'type': 'RsaVerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyPem': '-----BEGIN PUBLIC A...' + } + ], + 'service': [ + { + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL;0', + 'type': 'DidMessaging', + 'serviceEndpoint': 'https://www.von.ca' + } + ] + } + + dd = DIDDoc.deserialize(dd_in) + assert len(dd.pubkey) == len(dd_in['publicKey']) + 1 + assert len(dd.authnkey) == len(dd_in['authentication']) + + dd_out = dd.serialize() + #print('\n\n== 5 == DID Doc on canonical refs: {}'.format(ppjson(dd_out))) + + def test_minimal(self): + # Minimal as per indy-agent test suite without explicit identifiers + dd_in = { + '@context': 'https://w3id.org/did/v1', + 'publicKey': [ + { + 'id': 'LjgpST2rjsoxYegQDRm7EL#keys-1', + 'type': 'Ed25519VerificationKey2018', + 'controller': 'LjgpST2rjsoxYegQDRm7EL', + 'publicKeyBase58': '~XXXXXXXXXXXXXXXX' + } + ], + 'service': [ + { + 'type': 'DidMessaging', + 'recipientKeys': ['~XXXXXXXXXXXXXXXX'], + 'serviceEndpoint': 'https://www.von.ca' + } + ] + } + + dd = DIDDoc.deserialize(dd_in) + assert len(dd.pubkey) == len(dd_in['publicKey']) + assert len(dd.authnkey) == 0 + + dd_out = dd.serialize() + #print('\n\n== 6 == DID Doc miminal style, implcit DID document identifier: {}'.format( + # ppjson(dd_out))) + + def test_minimal_ids(self): + # Minimal + ids as per indy-agent test suite with explicit identifiers; novel service recipient key on raw base58 + dd_in = { + '@context': 'https://w3id.org/did/v1', + 'id': 'LjgpST2rjsoxYegQDRm7EL', + 'publicKey': [ + { + 'id': 'LjgpST2rjsoxYegQDRm7EL#keys-1', + 'type': 'Ed25519VerificationKey2018', + 'controller': 'LjgpST2rjsoxYegQDRm7EL', + 'publicKeyBase58': '~XXXXXXXXXXXXXXXX' + } + ], + 'service': [ + { + 'id': 'LjgpST2rjsoxYegQDRm7EL;indy', + 'type': 'DidMessaging', + 'priority': 1, + 'recipientKeys': ['~YYYYYYYYYYYYYYYY'], + 'serviceEndpoint': 'https://www.von.ca' + } + ] + } + + dd = DIDDoc.deserialize(dd_in) + assert len(dd.pubkey) == 1 + len(dd_in['publicKey']) + assert len(dd.authnkey) == 0 + + dd_out = dd.serialize() + #print('\n\n== 7 == DID Doc miminal style plus explicit idents and novel raw base58 service recip key: {}'.format( + # ppjson(dd_out))) + + def test_minimal_explicit(self): + # Minimal + ids as per indy-agent test suite with explicit identifiers; novel service recipient key on raw base58 + dd_in = { + '@context': 'https://w3id.org/did/v1', + 'id': 'LjgpST2rjsoxYegQDRm7EL', + 'publicKey': [ + { + 'id': 'LjgpST2rjsoxYegQDRm7EL#keys-1', + 'type': 'Ed25519VerificationKey2018', + 'controller': 'LjgpST2rjsoxYegQDRm7EL', + 'publicKeyBase58': '~XXXXXXXXXXXXXXXX' + }, + { + 'id': 'LjgpST2rjsoxYegQDRm7EL#keys-2', + 'type': 'Ed25519VerificationKey2018', + 'controller': 'LjgpST2rjsoxYegQDRm7EL', + 'publicKeyBase58': '~YYYYYYYYYYYYYYYY' + }, + { + 'id': 'LjgpST2rjsoxYegQDRm7EL#keys-3', + 'type': 'Secp256k1VerificationKey2018', + 'controller': 'LjgpST2rjsoxYegQDRm7EL', + 'publicKeyHex': '02b97c30de767f084ce3080168ee293053ba33b235d7116a3263d29f1450936b71' + }, + { + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL#keys-4', + 'type': 'RsaVerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyPem': '-----BEGIN PUBLIC A...' + } + ], + 'service': [ + { + 'id': 'LjgpST2rjsoxYegQDRm7EL;indy', + 'type': 'DidMessaging', + 'priority': 0, + 'recipientKeys': ['~ZZZZZZZZZZZZZZZZ'], + 'serviceEndpoint': 'did:sov:LjgpST2rjsoxYegQDRm7EL;1' + }, + { + 'id': '1', + 'type': 'one', + 'priority': 1, + 'recipientKeys': [ + '~XXXXXXXXXXXXXXXX', + 'did:sov:LjgpST2rjsoxYegQDRm7EL#keys-1' + ], + 'routingKeys': [ + 'did:sov:LjgpST2rjsoxYegQDRm7EL#keys-4' + ], + 'serviceEndpoint': 'LjgpST2rjsoxYegQDRm7EL;2' + }, + { + 'id': '2', + 'type': 'two', + 'priority': 2, + 'recipientKeys': [ + '~XXXXXXXXXXXXXXXX', + 'did:sov:LjgpST2rjsoxYegQDRm7EL#keys-1' + ], + 'routingKeys': [ + 'did:sov:LjgpST2rjsoxYegQDRm7EL#keys-4' + ], + 'serviceEndpoint': 'https://www.two.ca/two' + } + ] + } + + dd = DIDDoc.deserialize(dd_in) + assert len(dd.pubkey) == 1 + len(dd_in['publicKey']) + assert len(dd.authnkey) == 0 + assert {s.priority for s in dd.service.values()} == {0, 1, 2} + assert len(dd.service) == 3 + assert all(len(dd.service[k].to_dict()['recipientKeys']) == 1 for k in dd.service) + assert 'routingKeys' not in dd.service['did:sov:LjgpST2rjsoxYegQDRm7EL;indy'].to_dict() + assert all(len(dd.service[k].to_dict()['routingKeys']) == 1 + for k in ('did:sov:LjgpST2rjsoxYegQDRm7EL;1', 'did:sov:LjgpST2rjsoxYegQDRm7EL;2')) + + dd_out = dd.serialize() + #print('\n\n== 8 == DID Doc on mixed service routing and recipient keys: {}'.format(ppjson(dd_out))) + + pk = PublicKey( + dd.did, + '99', + '~AAAAAAAAAAAAAAAA', + PublicKeyType.ED25519_SIG_2018, + dd.did, + True) + dd.set(pk) + assert len(dd.pubkey) == 2 + len(dd_in['publicKey']) + assert canon_ref(dd.did, '99', '#') in dd.pubkey + assert len(dd.authnkey) == 1 + + service = Service( + dd.did, + 'abc', + 'IndyAgent', + [pk], + [pk], + 'http://www.abc.ca/123' + ) + dd.set(service) + assert len(dd.service) == 4 + assert canon_ref(dd.did, 'abc', ';') in dd.service + #print('\n\n== 9 == DID Doc adds public key and service via set() OK') + + def test_missing_recipkey(self): + # Exercise missing service recipient key + dd_in = { + '@context': 'https://w3id.org/did/v1', + 'id': 'LjgpST2rjsoxYegQDRm7EL', + 'publicKey': [ + { + 'id': 'LjgpST2rjsoxYegQDRm7EL#keys-1', + 'type': 'Ed25519VerificationKey2018', + 'controller': 'LjgpST2rjsoxYegQDRm7EL', + 'publicKeyBase58': '~XXXXXXXXXXXXXXXX' + } + ], + 'service': [ + { + 'id': 'LjgpST2rjsoxYegQDRm7EL;indy', + 'type': 'DidMessaging', + 'priority': 1, + 'recipientKeys': [ + 'did:sov:LjgpST2rjsoxYegQDRm7EL#keys-3' + ], + 'serviceEndpoint': 'https://www.von.ca' + } + ] + } + + with self.assertRaises(ValueError): + dd = DIDDoc.deserialize(dd_in) + #print('\n\n== 10 == DID Doc on underspecified service key fails as expected') + + def test_w3c_minimal(self): + # Minimal as per W3C Example 2, draft 0.12 + dd_in = { + '@context': 'https://w3id.org/did/v1', + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'authentication': [ + { + 'id': 'LjgpST2rjsoxYegQDRm7EL#keys-1', + 'type': 'Ed25519VerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyBase58': '~XXXXXXXXXXXXXXXX' + } + ], + 'service': [ + { + 'type': 'DidMessaging', + 'serviceEndpoint': 'https://example.com/endpoint/8377464' + } + ] + } + + dd = DIDDoc.deserialize(dd_in) + assert len(dd.pubkey) == 1 + assert len(dd.authnkey) == 1 + assert len(dd.service) == 1 + + dd_out = dd.serialize() + #print('\n\n== 11 == Minimal DID Doc (no pubkey except authentication) as per W3C spec parses OK: {}'.format( + # ppjson(dd_out))) + + def test_no_ident(self): + # Exercise no-identifier case + dd_in = { + '@context': 'https://w3id.org/did/v1', + 'authentication': [ + { + 'type': 'Ed25519VerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyBase58': '~XXXXXXXXXXXXXXXX' + } + ], + 'service': [ + { + 'type': 'DidMessaging', + 'serviceEndpoint': 'https://example.com/endpoint/8377464' + } + ] + } + + with self.assertRaises(ValueError): + dd = DIDDoc.deserialize(dd_in) + #print('\n\n== 12 == DID Doc without identifier rejected as expected') + + def test_canon_did(self): + # Exercise reference canonicalization, including failure paths + valid_did = 'LjgpST2rjsoxYegQDRm7EL' + + with self.assertRaises(ValueError): + canon_ref('not-a-DID', ref=valid_did, delimiter='#') + + with self.assertRaises(ValueError): + canon_ref(valid_did, ref='did:sov:not-a-DID', delimiter='#') + + urlref = 'https://www.clafouti-quasar.ca:8443/supply-management/fruit/index.html' + assert canon_ref(valid_did, ref=urlref) == urlref + #print('\n\n== 13 == Reference canonicalization operates as expected') + + def test_pubkey_type(self): + dd_in = { + '@context': 'https://w3id.org/did/v1', + 'id': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'authentication': [ + { + 'id': 'LjgpST2rjsoxYegQDRm7EL#keys-1', + 'type': 'Ed25519VerificationKey2018', + 'controller': 'did:sov:LjgpST2rjsoxYegQDRm7EL', + 'publicKeyBase58': '~XXXXXXXXXXXXXXXX' + } + ], + 'service': [ + { + 'type': 'DidMessaging', + 'serviceEndpoint': 'https://example.com/endpoint/8377464' + } + ] + } + + dd = DIDDoc.deserialize(dd_in) + + assert PublicKeyType.get('no-such-type') is None + pubkey0 = dd.pubkey[[k for k in dd.pubkey][0]] + was_authn = pubkey0.authn + pubkey0.authn = not was_authn + assert pubkey0.authn != was_authn + #print('\n\n== 14 == Changed authentication setting for DIDDoc {} in public key {}, now {}'.format( + # pubkey0.did, + # pubkey0.id, + # repr(pubkey0))) + From 2bcac8523883ae9e9e3a293a764728f8e946346b Mon Sep 17 00:00:00 2001 From: Andrew Whitehead Date: Fri, 28 Jun 2019 12:04:49 -0700 Subject: [PATCH 3/3] skip indy-specific tests when library is not available Signed-off-by: Andrew Whitehead --- aries_cloudagent/holder/tests/test_indy.py | 18 +++++++++++++- aries_cloudagent/ledger/tests/test_indy.py | 20 +++++++++++++-- aries_cloudagent/verifier/tests/test_indy.py | 26 +++++++++++++++++++- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/aries_cloudagent/holder/tests/test_indy.py b/aries_cloudagent/holder/tests/test_indy.py index 18747277a2..f390e2fdda 100644 --- a/aries_cloudagent/holder/tests/test_indy.py +++ b/aries_cloudagent/holder/tests/test_indy.py @@ -1,9 +1,25 @@ import json -from unittest import mock from asynctest import TestCase as AsyncTestCase from asynctest import mock as async_mock +import pytest + +try: + from indy.libindy import _cdll + + _cdll() +except ImportError: + pytest.skip( + "skipping Indy-specific tests: python module not installed", + allow_module_level=True, + ) +except OSError: + pytest.skip( + "skipping Indy-specific tests: shared library not loaded", + allow_module_level=True, + ) + from aries_cloudagent.holder.indy import IndyHolder diff --git a/aries_cloudagent/ledger/tests/test_indy.py b/aries_cloudagent/ledger/tests/test_indy.py index f210701e11..f1395257b8 100644 --- a/aries_cloudagent/ledger/tests/test_indy.py +++ b/aries_cloudagent/ledger/tests/test_indy.py @@ -1,16 +1,32 @@ import asyncio import json -from unittest import mock from asynctest import TestCase as AsyncTestCase from asynctest import mock as async_mock +import pytest + +try: + from indy.libindy import _cdll + + _cdll() +except ImportError: + pytest.skip( + "skipping Indy-specific tests: python module not installed", + allow_module_level=True, + ) +except OSError: + pytest.skip( + "skipping Indy-specific tests: shared library not loaded", + allow_module_level=True, + ) + from aries_cloudagent.ledger.indy import ( IndyLedger, GENESIS_TRANSACTION_PATH, ClosedPoolError, LedgerTransactionError, - DuplicateSchemaError + DuplicateSchemaError, ) diff --git a/aries_cloudagent/verifier/tests/test_indy.py b/aries_cloudagent/verifier/tests/test_indy.py index 07c69095f3..7037319a74 100644 --- a/aries_cloudagent/verifier/tests/test_indy.py +++ b/aries_cloudagent/verifier/tests/test_indy.py @@ -1,9 +1,33 @@ import json -from unittest import mock from asynctest import TestCase as AsyncTestCase from asynctest import mock as async_mock +import pytest + +try: + from indy.libindy import _cdll + + _cdll() +except ImportError: + pytest.skip( + "skipping Indy-specific tests: python module not installed", + allow_module_level=True, + ) +except OSError: + pytest.skip( + "skipping Indy-specific tests: shared library not loaded", + allow_module_level=True, + ) + +from aries_cloudagent.ledger.indy import ( + IndyLedger, + GENESIS_TRANSACTION_PATH, + ClosedPoolError, + LedgerTransactionError, + DuplicateSchemaError, +) + from aries_cloudagent.verifier.indy import IndyVerifier