From 9b1e98db1f70618565b2b460ea7a4ad2bf19a095 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Thu, 25 Feb 2021 16:42:59 +0100 Subject: [PATCH 001/138] add indy cred format class Signed-off-by: Timo Glastra --- .../issue_credential/v2_0/formats/__init__.py | 0 .../issue_credential/v2_0/formats/indy.py | 102 ++++++++++++++++++ .../issue_credential/v2_0/manager.py | 93 ++++------------ .../v2_0/messages/cred_format.py | 8 +- 4 files changed, 130 insertions(+), 73 deletions(-) create mode 100644 aries_cloudagent/protocols/issue_credential/v2_0/formats/__init__.py create mode 100644 aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/__init__.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py new file mode 100644 index 0000000000..6d983b19e7 --- /dev/null +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py @@ -0,0 +1,102 @@ +"""V2.0 indy issue-credential cred format.""" + +import uuid +import json +from typing import Coroutine, Mapping + +from ..manager import V20CredManagerError +from .....core.profile import Profile +from .....messaging.credential_definitions.util import CRED_DEF_SENT_RECORD_TYPE +from .....messaging.decorators.attach_decorator import AttachDecorator +from .....storage.base import BaseStorage +from .....indy.issuer import IndyIssuer +from .....cache.base import BaseCache +from .....ledger.base import BaseLedger +from ..messages.cred_format import V20CredFormat + + +class IndyCredFormat: + @property + def profile(self) -> Profile: + """ + Accessor for the current profile instance. + + Returns: + The profile instance for this credential manager + + """ + return self._profile + + async def _match_sent_cred_def_id(self, tag_query: Mapping[str, str]) -> str: + """Return most recent matching id of cred def that agent sent to ledger.""" + + async with self._profile.session() as session: + storage = session.inject(BaseStorage) + found = await storage.find_all_records( + type_filter=CRED_DEF_SENT_RECORD_TYPE, tag_query=tag_query + ) + if not found: + raise V20CredManagerError( + f"Issuer has no operable cred def for proposal spec {tag_query}" + ) + return max(found, key=lambda r: int(r.tags["epoch"])).tags["cred_def_id"] + + async def create_proposal( + self, filter: Mapping[str, str] + ) -> Coroutine[V20CredFormat, AttachDecorator]: + id = uuid() + + return ( + V20CredFormat(attach_id=id, format_=V20CredFormat.Format.INDY), + AttachDecorator.data_base64(filter, ident=id), + ) + + async def create_offer( + self, cred_proposal_message + ) -> Coroutine[V20CredFormat, AttachDecorator]: + issuer = self.profile.inject(IndyIssuer) + ledger = self.profile.inject(BaseLedger) + cache = self.profile.inject(BaseCache, required=False) + + cred_def_id = await self._match_sent_cred_def_id( + V20CredFormat.Format.INDY.get_attachment_data( + cred_proposal_message.formats, + cred_proposal_message.filters_attach, + ) + ) + + async def _create(): + offer_json = await issuer.create_credential_offer(cred_def_id) + return json.loads(offer_json) + + async with ledger: + schema_id = await ledger.credential_definition_id2schema_id(cred_def_id) + schema = await ledger.get_schema(schema_id) + schema_attrs = {attr for attr in schema["attrNames"]} + preview_attrs = { + attr for attr in cred_proposal_message.cred_preview.attr_dict() + } + if preview_attrs != schema_attrs: + raise V20CredManagerError( + f"Preview attributes {preview_attrs} " + f"mismatch corresponding schema attributes {schema_attrs}" + ) + + cred_offer = None + cache_key = f"credential_offer::{cred_def_id}" + + if cache: + async with cache.acquire(cache_key) as entry: + if entry.result: + cred_offer = entry.result + else: + cred_offer = await _create(cred_def_id) + await entry.set_result(cred_offer, 3600) + if not cred_offer: + cred_offer = await _create(cred_def_id) + + id = uuid() + return ( + V20CredFormat(attach_id=id, format_=V20CredFormat.Format.INDY), + AttachDecorator.data_base64(cred_offer, ident=id), + ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py index 4f8f6b3f96..c78c90bcf9 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py @@ -12,12 +12,10 @@ from ....indy.holder import IndyHolder, IndyHolderError from ....indy.issuer import IndyIssuer, IndyIssuerRevocationRegistryFullError from ....ledger.base import BaseLedger -from ....messaging.credential_definitions.util import CRED_DEF_SENT_RECORD_TYPE from ....messaging.decorators.attach_decorator import AttachDecorator from ....revocation.indy import IndyRevocation from ....revocation.models.revocation_registry import RevocationRegistry from ....revocation.models.issuer_rev_reg_record import IssuerRevRegRecord -from ....storage.base import BaseStorage from ....storage.error import StorageNotFoundError from .messages.cred_ack import V20CredAck @@ -61,20 +59,6 @@ def profile(self) -> Profile: """ return self._profile - async def _match_sent_cred_def_id(self, tag_query: Mapping[str, str]) -> str: - """Return most recent matching id of cred def that agent sent to ledger.""" - - async with self._profile.session() as session: - storage = session.inject(BaseStorage) - found = await storage.find_all_records( - type_filter=CRED_DEF_SENT_RECORD_TYPE, tag_query=tag_query - ) - if not found: - raise V20CredManagerError( - f"Issuer has no operable cred def for proposal spec {tag_query}" - ) - return max(found, key=lambda r: int(r.tags["epoch"])).tags["cred_def_id"] - async def get_detail_record( self, cred_ex_id: str, @@ -150,17 +134,17 @@ async def create_proposal( """ + # Format specific create_proposal handler + formats = [ + await fmt.handler(self.profile).create_proposal(filter) + for (fmt, filter) in fmt2filter.items() + ] + cred_proposal_message = V20CredProposal( comment=comment, credential_preview=cred_preview, - formats=[ - V20CredFormat(attach_id=str(ident), format_=V20CredFormat.Format.get(f)) - for ident, f in enumerate(fmt2filter.keys()) - ], - filters_attach=[ - AttachDecorator.data_base64(f or {}, ident=str(ident)) - for ident, f in enumerate(fmt2filter.values()) - ], + formats=[format for (format, _) in formats], + filters_attach=[attach for (_, attach) in formats], ) cred_proposal_message.assign_trace_decorator(self._profile.settings, trace) @@ -239,11 +223,6 @@ async def create_offer( """ - async def _create(cred_def_id): # may change for DIF - issuer = self._profile.inject(IndyIssuer) - offer_json = await issuer.create_credential_offer(cred_def_id) - return json.loads(offer_json) - cred_proposal_message = V20CredProposal.deserialize( cred_ex_record.cred_proposal ) @@ -251,50 +230,20 @@ async def _create(cred_def_id): # may change for DIF self._profile.settings, cred_ex_record.trace ) - assert V20CredFormat.Format.INDY in [ - V20CredFormat.Format.get(p.format) for p in cred_proposal_message.formats - ] # until DIF support - - cred_def_id = await self._match_sent_cred_def_id( - V20CredFormat.Format.INDY.get_attachment_data( - cred_proposal_message.formats, - cred_proposal_message.filters_attach, - ) - ) - cred_preview = cred_proposal_message.credential_preview - - # vet attributes - ledger = self._profile.inject(BaseLedger) # may change for DIF - async with ledger: - schema_id = await ledger.credential_definition_id2schema_id(cred_def_id) - schema = await ledger.get_schema(schema_id) - schema_attrs = {attr for attr in schema["attrNames"]} - preview_attrs = {attr for attr in cred_preview.attr_dict()} - if preview_attrs != schema_attrs: - raise V20CredManagerError( - f"Preview attributes {preview_attrs} " - f"mismatch corresponding schema attributes {schema_attrs}" - ) - - cred_offer = None - cache_key = f"credential_offer::{cred_def_id}" # may change for DIF - cache = self._profile.inject(BaseCache, required=False) - if cache: - async with cache.acquire(cache_key) as entry: - if entry.result: - cred_offer = entry.result - else: - cred_offer = await _create(cred_def_id) - await entry.set_result(cred_offer, 3600) - if not cred_offer: - cred_offer = await _create(cred_def_id) + # Format specific create_offer handler + formats = [ + await V20CredFormat.Format.get(p.format) + .handler(self.profile) + .create_offer(cred_proposal_message) + for p in cred_proposal_message.formats + ] cred_offer_message = V20CredOffer( replacement_id=replacement_id, comment=comment, - credential_preview=cred_preview, - formats=[V20CredFormat(attach_id="0", format_=V20CredFormat.Format.INDY)], - offers_attach=[AttachDecorator.data_base64(cred_offer, ident="0")], + credential_preview=cred_proposal_message.credential_preview, + formats=[format for (format, _) in formats], + offers_attach=[attach for (_, attach) in formats], ) cred_offer_message._thread = {"thid": cred_ex_record.thread_id} @@ -327,9 +276,9 @@ async def receive_offer( The credential exchange record, updated """ - assert V20CredFormat.Format.INDY in [ - V20CredFormat.Format.get(p.format) for p in cred_offer_message.formats - ] # until DIF support + + # TODO: assert for all methods that we support at least one format + # TODO: assert we don't suddenly change from format during the interaction offer = cred_offer_message.attachment( V20CredFormat.Format.INDY diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py index bad3ee714f..0e120f54c1 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py @@ -13,11 +13,12 @@ from .....messaging.models.base import BaseModel, BaseModelSchema from .....messaging.valid import UUIDFour +from ..formats.indy import IndyCredFormat from ..models.detail.dif import V20CredExRecordDIF from ..models.detail.indy import V20CredExRecordIndy # Aries RFC value, further monikers, cred ex detail record class -FormatSpec = namedtuple("FormatSpec", "aries aka detail") +FormatSpec = namedtuple("FormatSpec", "aries aka detail handler") class V20CredFormat(BaseModel): @@ -35,6 +36,7 @@ class Format(Enum): "hlindy-zkp-v1.0", {"indy", "hyperledgerindy", "hlindy"}, V20CredExRecordIndy, + IndyCredFormat, ) DIF = FormatSpec( "dif/credential-manifest@v1.0", @@ -77,6 +79,10 @@ def detail(self) -> str: """Accessor for credential exchange detail class.""" return self.value.detail + def handler(self) -> IndyCredFormat: + """Accessor for credential exchange format handler.""" + return self.value.handler + def validate_filter(self, data: Mapping): """Raise ValidationError for wrong filtration criteria.""" if self is V20CredFormat.Format.INDY: From de123d987143230bc3d7fea47379b8e619b414b1 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Wed, 3 Mar 2021 11:07:39 +0100 Subject: [PATCH 002/138] abstract credential format handler, move indy Signed-off-by: Timo Glastra --- .../issue_credential/v2_0/formats/handler.py | 98 +++++ .../issue_credential/v2_0/formats/indy.py | 367 ++++++++++++++++-- .../issue_credential/v2_0/manager.py | 335 ++-------------- .../v2_0/messages/cred_format.py | 38 +- .../protocols/issue_credential/v2_0/routes.py | 49 +-- 5 files changed, 515 insertions(+), 372 deletions(-) create mode 100644 aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py new file mode 100644 index 0000000000..e12f7b9d7a --- /dev/null +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py @@ -0,0 +1,98 @@ +"""V2.0 indy issue-credential cred format.""" + +from abc import ABC, abstractclassmethod, abstractmethod +import logging + +from typing import Mapping, Tuple, Union + +from .....core.error import BaseError +from .....core.profile import Profile +from .....messaging.decorators.attach_decorator import AttachDecorator +from .....storage.error import StorageNotFoundError + +from ..messages.cred_format import V20CredFormat +from ..messages.cred_proposal import V20CredProposal +from ..messages.cred_offer import V20CredOffer +from ..models.detail.indy import V20CredExRecordIndy +from ..models.detail.dif import V20CredExRecordDIF +from ..models.cred_ex_record import V20CredExRecord + +LOGGER = logging.getLogger(__name__) + + +class V20CredFormatError(BaseError): + """Credential format error under issue-credential protocol v2.0.""" + + +class V20CredFormatHandler(ABC): + "Base credential format handler." + + format: V20CredFormat.Format = None + + def __init__(self, profile: Profile): + """Initialize CredFormatHandler.""" + super().__init__() + + self._profile = profile + + @property + def profile(self) -> Profile: + """ + Accessor for the current profile instance. + + Returns: + The profile instance for this credential format + + """ + return self._profile + + async def get_detail_record( + self, cred_ex_id: str + ) -> Union[V20CredExRecordIndy, V20CredExRecordDIF]: + """Retrieve credential exchange detail record by cred_ex_id.""" + + async with self.profile.session() as session: + try: + return await self.format.detail.retrieve_by_cred_ex_id( + session, cred_ex_id + ) + except StorageNotFoundError: + return None + + @abstractclassmethod + def validate_filter(cls, data: Mapping): + """""" + + @abstractmethod + async def create_proposal( + self, filter: Mapping[str, str] + ) -> Tuple[V20CredFormat, AttachDecorator]: + """""" + + @abstractmethod + async def receive_offer( + self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer + ): + """""" + + @abstractmethod + async def create_offer( + self, cred_proposal_message: V20CredProposal + ) -> Tuple[V20CredFormat, AttachDecorator]: + """""" + + @abstractmethod + async def create_request( + self, + cred_ex_record: V20CredExRecord, + holder_did: str, + ): + """""" + + @abstractmethod + async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = 5): + """""" + + @abstractmethod + async def store_credential(self, cred_ex_record: V20CredExRecord, cred_id: str): + """""" \ No newline at end of file diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py index 6d983b19e7..8d4305b7b6 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py @@ -1,68 +1,111 @@ """V2.0 indy issue-credential cred format.""" +import logging + +from marshmallow import ValidationError import uuid import json -from typing import Coroutine, Mapping +from typing import Mapping, Tuple +import asyncio -from ..manager import V20CredManagerError -from .....core.profile import Profile -from .....messaging.credential_definitions.util import CRED_DEF_SENT_RECORD_TYPE -from .....messaging.decorators.attach_decorator import AttachDecorator -from .....storage.base import BaseStorage -from .....indy.issuer import IndyIssuer from .....cache.base import BaseCache +from .....indy.issuer import IndyIssuer, IndyIssuerRevocationRegistryFullError +from .....indy.holder import IndyHolder, IndyHolderError from .....ledger.base import BaseLedger +from .....messaging.credential_definitions.util import ( + CRED_DEF_SENT_RECORD_TYPE, + CRED_DEF_TAGS, +) +from .....messaging.decorators.attach_decorator import AttachDecorator +from .....revocation.models.issuer_rev_reg_record import IssuerRevRegRecord +from .....revocation.models.revocation_registry import RevocationRegistry +from .....revocation.indy import IndyRevocation +from .....storage.base import BaseStorage +from .....storage.error import StorageNotFoundError + from ..messages.cred_format import V20CredFormat +from ..messages.cred_proposal import V20CredProposal +from ..messages.cred_offer import V20CredOffer +from ..messages.cred_request import V20CredRequest +from ..messages.cred_issue import V20CredIssue +from ..models.cred_ex_record import V20CredExRecord +from ..models.detail.indy import V20CredExRecordIndy +from ..formats.handler import V20CredFormatError, V20CredFormatHandler + +LOGGER = logging.getLogger(__name__) -class IndyCredFormat: - @property - def profile(self) -> Profile: - """ - Accessor for the current profile instance. +class IndyCredFormatHandler(V20CredFormatHandler): - Returns: - The profile instance for this credential manager + format = V20CredFormat.Format.INDY - """ - return self._profile + @classmethod + def validate_filter(cls, data: Mapping): + if data.keys() - set(CRED_DEF_TAGS): + raise ValidationError(f"Bad indy credential filter: {data}") async def _match_sent_cred_def_id(self, tag_query: Mapping[str, str]) -> str: """Return most recent matching id of cred def that agent sent to ledger.""" - async with self._profile.session() as session: + async with self.profile.session() as session: storage = session.inject(BaseStorage) found = await storage.find_all_records( type_filter=CRED_DEF_SENT_RECORD_TYPE, tag_query=tag_query ) if not found: - raise V20CredManagerError( + raise V20CredFormatError( f"Issuer has no operable cred def for proposal spec {tag_query}" ) return max(found, key=lambda r: int(r.tags["epoch"])).tags["cred_def_id"] async def create_proposal( self, filter: Mapping[str, str] - ) -> Coroutine[V20CredFormat, AttachDecorator]: - id = uuid() + ) -> Tuple[V20CredFormat, AttachDecorator]: + id = uuid.uuid4() return ( - V20CredFormat(attach_id=id, format_=V20CredFormat.Format.INDY), + V20CredFormat(attach_id=id, format_=self.format), AttachDecorator.data_base64(filter, ident=id), ) + async def receive_offer( + self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer + ): + # TODO: Why move from offer to proposal? + offer = cred_offer_message.attachment(self.format) + schema_id = offer["schema_id"] + cred_def_id = offer["cred_def_id"] + + # TODO: this could overwrite proposal for other formats. We should append or something + cred_proposal_ser = V20CredProposal( + comment=cred_offer_message.comment, + credential_preview=cred_offer_message.credential_preview, + formats=[V20CredFormat(attach_id="0", format_=self.format)], + filters_attach=[ + AttachDecorator.data_base64( + { + "schema_id": schema_id, + "cred_def_id": cred_def_id, + }, + ident="0", + ) + ], + ).serialize() # proposal houses filters, preview (possibly with MIME types) + + async with self.profile.session() as session: + # TODO: we should probably not modify cred_ex_record here + cred_ex_record.cred_proposal = cred_proposal_ser + await cred_ex_record.save(session, reason="receive v2.0 credential offer") + async def create_offer( - self, cred_proposal_message - ) -> Coroutine[V20CredFormat, AttachDecorator]: + self, cred_proposal_message: V20CredProposal + ) -> Tuple[V20CredFormat, AttachDecorator]: issuer = self.profile.inject(IndyIssuer) ledger = self.profile.inject(BaseLedger) cache = self.profile.inject(BaseCache, required=False) cred_def_id = await self._match_sent_cred_def_id( - V20CredFormat.Format.INDY.get_attachment_data( - cred_proposal_message.formats, - cred_proposal_message.filters_attach, - ) + cred_proposal_message.attachment(self.format) ) async def _create(): @@ -74,10 +117,10 @@ async def _create(): schema = await ledger.get_schema(schema_id) schema_attrs = {attr for attr in schema["attrNames"]} preview_attrs = { - attr for attr in cred_proposal_message.cred_preview.attr_dict() + attr for attr in cred_proposal_message.credential_preview.attr_dict() } if preview_attrs != schema_attrs: - raise V20CredManagerError( + raise V20CredFormatError( f"Preview attributes {preview_attrs} " f"mismatch corresponding schema attributes {schema_attrs}" ) @@ -90,13 +133,271 @@ async def _create(): if entry.result: cred_offer = entry.result else: - cred_offer = await _create(cred_def_id) + cred_offer = await _create() await entry.set_result(cred_offer, 3600) if not cred_offer: - cred_offer = await _create(cred_def_id) + cred_offer = await _create() - id = uuid() + id = uuid.uuid4() return ( - V20CredFormat(attach_id=id, format_=V20CredFormat.Format.INDY), + V20CredFormat(attach_id=id, format_=self.format), AttachDecorator.data_base64(cred_offer, ident=id), ) + + async def create_request( + self, + cred_ex_record: V20CredExRecord, + holder_did: str, + ): + cred_offer = V20CredOffer.deserialize(cred_ex_record.cred_offer).attachment( + self.format + ) + + if "nonce" not in cred_offer: + raise V20CredFormatError("Missing nonce in credential offer") + + nonce = cred_offer["nonce"] + cred_def_id = cred_offer["cred_def_id"] + + async def _create(): + ledger = self.profile.inject(BaseLedger) + async with ledger: + cred_def = await ledger.get_credential_definition(cred_def_id) + + holder = self.profile.inject(IndyHolder) + request_json, metadata_json = await holder.create_credential_request( + cred_offer, cred_def, holder_did + ) + + return { + "request": json.loads(request_json), + "metadata": json.loads(metadata_json), + } + + cache_key = f"credential_request::{cred_def_id}::{holder_did}::{nonce}" + cred_req_result = None + cache = self.profile.inject(BaseCache, required=False) + if cache: + async with cache.acquire(cache_key) as entry: + if entry.result: + cred_req_result = entry.result + else: + cred_req_result = await _create() + await entry.set_result(cred_req_result, 3600) + if not cred_req_result: + cred_req_result = await _create() + + detail_record = V20CredExRecordIndy( + cred_ex_id=cred_ex_record.cred_ex_id, + cred_request_metadata=cred_req_result["metadata"], + ) + + async with self.profile.session() as session: + await detail_record.save(session, reason="create v2.0 credential request") + + id = uuid.uuid4() + return ( + V20CredFormat(attach_id=id, format_=self.format), + AttachDecorator.data_base64(cred_req_result["request"], ident=id), + ) + + async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = 5): + cred_offer = V20CredOffer.deserialize(cred_ex_record.cred_offer).attachment( + self.format + ) + cred_request = V20CredRequest.deserialize( + cred_ex_record.cred_request + ).attachment(self.format) + + schema_id = cred_offer["schema_id"] + cred_def_id = cred_offer["cred_def_id"] + + rev_reg_id = None + rev_reg = None + + ledger = self.profile.inject(BaseLedger) + async with ledger: + schema = await ledger.get_schema(schema_id) + cred_def = await ledger.get_credential_definition(cred_def_id) + + tails_path = None + if cred_def["value"].get("revocation"): + revoc = IndyRevocation(self.profile) + try: + active_rev_reg_rec = await revoc.get_active_issuer_rev_reg_record( + cred_def_id + ) + rev_reg = await active_rev_reg_rec.get_registry() + rev_reg_id = active_rev_reg_rec.revoc_reg_id + + tails_path = rev_reg.tails_local_path + await rev_reg.get_or_fetch_local_tails_path() + + except StorageNotFoundError: + async with self.profile.session() as session: + posted_rev_reg_recs = await IssuerRevRegRecord.query_by_cred_def_id( + session, + cred_def_id, + state=IssuerRevRegRecord.STATE_POSTED, + ) + if not posted_rev_reg_recs: + # Send next 2 rev regs, publish tails files in background + async with self.profile.session() as session: + old_rev_reg_recs = sorted( + await IssuerRevRegRecord.query_by_cred_def_id( + session, + cred_def_id, + ) + ) # prefer to reuse prior rev reg size + for _ in range(2): + pending_rev_reg_rec = await revoc.init_issuer_registry( + cred_def_id, + max_cred_num=( + old_rev_reg_recs[0].max_cred_num + if old_rev_reg_recs + else None + ), + ) + asyncio.ensure_future( + pending_rev_reg_rec.stage_pending_registry( + self.profile, + max_attempts=3, # fail both in < 2s at worst + ) + ) + if retries > 0: + LOGGER.info( + ("Waiting 2s on posted rev reg " "for cred def %s, retrying"), + cred_def_id, + ) + await asyncio.sleep(2) + return await self.issue_credential( + cred_ex_record, + retries - 1, + ) + + raise V20CredFormatError( + f"Cred def id {cred_def_id} " "has no active revocation registry" + ) + del revoc + + cred_values = V20CredProposal.deserialize( + cred_ex_record.cred_proposal + ).credential_preview.attr_dict(decode=False) + issuer = self.profile.inject(IndyIssuer) + try: + (cred_json, cred_rev_id,) = await issuer.create_credential( + schema, + cred_offer, + cred_request, + cred_values, + cred_ex_record.cred_ex_id, + rev_reg_id, + tails_path, + ) + + # If the rev reg is now full + if rev_reg and rev_reg.max_creds == int(cred_rev_id): + async with self.profile.session() as session: + await active_rev_reg_rec.set_state( + session, + IssuerRevRegRecord.STATE_FULL, + ) + + # Send next 1 rev reg, publish tails file in background + revoc = IndyRevocation(self.profile) + pending_rev_reg_rec = await revoc.init_issuer_registry( + active_rev_reg_rec.cred_def_id, + max_cred_num=active_rev_reg_rec.max_cred_num, + ) + asyncio.ensure_future( + pending_rev_reg_rec.stage_pending_registry( + self.profile, + max_attempts=16, + ) + ) + + detail_record = V20CredExRecordIndy( + cred_ex_id=cred_ex_record.cred_ex_id, + rev_reg_id=rev_reg_id, + cred_rev_id=cred_rev_id, + ) + + async with self.profile.session() as session: + await detail_record.save(session, reason="v2.0 issue credential") + + except IndyIssuerRevocationRegistryFullError: + # unlucky: duelling instance issued last cred near same time as us + async with self.profile.session() as session: + await active_rev_reg_rec.set_state( + session, + IssuerRevRegRecord.STATE_FULL, + ) + + if retries > 0: + # use next rev reg; at worst, lucky instance is putting one up + LOGGER.info( + "Waiting 1s and retrying: revocation registry %s is full", + active_rev_reg_rec.revoc_reg_id, + ) + await asyncio.sleep(1) + return await self.issue_credential( + cred_ex_record, + retries - 1, + ) + + raise + + id = uuid.uuid4() + return ( + V20CredFormat(attach_id=id, format_=self.format), + AttachDecorator.data_base64(json.loads(cred_json), ident=id), + ) + + async def store_credential(self, cred_ex_record: V20CredExRecord, cred_id: str): + cred = V20CredIssue.deserialize(cred_ex_record.cred_issue).attachment( + self.format + ) + + rev_reg_def = None + ledger = self.profile.inject(BaseLedger) + async with ledger: + cred_def = await ledger.get_credential_definition(cred["cred_def_id"]) + if cred.get("rev_reg_id"): + rev_reg_def = await ledger.get_revoc_reg_def(cred["rev_reg_id"]) + + holder = self.profile.inject(IndyHolder) + cred_proposal_message = V20CredProposal.deserialize( + cred_ex_record.cred_proposal + ) + mime_types = None + if cred_proposal_message and cred_proposal_message.credential_preview: + mime_types = cred_proposal_message.credential_preview.mime_types() or None + + if rev_reg_def: + rev_reg = RevocationRegistry.from_definition(rev_reg_def, True) + await rev_reg.get_or_fetch_local_tails_path() + try: + detail_record = await self.get_detail_record(cred_ex_record.cred_ex_id) + if detail_record is None: + raise V20CredFormatError( + f"No credential exchange {self.format.aries} " + f"detail record found for cred ex id {cred_ex_record.cred_ex_id}" + ) + cred_id_stored = await holder.store_credential( + cred_def, + cred, + detail_record.cred_request_metadata, + mime_types, + credential_id=cred_id, + rev_reg_def=rev_reg_def, + ) + + cred_ex_record.cred_id_stored = cred_id_stored + detail_record.rev_reg_id = cred.get("rev_reg_id", None) + detail_record.cred_rev_id = cred.get("cred_rev_id", None) + + async with self.profile.session() as session: + await detail_record.save(session, reason="store credential v2.0") + except IndyHolderError as e: + LOGGER.error(f"Error storing credential: {e.error_code} - {e.message}") + raise e diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py index c78c90bcf9..b3ab36c13d 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py @@ -1,21 +1,11 @@ """V2.0 issue-credential protocol manager.""" -import asyncio -import json import logging -from typing import Mapping, Tuple, Union +from typing import Mapping, Tuple -from ....cache.base import BaseCache from ....core.error import BaseError from ....core.profile import Profile -from ....indy.holder import IndyHolder, IndyHolderError -from ....indy.issuer import IndyIssuer, IndyIssuerRevocationRegistryFullError -from ....ledger.base import BaseLedger -from ....messaging.decorators.attach_decorator import AttachDecorator -from ....revocation.indy import IndyRevocation -from ....revocation.models.revocation_registry import RevocationRegistry -from ....revocation.models.issuer_rev_reg_record import IssuerRevRegRecord from ....storage.error import StorageNotFoundError from .messages.cred_ack import V20CredAck @@ -26,8 +16,6 @@ from .messages.cred_request import V20CredRequest from .messages.inner.cred_preview import V20CredPreview from .models.cred_ex_record import V20CredExRecord -from .models.detail.dif import V20CredExRecordDIF -from .models.detail.indy import V20CredExRecordIndy LOGGER = logging.getLogger(__name__) @@ -59,20 +47,6 @@ def profile(self) -> Profile: """ return self._profile - async def get_detail_record( - self, - cred_ex_id: str, - fmt: V20CredFormat.Format, - ) -> Union[V20CredExRecordIndy, V20CredExRecordDIF]: - """Retrieve credential exchange detail record by format.""" - - async with self._profile.session() as session: - detail_cls = fmt.detail - try: - return await detail_cls.retrieve_by_cred_ex_id(session, cred_ex_id) - except StorageNotFoundError: - return None - async def prepare_send( self, connection_id: str, @@ -136,7 +110,7 @@ async def create_proposal( # Format specific create_proposal handler formats = [ - await fmt.handler(self.profile).create_proposal(filter) + await fmt.handler(self._profile).create_proposal(filter) for (fmt, filter) in fmt2filter.items() ] @@ -280,27 +254,6 @@ async def receive_offer( # TODO: assert for all methods that we support at least one format # TODO: assert we don't suddenly change from format during the interaction - offer = cred_offer_message.attachment( - V20CredFormat.Format.INDY - ) # may change for DIF - schema_id = offer["schema_id"] - cred_def_id = offer["cred_def_id"] - - cred_proposal_ser = V20CredProposal( - comment=cred_offer_message.comment, - credential_preview=cred_offer_message.credential_preview, - formats=[V20CredFormat(attach_id="0", format_=V20CredFormat.Format.INDY)], - filters_attach=[ - AttachDecorator.data_base64( - { - "schema_id": schema_id, - "cred_def_id": cred_def_id, - }, - ident="0", - ) - ], - ).serialize() # proposal houses filters, preview (possibly with MIME types) - async with self._profile.session() as session: # Get credential exchange record (holder sent proposal first) # or create it (issuer sent offer first) @@ -310,14 +263,12 @@ async def receive_offer( session, connection_id, cred_offer_message._thread_id ) ) - cred_ex_record.cred_proposal = cred_proposal_ser except StorageNotFoundError: # issuer sent this offer free of any proposal cred_ex_record = V20CredExRecord( connection_id=connection_id, thread_id=cred_offer_message._thread_id, initiator=V20CredExRecord.INITIATOR_EXTERNAL, role=V20CredExRecord.ROLE_HOLDER, - cred_proposal=cred_proposal_ser, auto_remove=not self._profile.settings.get( "preserve_exchange_records" ), @@ -329,6 +280,15 @@ async def receive_offer( await cred_ex_record.save(session, reason="receive v2.0 credential offer") + # TODO: should be called before the code above + # Format specific receive_offer handler + formats = [ + await V20CredFormat.Format.get(p.format) + .handler(self.profile) + .receive_offer(cred_ex_record, cred_offer_message) + for p in cred_offer_message.formats + ] + return cred_ex_record async def create_request( @@ -346,6 +306,8 @@ async def create_request( A tuple (credential exchange record, credential request message) """ + # TODO: allow to start from request message + # TODO: limit the number of formats in the final credential? pick one? if cred_ex_record.state != V20CredExRecord.STATE_OFFER_RECEIVED: raise V20CredManagerError( # indy-ism: must change for DIF f"Credential exchange {cred_ex_record.cred_ex_id} " @@ -353,59 +315,26 @@ async def create_request( f"(must be {V20CredExRecord.STATE_OFFER_RECEIVED})" ) - cred_offer_message = V20CredOffer.deserialize(cred_ex_record.cred_offer) - cred_offer = cred_offer_message.attachment( - V20CredFormat.Format.INDY - ) # will change for DIF - cred_def_id = cred_offer["cred_def_id"] - - async def _create_indy(): - ledger = self._profile.inject(BaseLedger) - async with ledger: - cred_def = await ledger.get_credential_definition(cred_def_id) - - holder = self._profile.inject(IndyHolder) - request_json, metadata_json = await holder.create_credential_request( - cred_offer, cred_def, holder_did - ) - return { - "request": json.loads(request_json), - "metadata": json.loads(metadata_json), - } - if cred_ex_record.cred_request: raise V20CredManagerError( "create_request() called multiple times for " f"v2.0 credential exchange {cred_ex_record.cred_ex_id}" ) - if "nonce" not in cred_offer: - raise V20CredManagerError("Missing nonce in credential offer") - nonce = cred_offer["nonce"] - cache_key = f"credential_request::{cred_def_id}::{holder_did}::{nonce}" - cred_req_result = None - cache = self._profile.inject(BaseCache, required=False) - if cache: - async with cache.acquire(cache_key) as entry: - if entry.result: - cred_req_result = entry.result - else: - cred_req_result = await _create_indy() - await entry.set_result(cred_req_result, 3600) - if not cred_req_result: - cred_req_result = await _create_indy() - - detail_record = V20CredExRecordIndy( - cred_ex_id=cred_ex_record.cred_ex_id, - cred_request_metadata=cred_req_result["metadata"], - ) + cred_offer_message = V20CredOffer.deserialize(cred_ex_record.cred_offer) + + # Format specific create_request handler + formats = [ + await V20CredFormat.Format.get(p.format) + .handler(self.profile) + .create_request(cred_ex_record, holder_did) + for p in cred_offer_message.formats + ] cred_request_message = V20CredRequest( comment=comment, - formats=[V20CredFormat(attach_id="0", format_=V20CredFormat.Format.INDY)], - requests_attach=[ - AttachDecorator.data_base64(cred_req_result["request"], ident="0") - ], + formats=[format for (format, _) in formats], + requests_attach=[attach for (_, attach) in formats], ) cred_request_message._thread = {"thid": cred_ex_record.thread_id} @@ -416,7 +345,6 @@ async def _create_indy(): cred_ex_record.state = V20CredExRecord.STATE_REQUEST_SENT async with self._profile.session() as session: await cred_ex_record.save(session, reason="create v2.0 credential request") - await detail_record.save(session, reason="create v2.0 credential request") return (cred_ex_record, cred_request_message) @@ -453,7 +381,6 @@ async def issue_credential( cred_ex_record: V20CredExRecord, *, comment: str = None, - retries: int = 5, ) -> Tuple[V20CredExRecord, V20CredIssue]: """ Issue a credential. @@ -461,7 +388,6 @@ async def issue_credential( Args: cred_ex_record: credential exchange record for which to issue credential comment: optional human-readable comment pertaining to credential issue - retries: maximum number of retries on failure Returns: Tuple: (Updated credential exchange record, credential issue message) @@ -475,165 +401,29 @@ async def issue_credential( f"(must be {V20CredExRecord.STATE_REQUEST_RECEIVED})" ) - cred_offer_message = V20CredOffer.deserialize(cred_ex_record.cred_offer) - replacement_id = cred_offer_message.replacement_id - cred_offer = cred_offer_message.attachment(V20CredFormat.Format.INDY) - schema_id = cred_offer["schema_id"] - cred_def_id = cred_offer["cred_def_id"] - - cred_request = V20CredRequest.deserialize( - cred_ex_record.cred_request - ).attachment( - V20CredFormat.Format.INDY - ) # will change for DIF - - rev_reg_id = None - rev_reg = None - if cred_ex_record.cred_issue: raise V20CredManagerError( "issue_credential() called multiple times for " f"cred ex record {cred_ex_record.cred_ex_id}" ) - ledger = self._profile.inject(BaseLedger) - async with ledger: - schema = await ledger.get_schema(schema_id) - cred_def = await ledger.get_credential_definition(cred_def_id) - - tails_path = None - if cred_def["value"].get("revocation"): - revoc = IndyRevocation(self._profile) - try: - active_rev_reg_rec = await revoc.get_active_issuer_rev_reg_record( - cred_def_id - ) - rev_reg = await active_rev_reg_rec.get_registry() - rev_reg_id = active_rev_reg_rec.revoc_reg_id - - tails_path = rev_reg.tails_local_path - await rev_reg.get_or_fetch_local_tails_path() - - except StorageNotFoundError: - async with self._profile.session() as session: - posted_rev_reg_recs = await IssuerRevRegRecord.query_by_cred_def_id( - session, - cred_def_id, - state=IssuerRevRegRecord.STATE_POSTED, - ) - if not posted_rev_reg_recs: - # Send next 2 rev regs, publish tails files in background - async with self._profile.session() as session: - old_rev_reg_recs = sorted( - await IssuerRevRegRecord.query_by_cred_def_id( - session, - cred_def_id, - ) - ) # prefer to reuse prior rev reg size - for _ in range(2): - pending_rev_reg_rec = await revoc.init_issuer_registry( - cred_def_id, - max_cred_num=( - old_rev_reg_recs[0].max_cred_num - if old_rev_reg_recs - else None - ), - ) - asyncio.ensure_future( - pending_rev_reg_rec.stage_pending_registry( - self._profile, - max_attempts=3, # fail both in < 2s at worst - ) - ) - if retries > 0: - LOGGER.info( - ("Waiting 2s on posted rev reg " "for cred def %s, retrying"), - cred_def_id, - ) - await asyncio.sleep(2) - return await self.issue_credential( - cred_ex_record=cred_ex_record, - comment=comment, - retries=retries - 1, - ) - - raise V20CredManagerError( - f"Cred def id {cred_def_id} " "has no active revocation registry" - ) - del revoc - - cred_values = V20CredProposal.deserialize( - cred_ex_record.cred_proposal - ).credential_preview.attr_dict(decode=False) - issuer = self._profile.inject(IndyIssuer) - try: - (cred_json, cred_rev_id,) = await issuer.create_credential( - schema, - cred_offer, - cred_request, - cred_values, - cred_ex_record.cred_ex_id, - rev_reg_id, - tails_path, - ) - - detail_record = V20CredExRecordIndy( - cred_ex_id=cred_ex_record.cred_ex_id, - rev_reg_id=rev_reg_id, - cred_rev_id=cred_rev_id, - ) - - # If the rev reg is now full - if rev_reg and rev_reg.max_creds == int(cred_rev_id): - async with self._profile.session() as session: - await active_rev_reg_rec.set_state( - session, - IssuerRevRegRecord.STATE_FULL, - ) - - # Send next 1 rev reg, publish tails file in background - revoc = IndyRevocation(self._profile) - pending_rev_reg_rec = await revoc.init_issuer_registry( - active_rev_reg_rec.cred_def_id, - max_cred_num=active_rev_reg_rec.max_cred_num, - ) - asyncio.ensure_future( - pending_rev_reg_rec.stage_pending_registry( - self._profile, - max_attempts=16, - ) - ) - - except IndyIssuerRevocationRegistryFullError: - # unlucky: duelling instance issued last cred near same time as us - async with self._profile.session() as session: - await active_rev_reg_rec.set_state( - session, - IssuerRevRegRecord.STATE_FULL, - ) - - if retries > 0: - # use next rev reg; at worst, lucky instance is putting one up - LOGGER.info( - "Waiting 1s and retrying: revocation registry %s is full", - active_rev_reg_rec.revoc_reg_id, - ) - await asyncio.sleep(1) - return await self.issue_credential( - cred_ex_record=cred_ex_record, - comment=comment, - retries=retries - 1, - ) + cred_offer_message = V20CredOffer.deserialize(cred_ex_record.cred_offer) + cred_request_message = V20CredRequest.deserialize(cred_ex_record.cred_request) + replacement_id = cred_offer_message.replacement_id - raise + # Format specific issue_credential handler + formats = [ + await V20CredFormat.Format.get(p.format) + .handler(self.profile) + .issue_credential(cred_ex_record) + for p in cred_request_message.formats + ] cred_issue_message = V20CredIssue( replacement_id=replacement_id, comment=comment, - formats=[V20CredFormat(attach_id="0", format_=V20CredFormat.Format.INDY)], - credentials_attach=[ - AttachDecorator.data_base64(json.loads(cred_json), ident="0") - ], + formats=[format for (format, _) in formats], + credentials_attach=[attach for (_, attach) in formats], ) cred_ex_record.state = V20CredExRecord.STATE_ISSUED @@ -641,7 +431,6 @@ async def issue_credential( async with self._profile.session() as session: # FIXME - re-fetch record to check state, apply transactional update await cred_ex_record.save(session, reason="v2.0 issue credential") - await detail_record.save(session, reason="v2.0 issue credential") cred_issue_message._thread = {"thid": cred_ex_record.thread_id} cred_issue_message.assign_trace_decorator( @@ -701,59 +490,17 @@ async def store_credential( f"(must be {V20CredExRecord.STATE_CREDENTIAL_RECEIVED})" ) - cred = V20CredIssue.deserialize(cred_ex_record.cred_issue).attachment( - V20CredFormat.Format.INDY - ) - - rev_reg_def = None - ledger = self._profile.inject(BaseLedger) - async with ledger: - cred_def = await ledger.get_credential_definition(cred["cred_def_id"]) - if cred.get("rev_reg_id"): - rev_reg_def = await ledger.get_revoc_reg_def(cred["rev_reg_id"]) - - holder = self._profile.inject(IndyHolder) - cred_proposal_message = V20CredProposal.deserialize( - cred_ex_record.cred_proposal - ) - mime_types = None - if cred_proposal_message and cred_proposal_message.credential_preview: - mime_types = cred_proposal_message.credential_preview.mime_types() or None - - if rev_reg_def: - rev_reg = RevocationRegistry.from_definition(rev_reg_def, True) - await rev_reg.get_or_fetch_local_tails_path() - try: - detail_record = await self.get_detail_record( - cred_ex_record.cred_ex_id, - V20CredFormat.Format.INDY, - ) - if detail_record is None: - raise V20CredManagerError( - f"No credential exchange {V20CredFormat.Format.INDY.aries} " - f"detail record found for cred ex id {cred_ex_record.cred_ex_id}" - ) - cred_id_stored = await holder.store_credential( - cred_def, - cred, - detail_record.cred_request_metadata, - mime_types, - credential_id=cred_id, - rev_reg_def=rev_reg_def, - ) - except IndyHolderError as e: - LOGGER.error(f"Error storing credential: {e.error_code} - {e.message}") - raise e + # Format specific store_credential handler + for p in V20CredIssue.deserialize(cred_ex_record.cred_issue).formats: + await V20CredFormat.Format.get(p.format).handler( + self.profile + ).store_credential(cred_ex_record, cred_id) cred_ex_record.state = V20CredExRecord.STATE_DONE - cred_ex_record.cred_id_stored = cred_id_stored - detail_record.rev_reg_id = cred.get("rev_reg_id", None) - detail_record.cred_rev_id = cred.get("cred_rev_id", None) async with self._profile.session() as session: # FIXME - re-fetch record to check state, apply transactional update await cred_ex_record.save(session, reason="store credential v2.0") - await detail_record.save(session, reason="store credential v2.0") cred_ack_message = V20CredAck() cred_ack_message.assign_thread_id( diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py index 0e120f54c1..f1e490d516 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py @@ -1,23 +1,26 @@ """Issue-credential protocol message attachment format.""" from collections import namedtuple -from enum import Enum from re import sub -from typing import Mapping, Sequence, Union +from typing import Mapping, Sequence, Type, Union +from enum import Enum from uuid import uuid4 -from marshmallow import EXCLUDE, fields, validate, ValidationError +from marshmallow import EXCLUDE, fields, validate -from .....messaging.credential_definitions.util import CRED_DEF_TAGS -from .....messaging.decorators.attach_decorator import AttachDecorator +from .....utils.classloader import ClassLoader from .....messaging.models.base import BaseModel, BaseModelSchema from .....messaging.valid import UUIDFour - -from ..formats.indy import IndyCredFormat -from ..models.detail.dif import V20CredExRecordDIF +from .....messaging.decorators.attach_decorator import AttachDecorator +from ..message_types import PROTOCOL_PACKAGE from ..models.detail.indy import V20CredExRecordIndy +from ..models.detail.dif import V20CredExRecordDIF +from typing import TYPE_CHECKING + +# TODO: remove +if TYPE_CHECKING: + from ..formats.handler import V20CredFormatHandler -# Aries RFC value, further monikers, cred ex detail record class FormatSpec = namedtuple("FormatSpec", "aries aka detail handler") @@ -34,14 +37,15 @@ class Format(Enum): INDY = FormatSpec( "hlindy-zkp-v1.0", - {"indy", "hyperledgerindy", "hlindy"}, + ["indy", "hyperledgerindy", "hlindy"], V20CredExRecordIndy, - IndyCredFormat, + f"{PROTOCOL_PACKAGE}.formats.indy.IndyCredFormatHandler", ) DIF = FormatSpec( "dif/credential-manifest@v1.0", - {"dif", "w3c", "jsonld"}, + ["dif", "w3c", "jsonld"], V20CredExRecordDIF, + f"{PROTOCOL_PACKAGE}.formats.indy.IndyCredFormatHandler", ) @classmethod @@ -79,15 +83,15 @@ def detail(self) -> str: """Accessor for credential exchange detail class.""" return self.value.detail - def handler(self) -> IndyCredFormat: + @property + def handler(self) -> Type["V20CredFormatHandler"]: """Accessor for credential exchange format handler.""" - return self.value.handler + # TODO: optimize / refactor + return ClassLoader.load_class(self.value.handler) def validate_filter(self, data: Mapping): """Raise ValidationError for wrong filtration criteria.""" - if self is V20CredFormat.Format.INDY: - if data.keys() - set(CRED_DEF_TAGS): - raise ValidationError(f"Bad indy credential filter: {data}") + self.handler.validate_filter(data) def get_attachment_data( self, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py index 3e249508d3..937f8cd4ca 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py @@ -308,6 +308,24 @@ def _formats_filters(filt_spec: Mapping) -> Mapping: } +async def _get_result_with_details( + profile: Profile, cred_ex_record: V20CredExRecord +) -> Mapping: + """Get credential exchange result with detail records.""" + result = {"cred_ex_record": cred_ex_record.serialize()} + + for fmt in V20CredFormat.Format: + # TODO: optimize so we don't need to initialize it for each record + detail_record = await fmt.handler(profile).get_detail_record( + cred_ex_record.cred_ex_id + ) + + if detail_record: + result[fmt.aka[0]] = detail_record.serialize() + + return result + + @docs( tags=["issue-credential v2.0"], summary="Fetch all credential exchange records", @@ -344,23 +362,9 @@ async def credential_exchange_list(request: web.BaseRequest): ) results = [] - cred_manager = V20CredManager(context.profile) for cxr in cred_ex_records: - indy_record = await cred_manager.get_detail_record( - cxr.cred_ex_id, - V20CredFormat.Format.INDY, - ) - dif_record = await cred_manager.get_detail_record( - cxr.cred_ex_id, - V20CredFormat.Format.DIF, - ) - results.append( - { - "cred_ex_record": cxr.serialize(), - "indy": indy_record.serialize() if indy_record else None, - "dif": dif_record.serialize() if dif_record else None, - } - ) + result = await _get_result_with_details(context.profile, cxr) + results.append(result) except (StorageError, BaseModelError) as err: raise web.HTTPBadRequest(reason=err.roll_up) from err @@ -394,18 +398,7 @@ async def credential_exchange_retrieve(request: web.BaseRequest): async with context.session() as session: cred_ex_record = await V20CredExRecord.retrieve_by_id(session, cred_ex_id) - cred_manager = V20CredManager(context.profile) - indy_record = await cred_manager.get_detail_record( - cred_ex_id, V20CredFormat.Format.INDY - ) - dif_record = await cred_manager.get_detail_record( - cred_ex_id, V20CredFormat.Format.DIF - ) - result = { - "cred_ex_record": cred_ex_record.serialize(), - "indy": indy_record.serialize() if indy_record else None, - "dif": dif_record.serialize() if dif_record else None, - } + result = await _get_result_with_details(context.profile, cred_ex_record) except StorageNotFoundError as err: raise web.HTTPNotFound(reason=err.roll_up) from err From 5477f290d2f9cdc79bec05108dca94e4acb6a179 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Tue, 23 Feb 2021 15:08:18 +0100 Subject: [PATCH 003/138] add ld_proofs code to aries_cloudagent/vc/ld_proofs Signed-off-by: Karim Stekelenburg --- aries_cloudagent/vc/ld_proofs/__init__.py | 0 aries_cloudagent/vc/ld_proofs/constants.py | 1 + .../vc/ld_proofs/crypto/Base58Encoder.py | 11 ++ .../vc/ld_proofs/crypto/Ed25519KeyPair.py | 34 ++++ .../vc/ld_proofs/crypto/KeyPair.py | 11 ++ .../vc/ld_proofs/crypto/__init__.py | 0 .../vc/ld_proofs/document_loader.py | 44 +++++ .../vc/ld_proofs/mock/__init__.py | 0 .../vc/ld_proofs/mock/test_documents.py | 19 ++ .../purposes/AssertionProofPurpose.py | 7 + .../purposes/AuthenticationProofPurpose.py | 45 +++++ .../purposes/ControllerProofPurpose.py | 46 +++++ .../purposes/IssueCredentialProofPurpose.py | 34 ++++ .../vc/ld_proofs/purposes/ProofPurpose.py | 34 ++++ .../purposes/PublicKeyProofPurpose.py | 18 ++ .../vc/ld_proofs/purposes/__init__.py | 0 .../suites/JwsLinkedDataSignature.py | 108 ++++++++++++ .../vc/ld_proofs/suites/LinkedDataProof.py | 17 ++ .../ld_proofs/suites/LinkedDataSignature.py | 166 ++++++++++++++++++ .../vc/ld_proofs/suites/__init__.py | 0 aries_cloudagent/vc/vc_ld/checker.py | 82 +++++++++ aries_cloudagent/vc/vc_ld/constants.py | 5 + aries_cloudagent/vc/vc_ld/issue.py | 20 +++ aries_cloudagent/vc/vc_ld/verify.py | 50 ++++++ 24 files changed, 752 insertions(+) create mode 100644 aries_cloudagent/vc/ld_proofs/__init__.py create mode 100644 aries_cloudagent/vc/ld_proofs/constants.py create mode 100644 aries_cloudagent/vc/ld_proofs/crypto/Base58Encoder.py create mode 100644 aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py create mode 100644 aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py create mode 100644 aries_cloudagent/vc/ld_proofs/crypto/__init__.py create mode 100644 aries_cloudagent/vc/ld_proofs/document_loader.py create mode 100644 aries_cloudagent/vc/ld_proofs/mock/__init__.py create mode 100644 aries_cloudagent/vc/ld_proofs/mock/test_documents.py create mode 100644 aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py create mode 100644 aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py create mode 100644 aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py create mode 100644 aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py create mode 100644 aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py create mode 100644 aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py create mode 100644 aries_cloudagent/vc/ld_proofs/purposes/__init__.py create mode 100644 aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py create mode 100644 aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py create mode 100644 aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py create mode 100644 aries_cloudagent/vc/ld_proofs/suites/__init__.py create mode 100644 aries_cloudagent/vc/vc_ld/checker.py create mode 100644 aries_cloudagent/vc/vc_ld/constants.py create mode 100644 aries_cloudagent/vc/vc_ld/issue.py create mode 100644 aries_cloudagent/vc/vc_ld/verify.py diff --git a/aries_cloudagent/vc/ld_proofs/__init__.py b/aries_cloudagent/vc/ld_proofs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/vc/ld_proofs/constants.py b/aries_cloudagent/vc/ld_proofs/constants.py new file mode 100644 index 0000000000..58a829645f --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/constants.py @@ -0,0 +1 @@ +SECURITY_CONTEXT_V2_URL = 'https://w3id.org/security/v2' diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Base58Encoder.py b/aries_cloudagent/vc/ld_proofs/crypto/Base58Encoder.py new file mode 100644 index 0000000000..7159d77563 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/crypto/Base58Encoder.py @@ -0,0 +1,11 @@ +import base58 + + +class Base58Encoder(object): + @staticmethod + def encode(data): + return base58.b58encode(data) + + @staticmethod + def decode(data): + return base58.b58decode(data) diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py new file mode 100644 index 0000000000..5eb8e17549 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py @@ -0,0 +1,34 @@ +from .KeyPair import KeyPair +from base58 import b58decode, b58encode +from nacl.encoding import Base64Encoder +from nacl.signing import VerifyKey, SigningKey + + +class Ed25519KeyPair(KeyPair): + def __init__(self, public_key_base_58: bytes): + self.public_key = b58decode(public_key_base_58) + self.verifier = VerifyKey(self.public_key) + self.signer = None + + @classmethod + def generate(cls, seed: str = None): + if seed: + signer = SigningKey(seed) + else: + signer = SigningKey.generate() + + key_pair = cls(b58encode(signer.verify_key._key)) + key_pair.signer = signer + key_pair.public_key = key_pair.verifier._key + key_pair.private_key = signer._signing_key + + return key_pair + + def sign(self, message): + if not self.signer: + raise Exception('No signer defined') + + return self.signer.sign(message, Base64Encoder) + + def verify(self, message): + return self.verifier.verify(message) diff --git a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py new file mode 100644 index 0000000000..44511fbc96 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + + +class KeyPair(ABC): + @abstractmethod + def sign(self, message): + pass + + @abstractmethod + def verify(self, message): + pass diff --git a/aries_cloudagent/vc/ld_proofs/crypto/__init__.py b/aries_cloudagent/vc/ld_proofs/crypto/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/vc/ld_proofs/document_loader.py b/aries_cloudagent/vc/ld_proofs/document_loader.py new file mode 100644 index 0000000000..6454b0d279 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/document_loader.py @@ -0,0 +1,44 @@ +from pyld.documentloader import requests +from .wallet_util import did_key_to_naked +from ...wallet.util import did_key_to_naked + +def resolve_ed25519_did_key(did_key: str) -> dict: + pub_key_base58 = did_key_to_naked(did_key) + key_ref = f"#{did_key[8:]}" + did_key_with_key_ref = did_key + key_ref + + return { + "contentType": "application/ld+json", + "contextUrl": "https://w3id.org/did/v1", + "documentUrl": did_key, + "document": { + "@context": "https://w3id.org/did/v1", + "id": did_key, + "verificationMethod": [ + { + "id": did_key_with_key_ref, + "type": "Ed25519VerificationKey2018", + "controller": did_key, + "publicKeyBase58": pub_key_base58, + } + ], + "authentication": [did_key_with_key_ref], + "assertionMethod": [did_key_with_key_ref], + "capabilityDelegation": [did_key_with_key_ref], + "capabilityInvocation": [did_key_with_key_ref], + "keyAgreement": [], + }, + } + + +def document_loader(url: str, options: dict): + # NOTE: this is a hacky approach for the time being. + if url.startswith("did:key:"): + return resolve_ed25519_did_key(url) + elif url.startswith("http://") or url.startswith("https://"): + loader = requests.requests_document_loader() + return loader(url, options) + else: + raise Exception( + "Unrecognized url format. Must start with 'did:key:', 'http://' or 'https://'" + ) diff --git a/aries_cloudagent/vc/ld_proofs/mock/__init__.py b/aries_cloudagent/vc/ld_proofs/mock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/vc/ld_proofs/mock/test_documents.py b/aries_cloudagent/vc/ld_proofs/mock/test_documents.py new file mode 100644 index 0000000000..0c5e4ef9b4 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/mock/test_documents.py @@ -0,0 +1,19 @@ +from ..constants import SECURITY_CONTEXT_V2_URL + +non_security_context_test_doc = { + '@context': { + 'schema': 'http://schema.org/', + 'name': 'schema:name', + 'homepage': 'schema:url', + 'image': 'schema:image' + }, + 'name': 'Manu Sporny', + 'homepage': 'https://manu.sporny.org/', + 'image': 'https://manu.sporny.org/images/manu.png' +} + +security_context_test_doc = { + **non_security_context_test_doc, '@context': [{ + '@version': 1.1 + }, non_security_context_test_doc['@context'], SECURITY_CONTEXT_V2_URL] +} diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py new file mode 100644 index 0000000000..e3fb887ab1 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py @@ -0,0 +1,7 @@ +from .ControllerProofPurpose import ControllerProofPurpose +from datetime import datetime, timedelta + + +class AssertionProofPurpose(ControllerProofPurpose): + def __init__(self, date: datetime, max_timestamp_delta: timedelta = None): + super().__init__('assertionMethod', date, max_timestamp_delta) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py new file mode 100644 index 0000000000..af18f4abf4 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py @@ -0,0 +1,45 @@ +from .ControllerProofPurpose import ControllerProofPurpose +from datetime import datetime, timedelta +from typing import Awaitable + + +class AuthenticationProofPurpose(ControllerProofPurpose): + def __init__( + self, + controller: dict, + challenge: str, + date: datetime, + domain: str = None, + max_timestamp_delta: timedelta = None): + super(ControllerProofPurpose, self).__init__( + 'authentication', controller, date, max_timestamp_delta) + self.challenge = challenge + self.domain = domain + + async def validate( + self, proof: dict, verification_method: dict, + document_loader: callable) -> Awaitable[dict]: + try: + if proof['challenge'] != self.challenge: + raise Exception( + f'The challenge is not expected; challenge={proof["challenge"]}, expected=[self.challenge]' + ) + + if self.domain and (proof['domain'] != self.domain): + raise Exception( + f'The domain is not as expected; domain={proof["domain"]}, expected={self.domain}' + ) + + return await super(ControllerProofPurpose, self).validate( + proof, verification_method, document_loader) + except Exception as e: + return {'valid': False, 'eror': e} + + async def update(self, proof: dict) -> Awaitable[dict]: + proof = await super().update(proof) + proof['challenge'] = self.challenge + + if self.domain: + proof['domain'] = self.domain + + return proof diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py new file mode 100644 index 0000000000..d2ab222c9d --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py @@ -0,0 +1,46 @@ +from .ProofPurpose import ProofPurpose +from ..constants import SECURITY_CONTEXT_V2_URL +from pyld import jsonld +from typing import Union +from datetime import datetime, timedelta + + +class ControllerProofPurpose(ProofPurpose): + def __init__( + self, term: str, date: datetime, max_timestamp_delta: timedelta = None): + super().__init__(term, date, max_timestamp_delta) + + async def validate( + self, proof: dict, verification_method: str, document_loader: callable): + """ + 1. + """ + try: + result = await super(ProofPurpose, self).validate(proof) + + if not result['valid']: + raise result['error'] + + framed = jsonld.frame( + verification_method, { + '@context': SECURITY_CONTEXT_V2_URL, + '@embed': '@always', + 'id': verification_method + }, {'documentLoader': document_loader}) + + result['controller'] = framed + verificationId = verification_method['id'] + + verification_methods = jsonld.get_values(result['controller'], self.term) + result['valid'] = any( + method == verificationId for method in verification_methods) + + if not result['valid']: + raise Exception( + f"Verification method {verification_method['id']} not authorized by controller for proof purpose {self.term}" + ) + + return result + + except Exception as e: + return {'valid': False, 'error': e} diff --git a/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py new file mode 100644 index 0000000000..2e8ea2f71f --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py @@ -0,0 +1,34 @@ +from .AssertionProofPurpose import AssertionProofPurpose +from datetime import datetime, timedelta +from ..suites import LinkedDataSignature +from pyld import jsonld + + +# TODO Move this file to the vc lib +class IssueCredentialProofPurpose(AssertionProofPurpose): + def __init__(self, date: datetime, max_timestamp_delta: None): + super().__init__(date, max_timestamp_delta) + + async def validate( + self, proof: dict, document: dict, suite: LinkedDataSignature, + verification_method: str, document_loader: callable): + try: + result = super().validate(proof, verification_method, document_loader) + + if not result['valid']: + raise result['error'] + + issuer = jsonld.get_values( + document, 'https://www.w3.org/2018/credentials#issuer') + + if not issuer or issuer.len() == 0: + raise Exception('Credential issuer is required.') + + if result['controller']['id'] != issuer[0]['id']: + raise Exception( + 'Credential issuer must match the verification method controller.') + + return {'valid': True} + + except Exception as e: + return {'valid': False, 'error': e} diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py new file mode 100644 index 0000000000..ef1d95fcc1 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py @@ -0,0 +1,34 @@ +import datetime + + +class ProofPurpose: + def __init__( + self, + term: str, + date: datetime.datetime, + max_timestamp_delta: datetime.timedelta = None): + self.term = term + self.date = date + self.max_timestamp_delta = max_timestamp_delta + + def validate(self, proof: dict) -> bool: + try: + if self.max_timestamp_delta is not None: + expected = self.date.time() + created = datetime.datetime.strptime( + proof['created'], "%Y-%m-%dT%H:%M:%SZ") + + if not (created >= (expected - self.max_timestamp_delta) and created <= + (expected + self.max_timestamp_delta)): + raise Exception('The proof\'s created timestamp is out of range.') + + return {'valid': True} + except Exception as err: + return {"valid": False, "error": err} + + def update(self, proof: dict) -> dict: + proof['proofPurpose'] = self.term + return proof + + def match(self, proof: dict) -> bool: + return proof['proofPurpose'] == self.term diff --git a/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py new file mode 100644 index 0000000000..e8d182523f --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py @@ -0,0 +1,18 @@ +from datetime import datetime, timedelta +from typing import Awaitable +from .ControllerProofPurpose import ControllerProofPurpose + + +class PublicKeyProofPurpose(ControllerProofPurpose): + def __init__( + self, + controller: dict, + date: datetime, + max_timestamp_delta: timedelta = None): + super().__init__('publicKey', controller, date, max_timestamp_delta) + + async def update(self, proof: dict) -> Awaitable[dict]: + return proof + + async def match(self, proof: dict) -> Awaitable[boo]: + return proof.get('proofPurpose') == None diff --git a/aries_cloudagent/vc/ld_proofs/purposes/__init__.py b/aries_cloudagent/vc/ld_proofs/purposes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py new file mode 100644 index 0000000000..5c5b29f380 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py @@ -0,0 +1,108 @@ +from .LinkedDataSignature import LinkedDataSignature +from .LinkedDataProof import LinkedDataProof +from ....wallet.util import str_to_b64, b64_to_str, create_jws +import json +from pyld import jsonld +from typing import Union +from datetime import datetime +from ..KeyPair import KeyPair + + +class JwsLinkedDataSignature(LinkedDataSignature): + def __init__( + self, + signature_type: str, + algorithm: str, + key_pair: KeyPair, + verification_method: str, + *, + proof: dict = None, + date: Union[datetime, str], + ): + + super().__init__( + signature_type, verification_method, proof=proof, date=date) + + self.algorithm = algorithm + self.key_pair = key_pair + + def decode_header(self, encoded_header: str) -> dict: + header = None + try: + header = json.loads(b64_to_str(encoded_header, urlsafe=True)) + except Exception: + raise Exception('Could not parse JWS header.') + return header + + def validate_header(self, header: dict): + """ Validates the JWS header, throws if not ok """ + if not (header and isinstance(header, dict)): + raise Exception('Invalid JWS header.') + + if not (header['alg'] == self.algorithm and header['b64'] is False + and isinstance(header['crit'], list) and header['crit'].len() == 1 + and header['crit'][0] == 'b64' and header.keys().len() == 3): + raise Exception(f'Invalid JWS header params for {self.signature_type}') + + async def sign(self, verify_data: bytes, proof: dict): + + header = {'alg': self.algorithm, 'b64': False, 'crit': ['b64']} + + encoded_header = str_to_b64(json.dumps(header), urlsafe=True, pad=False) + + data = create_jws(encoded_header, verify_data) + + signature = self.key_pair.sign(data).signature + + encoded_signature = bytes_to_b64(signature, urlsafe=True, pad=False) + + proof['jws'] = str(encoded_header) + '..' + encoded_signature + + return proof + + async def verify_signature( + self, verify_data: bytes, verification_method: dict, proof: dict): + + if not (('jws' in proof) and isinstance(proof['jws'], str) and + ('.' in proof['jws'])): + raise Exception('The proof does not contain a valid "jws" property.') + + encoded_header, payload, encoded_signature = proof['jws'].split('.') + + header = self.decode_header(encoded_header) + + self.validate_header(header) + + signature = b64_to_str(encoded_signature, urlsafe=True) + data = create_jws(encoded_header, verify_data) + + return self.key_pair.verify(data, signature) + + def assert_verification_method(self, verification_method: dict): + if not jsonld.has_value(verification_method, 'type', + self.required_key_type): + raise Exception( + f'Invalid key type. The key type must be {self.required_key_type}') + + async def get_verification_method( + self, proof: dict, document_loader: callable): + verification_method = await super(LinkedDataSignature, self).\ + get_verification_method(proof, document_loader) + self.assert_verification_method(verification_method) + return verification_method + + # async def match_proof(self, proof, document, purpose, document_loader): + # if not super(LinkedDataProof, self).match_proof(proof['type']): + # return False + + # if not self.key: + # return True + + # verification_method = proof['verificationMethod'] + + # if isinstance(verification_method, dict): + # return verification_method['id'] == self.key.id + + # return verification_method == self.key.id + + diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py new file mode 100644 index 0000000000..2428f98f74 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py @@ -0,0 +1,17 @@ +"""Abstract base class for linked data proofs.""" +from abc import ABCMeta, abstractmethod + +class LinkedDataProof(metaclass=ABCMeta): + def __init__(self, signature_type: str): + self.signature_type = signature_type + + @abstractmethod + async def create_proof(self, options: dict): + pass + + @abstractmethod + async def verify_proof(self, **kwargs): + pass + + async def match_proof(self, signature_type: str) -> bool: + return signature_type == self.signature_type diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py new file mode 100644 index 0000000000..c8f99a7b0f --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -0,0 +1,166 @@ +from abc import abstractmethod, ABCMeta +from .LinkedDataProof import LinkedDataProof +from ..purposes import ProofPurpose +from pyld import jsonld +from datetime import datetime +from hashlib import sha256 +from typing import Union +from ..constants import SECURITY_CONTEXT_V2_URL + + +class LinkedDataSignature(LinkedDataProof, metaclass=ABCMeta): + def __init__( + self, + signature_type: str, + verification_method: str, + *, + proof: dict = None, + date: Union[datetime, str]): + super().__init__(signature_type) + self.verification_method = verification_method + self.proof = proof + self.date = date + + async def create_proof( + self, document: dict, purpose: ProofPurpose, document_loader: callable, + compact_proof: bool) -> dict: + proof = None + if self.proof: + # TODO remove hardcoded security context + # TODO verify if the other optional params shown in jsonld-signatures are + # required + proof = jsonld.compact( + self.proof, SECURITY_CONTEXT_V2_URL, + {'documentLoader': document_loader}) + else: + proof = {'@context': SECURITY_CONTEXT_V2_URL} + + proof['type'] = self.signature_type + + # TODO validate existance and type of date more carefully + # see: jsonld-signatures implementation + if not self.date: + self.date = datetime.now().isoformat() + + if not proof.get('created'): + proof['created'] = str(self.date.isoformat()) + + if self.verification_method: + proof[ + 'verificationMethod'] = f'{self.verification_method}#{self.verification_method[8:]}' + + proof = await self.update_proof(proof) + + proof = purpose.update(proof) + verify_data = await self.create_verify_data( + document, proof, document_loader) + + proof = await self.sign(verify_data, proof) + return proof + + async def create_verify_data( + self, document: dict, proof: dict, document_loader: dict) -> str: + c14n_proof_options = await self.canonize_proof( + proof, document_loader=document_loader) + print(c14n_proof_options) + c14n_doc = await self.canonize(document, document_loader=document_loader) + hash = sha256(c14n_proof_options.encode()) + hash.update(c14n_doc.encode()) + + # TODO verify this is the right return type + return hash.digest() + + async def canonize(self, input_, *, document_loader: callable = None): + return jsonld.normalize( + input_, { + 'algorithm': 'URDNA2015', + 'format': 'application/n-quads', + 'documentLoader': document_loader + }) + + async def canonize_proof( + self, proof: dict, *, document_loader: callable = None): + print(proof) + proof = proof.copy() + + # TODO check if these values ever exist in our use case + # del proof['jws'] + # del proof['signatureValue'] + # del proof['proofValue'] + + return await self.canonize(proof, document_loader=document_loader) + + async def update_proof(self, proof: dict): + """ + Extending classes may do more + """ + return proof + + async def verify_proof( + self, + proof: dict, + document: dict, + purpose: ProofPurpose, + document_loader: callable, + ): + try: + verify_data = await self.create_verify_data( + document, proof, document_loader=document_loader) + verification_method = await self.get_verification_method( + proof, document_loader=document_loader) + + verified = await self.verify_signature( + verify_data=verify_data, + verification_method=verification_method, + document=document, + proof=proof, + document_loader=document_loader) + + if not verified: + raise Exception('Invalid signature') + + purpose_result = await purpose.validate( + proof, + document=document, + suite=self, + verification_method=verification_method, + document_loader=document_loader) + + if not purpose_result['valid']: + raise purpose_result['error'] + + return {'verified': True, 'purpose_result': purpose_result} + except Exception as err: + return {'verified': False, 'error': err} + + async def get_verification_method( + self, proof: dict, document_loader: callable): + + verification_method = proof.get('verificationMethod') + if not verification_method: + raise Exception('No "verificationMethod" found in proof') + + framed = await jsonld.frame( + verification_method, { + '@context': SECURITY_CONTEXT_V2_URL, + '@embed': '@always', + 'id': verification_method + }, + options={'documentLoader': document_loader}) + + if not framed: + raise Exception(f'Verification method {verification_method} not found') + + if framed.get('revoked'): + raise Exception('The verification method has been revoked.') + + return framed + + @abstractmethod + def sign(self, verify_data: bytes, proof: dict): + pass + + @abstractmethod + def verify_signature( + self, verify_data: bytes, verification_method: dict, proof: dict): + pass diff --git a/aries_cloudagent/vc/ld_proofs/suites/__init__.py b/aries_cloudagent/vc/ld_proofs/suites/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/vc/vc_ld/checker.py b/aries_cloudagent/vc/vc_ld/checker.py new file mode 100644 index 0000000000..9977c57b55 --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/checker.py @@ -0,0 +1,82 @@ +from pyld.jsonld import JsonLdProcessor +import re + +from ..messaging.valid import RFC3339DateTime +from .constants import CREDENTIALS_CONTEXT_V1_URL + + +def get_id(obj): + if type(obj) is str: + return obj + + if "id" not in obj: + return + + return obj["id"] + + +def check_credential(credential: dict): + if not ( + credential["@context"] + and credential["@context"][0] == CREDENTIALS_CONTEXT_V1_URL + ): + raise Exception( + f"{CREDENTIALS_CONTEXT_V1_URL} needs to be first in the list of contexts" + ) + + if not credential["type"]: + raise Exception('"type" property is required') + + if "VerifiableCredential" not in JsonLdProcessor.get_values(credential, "type"): + raise Exception('"type" must include "VerifiableCredential"') + + if not credential["credentialSubject"]: + raise Exception('"credentialSubject" property is required') + + if not credential["issuer"]: + raise Exception('"issuer" property is required') + + if len(JsonLdProcessor.get_values(credential, "issuanceDate")) > 1: + raise Exception('"issuanceDate" property can only have one value') + + if not credential["issuanceDate"]: + raise Exception('"issuanceDate" property is required') + + if not re.match(RFC3339DateTime.PATTERN, credential["issuanceDate"]): + raise Exception( + f'"issuanceDate" must be a valid date {credential["issuanceDate"]}' + ) + + if len(JsonLdProcessor.get_values(credential, "issuer")) > 1: + raise Exception('"issuer" property can only have one value') + + if "issuer" in credential: + issuer = get_id(credential["issuer"]) + + if not issuer: + raise Exception('"issuer" id is required') + + if ":" not in issuer: + raise Exception(f'"issuer" id must be a URL: {issuer}') + + if "credentialStatus" in credential: + credential_status = credential["credentialStatus"] + + if not credential_status["id"]: + raise Exception('"credentialStatus" must include an id') + + if not credential_status["type"]: + raise Exception('"credentialStatus" must include a type') + + for evidence in JsonLdProcessor.get_values(credential, "evidence"): + evidence_id = get_id(evidence) + + if evidence_id and ":" not in evidence_id: + raise Exception(f'"evidence" id must be a URL: {evidence}') + + if "expirationDate" in credential and not re.match( + RFC3339DateTime.PATTERN, credential["issuanceDate"] + ): + raise Exception( + f'"expirationDate" must be a valid date {credential["expirationDate"]}' + ) \ No newline at end of file diff --git a/aries_cloudagent/vc/vc_ld/constants.py b/aries_cloudagent/vc/vc_ld/constants.py new file mode 100644 index 0000000000..91c9406f78 --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/constants.py @@ -0,0 +1,5 @@ +CREDENTIALS_CONTEXT_V1_URL = "https://www.w3.org/2018/credentials/v1" +SECURITY_CONTEXT_V2_URL = "https://w3id.org/security/v2" +SECURITY_PROOF_URL = "https://w3id.org/security#proof" +SECURITY_SIGNATURE_URL = "https://w3id.org/security#signature" +SECURITY_BBS_URL = "https://w3id.org/security/bbs/v1" diff --git a/aries_cloudagent/vc/vc_ld/issue.py b/aries_cloudagent/vc/vc_ld/issue.py new file mode 100644 index 0000000000..61b670ad17 --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/issue.py @@ -0,0 +1,20 @@ +from typing import Any + +from .checker import check_credential + + +def issue(credential: dict, suite: Any, *, purpose: Any = None): + # common credential checks + check_credential(credential) + + # todo: document loader + # see: https://github.com/animo/aries-cloudagent-python/issues/3 + + if not suite.verification_method: + raise Exception('"suite.verification_method" property is required') + + # todo: set purpose to CredentialIssuancePurpose if not present + # see: https://github.com/animo/aries-cloudagent-python/issues/4 + + # todo: sign credential, dependent on ld-proofs functionality + return credential \ No newline at end of file diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py new file mode 100644 index 0000000000..369dd6fd1c --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -0,0 +1,50 @@ +from typing import Any + +from .checker import check_credential + + +def verify_presentation(credential: dict, suite: Any, *, purpose: Any = None): + # common credential checks + check_credential(credential) + + # todo: document loader + # see: https://github.com/animo/aries-cloudagent-python/issues/3 + + if not suite.verification_method: + raise Exception('"suite.verification_method" property is required') + + # todo: set purpose to CredentialIssuancePurpose if not present + # see: https://github.com/animo/aries-cloudagent-python/issues/4 + + # todo: sign credential, dependent on ld-proofs functionality + return credential + + +# todo: controller required? +def verify_credential( + credential: dict, + suite: Any, + *, + purpose: Any = None, + check_status: function = None, + controller: Any = None +): + # common credential checks + check_credential(credential) + + if credential["credentialStatus"] and not check_status: + raise Exception( + 'A "check_status" function must be given to verify credentials with "credentialStatus"' + ) + + # todo: document loader + # see: https://github.com/animo/aries-cloudagent-python/issues/3 + + if not suite.verification_method: + raise Exception('"suite.verification_method" property is required') + + # todo: set purpose to CredentialIssuancePurpose if not present + # with controller as param + + # todo: verify credential, dependent on ld-proofs functionality + return credential \ No newline at end of file From 9386fbc605ab76524ce41299e4740ad351c823b9 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Wed, 3 Mar 2021 16:38:56 +0100 Subject: [PATCH 004/138] Add linked data proofs functionality Signed-off-by: Karim Stekelenburg --- aries_cloudagent/vc/ld_proofs/ProofSet.py | 126 ++++++++++++++++++ .../vc/ld_proofs/VerificationException.py | 9 ++ aries_cloudagent/vc/ld_proofs/__init__.py | 33 +++++ .../vc/ld_proofs/crypto/__init__.py | 5 + .../vc/ld_proofs/document_loader.py | 11 +- aries_cloudagent/vc/ld_proofs/ld_proofs.py | 61 +++++++++ .../purposes/AssertionProofPurpose.py | 4 + .../purposes/AuthenticationProofPurpose.py | 72 +++++----- .../purposes/ControllerProofPurpose.py | 82 ++++++------ .../purposes/IssueCredentialProofPurpose.py | 2 + .../vc/ld_proofs/purposes/ProofPurpose.py | 70 +++++----- .../purposes/PublicKeyProofPurpose.py | 3 + .../vc/ld_proofs/purposes/__init__.py | 13 ++ .../suites/JwsLinkedDataSignature.py | 1 + .../vc/ld_proofs/suites/LinkedDataProof.py | 30 +++-- .../ld_proofs/suites/LinkedDataSignature.py | 4 +- .../vc/ld_proofs/suites/__init__.py | 5 + 17 files changed, 416 insertions(+), 115 deletions(-) create mode 100644 aries_cloudagent/vc/ld_proofs/ProofSet.py create mode 100644 aries_cloudagent/vc/ld_proofs/VerificationException.py create mode 100644 aries_cloudagent/vc/ld_proofs/ld_proofs.py diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py new file mode 100644 index 0000000000..f0f8aa19a4 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -0,0 +1,126 @@ +"""Class to represent a linked data proof set.""" +import asyncio +from typing import Union, List +from pyld import jsonld + +from .suites import LinkedDataProof, LinkedDataSignature +from .purposes.ProofPurpose import ProofPurpose +from .document_loader import document_loader +from .constants import SECURITY_CONTEXT_V2_URL + + +class ProofSet: + async def add( + self, + *, + document: Union[dict, str], + suite: LinkedDataProof, + purpose: ProofPurpose, + document_loader: document_loader + ) -> dict: + + if isinstance(document, str): + document = await document_loader(document) + + input_ = document.copy() + + if "proof" in input_: + del input_["proof"] + + proof = await suite.create_proof(input_, purpose, document_loader) + + if "@context" in proof: + del proof["@context"] + + jsonld.add_value(document, "proof", proof) + + return proof + + async def verify( + self, + *, + document: Union[dict, str], + suites: List[LinkedDataProof], + purpose: ProofPurpose, + document_loader: document_loader + ): + try: + if isinstance(document, str): + document = await document_loader(document) + + proofs = await ProofSet._get_proofs(document, document_loader) + + results = ProofSet._verify( + document, suites, proofs["proof_set"], purpose, document_loader + ) + + if results.len() == 0: + raise Exception( + "Could not verify any proofs; no proofs matched the required suite and purpose" + ) + + verified = any(x["verified"] for x in results) + + if not verified: + errors = [r["error"] for r in results if r["error"]] + result = {"verified": verified, "results": results} + + if errors.len() > 0: + result["error"] = errors + + return result + + return {"verified": verified, "results": results} + except Exception as e: + return {"verified": verified, "error": e} + + @staticmethod + async def _get_proofs(document: dict, document_loader: document_loader) -> dict: + proof_set = jsonld.get_values(document, "proof") + + del document["proof"] + + if proof_set.len() == 0: + raise Exception("No matching proofs found in the given document") + + proof_set = [ + {"@context": SECURITY_CONTEXT_V2_URL, **proof} for proof in proof_set + ] + + return {"proof_set": proof_set, "document": document} + + @staticmethod + async def _verify( + document: dict, + suites: List[LinkedDataProof], + proof_set: List[dict], + purpose: ProofPurpose, + document_loader: document_loader, + ): + + result = await asyncio.gather( + *[ + purpose.match(proof["type"], document, document_loader) + for proof in proof_set + ] + ) + + matches = [x for i, x in enumerate(proof_set) if result[i]] + + if matches.len() == 0: + return [] + + out = [] + + for m in matches: + for s in suites: + if await s.match_proof(m["type"]): + out.append(s.verify_proof(m, document, purpose, document_loader)) + + results = await asyncio.gather( + *[x.verify_proof(x, document, purpose, document_loader) for x in out] + ) + + return [ + None if not r else {"proof": matches[i], **r} for r, i in enumerate(results) + ] diff --git a/aries_cloudagent/vc/ld_proofs/VerificationException.py b/aries_cloudagent/vc/ld_proofs/VerificationException.py new file mode 100644 index 0000000000..5735da0399 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/VerificationException.py @@ -0,0 +1,9 @@ +from typing import Union, List + + +class VerificationException(Exception): + """Raised when verification verification fails.""" + + def __init__(self, message: str, errors: Union[Exception, List[Exception]]): + self.errors = errors if isinstance(errors, List) else [errors] + super().__init__(self.message) diff --git a/aries_cloudagent/vc/ld_proofs/__init__.py b/aries_cloudagent/vc/ld_proofs/__init__.py index e69de29bb2..c8b37c7a4d 100644 --- a/aries_cloudagent/vc/ld_proofs/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/__init__.py @@ -0,0 +1,33 @@ +from .ld_proofs import sign, verify +from .ProofSet import ProofSet +from .purposes import ( + ProofPurpose, + ControllerProofPurpose, + AuthenticationProofPurpose, + PublicKeyProofPurpose, + AssertionProofPurpose, + IssueCredentialProofPurpose, +) +from .suites import LinkedDataProof, LinkedDataSignature, JwsLinkedDataSignature +from .crypto import Base58Encoder, KeyPair, Ed25519KeyPair +from .document_loader import DocumentLoader, did_key_document_loader + +__all__ = [ + sign, + verify, + ProofSet, + ProofPurpose, + ControllerProofPurpose, + AssertionProofPurpose, + AuthenticationProofPurpose, + PublicKeyProofPurpose, + IssueCredentialProofPurpose, + LinkedDataProof, + LinkedDataSignature, + JwsLinkedDataSignature, + Base58Encoder, + KeyPair, + Ed25519KeyPair, + DocumentLoader, + did_key_document_loader, +] diff --git a/aries_cloudagent/vc/ld_proofs/crypto/__init__.py b/aries_cloudagent/vc/ld_proofs/crypto/__init__.py index e69de29bb2..36578d8573 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/__init__.py @@ -0,0 +1,5 @@ +from .Base58Encoder import Base58Encoder +from .KeyPair import KeyPair +from .Ed25519KeyPair import Ed25519KeyPair + +__all__ = [Base58Encoder, KeyPair, Ed25519KeyPair] diff --git a/aries_cloudagent/vc/ld_proofs/document_loader.py b/aries_cloudagent/vc/ld_proofs/document_loader.py index 6454b0d279..0e7cf3774a 100644 --- a/aries_cloudagent/vc/ld_proofs/document_loader.py +++ b/aries_cloudagent/vc/ld_proofs/document_loader.py @@ -1,6 +1,8 @@ from pyld.documentloader import requests from .wallet_util import did_key_to_naked -from ...wallet.util import did_key_to_naked +from ...wallet.util import did_key_to_naked +from typing import Callable + def resolve_ed25519_did_key(did_key: str) -> dict: pub_key_base58 = did_key_to_naked(did_key) @@ -31,7 +33,7 @@ def resolve_ed25519_did_key(did_key: str) -> dict: } -def document_loader(url: str, options: dict): +def did_key_document_loader(url: str, options: dict): # NOTE: this is a hacky approach for the time being. if url.startswith("did:key:"): return resolve_ed25519_did_key(url) @@ -42,3 +44,8 @@ def document_loader(url: str, options: dict): raise Exception( "Unrecognized url format. Must start with 'did:key:', 'http://' or 'https://'" ) + + +DocumentLoader = Callable[str, dict] + +__all__ = [DocumentLoader, did_key_document_loader] diff --git a/aries_cloudagent/vc/ld_proofs/ld_proofs.py b/aries_cloudagent/vc/ld_proofs/ld_proofs.py new file mode 100644 index 0000000000..5801606606 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/ld_proofs.py @@ -0,0 +1,61 @@ +from typing import Union + +from .ProofSet import ProofSet +from .suites import LinkedDataSignature +from .document_loader import DocumentLoader +from .purposes import ProofPurpose +from pyld.jsonld import JsonLdError +from .VerificationException import VerificationException + + +async def sign( + *, + document: Union[dict, str], + suite: LinkedDataSignature, + purpose: ProofPurpose, + document_loader: DocumentLoader, +): + try: + return await ProofSet().add( + document=document, + suite=suite, + purpose=ProofPurpose, + document_loader=document_loader, + ) + + except JsonLdError as e: + if e.type == "jsonld.InvalidUrl": + raise Exception( + f'A URL "{e.details}" could not be fetched; you need to pass a DocumentLoader function that can resolve this URL, or resolve the URL before calling "sign".' + ) + except Exception as e: + raise e + + +async def verify( + *, + document: Union[dict, str], + suite: LinkedDataSignature, + purpose: ProofPurpose, + document_loader: DocumentLoader, +): + result = await ProofSet().verify( + document=document, + suite=suite, + purpose=ProofPurpose, + document_loader=document_loader, + ) + + if result["error"]: + if ( + hasattr(result["error"], "type") + and result["error"].type == "jsonld.InvalidUrl" + ): + url_err = Exception( + f'A URL "{result["error"].details}" could not be fetched; you need to pass a DocumentLoader function that can resolve this URL, or resolve the URL before calling "sign".' + ) + result["error"] = VerificationException(url_err) + else: + result["error"] = VerificationException(result["error"]) + + return result diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py index e3fb887ab1..87199703e3 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py @@ -5,3 +5,7 @@ class AssertionProofPurpose(ControllerProofPurpose): def __init__(self, date: datetime, max_timestamp_delta: timedelta = None): super().__init__('assertionMethod', date, max_timestamp_delta) + + + +__all__ = [AssertionProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py index af18f4abf4..10d2a3d808 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py @@ -4,42 +4,48 @@ class AuthenticationProofPurpose(ControllerProofPurpose): - def __init__( - self, - controller: dict, - challenge: str, - date: datetime, - domain: str = None, - max_timestamp_delta: timedelta = None): - super(ControllerProofPurpose, self).__init__( - 'authentication', controller, date, max_timestamp_delta) - self.challenge = challenge - self.domain = domain - - async def validate( - self, proof: dict, verification_method: dict, - document_loader: callable) -> Awaitable[dict]: - try: - if proof['challenge'] != self.challenge: - raise Exception( - f'The challenge is not expected; challenge={proof["challenge"]}, expected=[self.challenge]' + def __init__( + self, + controller: dict, + challenge: str, + date: datetime = None, + domain: str = None, + max_timestamp_delta: timedelta = None, + ): + super(ControllerProofPurpose, self).__init__( + "authentication", controller, date, max_timestamp_delta ) + self.challenge = challenge + self.domain = domain - if self.domain and (proof['domain'] != self.domain): - raise Exception( - f'The domain is not as expected; domain={proof["domain"]}, expected={self.domain}' - ) + async def validate( + self, proof: dict, verification_method: dict, document_loader: callable + ) -> dict: + try: + if proof["challenge"] != self.challenge: + raise Exception( + f'The challenge is not expected; challenge={proof["challenge"]}, expected=[self.challenge]' + ) + + if self.domain and (proof["domain"] != self.domain): + raise Exception( + f'The domain is not as expected; domain={proof["domain"]}, expected={self.domain}' + ) + + return await super(ControllerProofPurpose, self).validate( + proof, verification_method, document_loader + ) + except Exception as e: + return {"valid": False, "error": e} + + async def update(self, proof: dict) -> Awaitable[dict]: + proof = await super().update(proof) + proof["challenge"] = self.challenge - return await super(ControllerProofPurpose, self).validate( - proof, verification_method, document_loader) - except Exception as e: - return {'valid': False, 'eror': e} + if self.domain: + proof["domain"] = self.domain - async def update(self, proof: dict) -> Awaitable[dict]: - proof = await super().update(proof) - proof['challenge'] = self.challenge + return proof - if self.domain: - proof['domain'] = self.domain - return proof +__all__ = [AuthenticationProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py index d2ab222c9d..7fb8fb857b 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py @@ -6,41 +6,47 @@ class ControllerProofPurpose(ProofPurpose): - def __init__( - self, term: str, date: datetime, max_timestamp_delta: timedelta = None): - super().__init__(term, date, max_timestamp_delta) - - async def validate( - self, proof: dict, verification_method: str, document_loader: callable): - """ - 1. - """ - try: - result = await super(ProofPurpose, self).validate(proof) - - if not result['valid']: - raise result['error'] - - framed = jsonld.frame( - verification_method, { - '@context': SECURITY_CONTEXT_V2_URL, - '@embed': '@always', - 'id': verification_method - }, {'documentLoader': document_loader}) - - result['controller'] = framed - verificationId = verification_method['id'] - - verification_methods = jsonld.get_values(result['controller'], self.term) - result['valid'] = any( - method == verificationId for method in verification_methods) - - if not result['valid']: - raise Exception( - f"Verification method {verification_method['id']} not authorized by controller for proof purpose {self.term}" - ) - - return result - - except Exception as e: - return {'valid': False, 'error': e} + def __init__( + self, term: str, date: datetime = None, max_timestamp_delta: timedelta = None + ): + super().__init__(term, date, max_timestamp_delta) + + async def validate( + self, proof: dict, verification_method: str, document_loader: callable + ): + try: + result = await super(ProofPurpose, self).validate(proof) + + if not result["valid"]: + raise result["error"] + + framed = jsonld.frame( + verification_method, + { + "@context": SECURITY_CONTEXT_V2_URL, + "@embed": "@always", + "id": verification_method, + }, + {"documentLoader": document_loader}, + ) + + result["controller"] = framed + verification_id = verification_method["id"] + + verification_methods = jsonld.get_values(result["controller"], self.term) + result["valid"] = any( + method == verification_id for method in verification_methods + ) + + if not result["valid"]: + raise Exception( + f"Verification method {verification_method['id']} not authorized by controller for proof purpose {self.term}" + ) + + return result + + except Exception as e: + return {"valid": False, "error": e} + + +__all__ = [ControllerProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py index 2e8ea2f71f..3c2df1d71c 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py @@ -32,3 +32,5 @@ async def validate( except Exception as e: return {'valid': False, 'error': e} + +__all__ = [IssueCredentialProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py index ef1d95fcc1..3c793e88f7 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py @@ -1,34 +1,42 @@ -import datetime +from datetime import datetime, timedelta class ProofPurpose: - def __init__( - self, - term: str, - date: datetime.datetime, - max_timestamp_delta: datetime.timedelta = None): - self.term = term - self.date = date - self.max_timestamp_delta = max_timestamp_delta - - def validate(self, proof: dict) -> bool: - try: - if self.max_timestamp_delta is not None: - expected = self.date.time() - created = datetime.datetime.strptime( - proof['created'], "%Y-%m-%dT%H:%M:%SZ") - - if not (created >= (expected - self.max_timestamp_delta) and created <= - (expected + self.max_timestamp_delta)): - raise Exception('The proof\'s created timestamp is out of range.') - - return {'valid': True} - except Exception as err: - return {"valid": False, "error": err} - - def update(self, proof: dict) -> dict: - proof['proofPurpose'] = self.term - return proof - - def match(self, proof: dict) -> bool: - return proof['proofPurpose'] == self.term + def __init__( + self, term: str, date: datetime = None, max_timestamp_delta: timedelta = None + ): + self.term = term + + if not date: + date = datetime.now() + + self.date = date + self.max_timestamp_delta = max_timestamp_delta + + def validate(self, proof: dict) -> bool: + try: + if self.max_timestamp_delta is not None: + expected = self.date.time() + created = datetime.datetime.strptime( + proof["created"], "%Y-%m-%dT%H:%M:%SZ" + ) + + if not ( + created >= (expected - self.max_timestamp_delta) + and created <= (expected + self.max_timestamp_delta) + ): + raise Exception("The proof's created timestamp is out of range.") + + return {"valid": True} + except Exception as err: + return {"valid": False, "error": err} + + def update(self, proof: dict) -> dict: + proof["proofPurpose"] = self.term + return proof + + def match(self, proof: dict) -> bool: + return proof["proofPurpose"] == self.term + + +__all__ = [ProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py index e8d182523f..a2e0aff0d5 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py @@ -16,3 +16,6 @@ async def update(self, proof: dict) -> Awaitable[dict]: async def match(self, proof: dict) -> Awaitable[boo]: return proof.get('proofPurpose') == None + + +__all__ = [PublicKeyProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/purposes/__init__.py b/aries_cloudagent/vc/ld_proofs/purposes/__init__.py index e69de29bb2..4cac8d326d 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/__init__.py @@ -0,0 +1,13 @@ +from .ProofPurpose import ProofPurpose +from .ControllerProofPurpose import ControllerProofPurpose +from .AssertionProofPurpose import AssertionProofPurpose +from .AuthenticationProofPurpose import AuthenticationProofPurpose +from .PublicKeyProofPurpose import PublicKeyProofPurpose + +__all__ = [ + ProofPurpose, + ControllerProofPurpose, + AssertionProofPurpose, + AuthenticationProofPurpose, + PublicKeyProofPurpose +] diff --git a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py index 5c5b29f380..5bc62f2821 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py @@ -106,3 +106,4 @@ async def get_verification_method( # return verification_method == self.key.id +__all__ = [JwsLinkedDataSignature] diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py index 2428f98f74..a1e2e22d2e 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py @@ -1,17 +1,27 @@ """Abstract base class for linked data proofs.""" +from ..purposes.ProofPurpose import ProofPurpose +from ..document_loader import document_loader + from abc import ABCMeta, abstractmethod + class LinkedDataProof(metaclass=ABCMeta): - def __init__(self, signature_type: str): - self.signature_type = signature_type + def __init__(self, signature_type: str): + self.signature_type = signature_type + + @abstractmethod + async def create_proof( + self, *, document: dict, purpose: ProofPurpose, document_loader: document_loader + ): + pass + + @abstractmethod + async def verify_proof(self, **kwargs): + """TODO update method signature""" + pass - @abstractmethod - async def create_proof(self, options: dict): - pass + async def match_proof(self, signature_type: str) -> bool: + return signature_type == self.signature_type - @abstractmethod - async def verify_proof(self, **kwargs): - pass - async def match_proof(self, signature_type: str) -> bool: - return signature_type == self.signature_type +__all__ = [LinkedDataProof] diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py index c8f99a7b0f..5cca3db3d4 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -7,7 +7,6 @@ from typing import Union from ..constants import SECURITY_CONTEXT_V2_URL - class LinkedDataSignature(LinkedDataProof, metaclass=ABCMeta): def __init__( self, @@ -164,3 +163,6 @@ def sign(self, verify_data: bytes, proof: dict): def verify_signature( self, verify_data: bytes, verification_method: dict, proof: dict): pass + + +__all__ = [LinkedDataSignature] diff --git a/aries_cloudagent/vc/ld_proofs/suites/__init__.py b/aries_cloudagent/vc/ld_proofs/suites/__init__.py index e69de29bb2..93be0b735a 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/suites/__init__.py @@ -0,0 +1,5 @@ +from .LinkedDataProof import LinkedDataProof +from .LinkedDataSignature import LinkedDataSignature +from .JwsLinkedDataSignature import JwsLinkedDataSignature + +__all__ = [LinkedDataProof, LinkedDataSignature, JwsLinkedDataSignature] From 4cb007a5a6fda24aa4da4a3a0bf70580de052163 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Wed, 3 Mar 2021 16:40:33 +0100 Subject: [PATCH 005/138] Add linked data verifiable credential functionality Signed-off-by: Karim Stekelenburg --- aries_cloudagent/vc/vc_ld/__init__.py | 5 + aries_cloudagent/vc/vc_ld/issue.py | 29 +-- aries_cloudagent/vc/vc_ld/prove.py | 49 ++++ .../purposes/IssueCredentialProofPurpose.py | 40 ++++ aries_cloudagent/vc/vc_ld/verify.py | 212 +++++++++++++++--- 5 files changed, 290 insertions(+), 45 deletions(-) create mode 100644 aries_cloudagent/vc/vc_ld/__init__.py create mode 100644 aries_cloudagent/vc/vc_ld/prove.py create mode 100644 aries_cloudagent/vc/vc_ld/purposes/IssueCredentialProofPurpose.py diff --git a/aries_cloudagent/vc/vc_ld/__init__.py b/aries_cloudagent/vc/vc_ld/__init__.py new file mode 100644 index 0000000000..65e0143c4c --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/__init__.py @@ -0,0 +1,5 @@ +from .issue import issue +from .verify import verify, verify_credential +from .prove import create_presentation, sign_presentation + +__all__ = [issue, verify, verify_credential, create_presentation, sign_presentation] diff --git a/aries_cloudagent/vc/vc_ld/issue.py b/aries_cloudagent/vc/vc_ld/issue.py index 61b670ad17..f6bf89ff68 100644 --- a/aries_cloudagent/vc/vc_ld/issue.py +++ b/aries_cloudagent/vc/vc_ld/issue.py @@ -1,20 +1,23 @@ from typing import Any -from .checker import check_credential - -def issue(credential: dict, suite: Any, *, purpose: Any = None): - # common credential checks - check_credential(credential) +from ..ld_proofs import LinkedDataSignature, ProofPurpose, sign, document_loader +from .purposes import IssueCredentialProofPurpose +from .checker import check_credential - # todo: document loader - # see: https://github.com/animo/aries-cloudagent-python/issues/3 - if not suite.verification_method: - raise Exception('"suite.verification_method" property is required') +def issue( + credential: dict, suite: LinkedDataSignature, *, purpose: ProofPurpose = None +): + # TODO: validate credential format - # todo: set purpose to CredentialIssuancePurpose if not present - # see: https://github.com/animo/aries-cloudagent-python/issues/4 + if not purpose: + purpose = IssueCredentialProofPurpose() - # todo: sign credential, dependent on ld-proofs functionality - return credential \ No newline at end of file + signed_credential = sign( + document=credential, + suite=suite, + purpose=purpose, + document_loader=document_loader, + ) + return signed_credential diff --git a/aries_cloudagent/vc/vc_ld/prove.py b/aries_cloudagent/vc/vc_ld/prove.py new file mode 100644 index 0000000000..fab209ffb1 --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/prove.py @@ -0,0 +1,49 @@ +from ..ld_proofs import AuthenticationProofPurpose, ProofPurpose, DocumentLoader, sign +from .constants import CREDENTIALS_CONTEXT_V1_URL + + +async def create_presentation( + verifiable_credential: Union[dict, List[dict]], id_: str = None +) -> dict: + presentation = { + "@context": [CREDENTIALS_CONTEXT_V1_URL], + "type": ["VerifiablePresentation"], + } + + if isinstance(verifiable_credential, dict): + verifiable_credential = [verifiable_credential] + + # TODO loop through all credentials and validate credential structure + + presentation["verifiableCredential"] = verifiable_credential + + if id_: + presentation["id"] = id_ + + # TODO validate presentation structure + + return presentation + + +async def sign_presentation( + presentation: dict, + suite: LinkedProofSignature, + document_loader: DocumentLoader, + domain: str, + challenge: str, + purpose: ProofPurpose = None, +): + + if not purpose: + if not domain and challenge: + raise Exception( + '"domain" and "challenge" must be provided when not providing a "purpose"' + ) + purpose = AuthenticationProofPurpose(challenge=challenge, domain=domain) + + return await sign( + document=presentaton, + suite=suite, + purpose=purpose, + document_loader=document_loader, + ) diff --git a/aries_cloudagent/vc/vc_ld/purposes/IssueCredentialProofPurpose.py b/aries_cloudagent/vc/vc_ld/purposes/IssueCredentialProofPurpose.py new file mode 100644 index 0000000000..e6151fdc91 --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/purposes/IssueCredentialProofPurpose.py @@ -0,0 +1,40 @@ +from .AssertionProofPurpose import AssertionProofPurpose +from datetime import datetime +from ..suites import LinkedDataSignature +from pyld import jsonld + + +class IssueCredentialProofPurpose(AssertionProofPurpose): + def __init__(self, date: datetime, max_timestamp_delta: None): + super().__init__(date, max_timestamp_delta) + + async def validate( + self, + proof: dict, + document: dict, + suite: LinkedDataSignature, + verification_method: str, + document_loader: callable, + ): + try: + result = super().validate(proof, verification_method, document_loader) + + if not result["valid"]: + raise result["error"] + + issuer = jsonld.get_values( + document, "https://www.w3.org/2018/credentials#issuer" + ) + + if not issuer or issuer.len() == 0: + raise Exception("Credential issuer is required.") + + if result["controller"]["id"] != issuer[0]["id"]: + raise Exception( + "Credential issuer must match the verification method controller." + ) + + return {"valid": True} + + except Exception as e: + return {"valid": False, "error": e} diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index 369dd6fd1c..a0099f9b1e 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -1,50 +1,198 @@ -from typing import Any +from typing import Any, Awaitable, Callable, Mapping +import asyncio +from pyld import jsonld +from ..ld_proofs import ( + DocumentLoader, + LinkedDataSignature, + ProofPurpose, + AuthenticationProofPurpose, + verify as ld_proofs_verify, +) from .checker import check_credential +from .purposes import IssueCredentialProofPurpose -def verify_presentation(credential: dict, suite: Any, *, purpose: Any = None): - # common credential checks - check_credential(credential) +async def _verify_credential( + credential: dict, + controller: dict, + document_loader: DocumentLoader, + suite: LinkedDataSignature, + purpose: ProofPurpose = None, + check_status: Callable = None, +) -> Awaitable(dict): + # TODO: validate credential structure + + if credential["credentialStatus"] and not check_status: + raise Exception( + 'A "check_status function must be provided to verify credentials with "credentialStatus" set.' + ) - # todo: document loader - # see: https://github.com/animo/aries-cloudagent-python/issues/3 + if not purpose: + purpose = IssueCredentialProofPurpose() - if not suite.verification_method: - raise Exception('"suite.verification_method" property is required') + result = await ld_proofs_verify( + document=credential, + suite=suite, + purpose=purpose, + document_loader=document_loader, + ) - # todo: set purpose to CredentialIssuancePurpose if not present - # see: https://github.com/animo/aries-cloudagent-python/issues/4 + if not result["verified"]: + return result - # todo: sign credential, dependent on ld-proofs functionality - return credential + if credential["credentialStatus"]: + # CHECK make sure this is how check_status should be called + result["credentialStatus"] = await check_status(credential) + if not result["statusResult"]["verified"]: + result["verified"] = False -# todo: controller required? -def verify_credential( + return result + + +async def verify_credential( credential: dict, - suite: Any, - *, - purpose: Any = None, - check_status: function = None, - controller: Any = None -): - # common credential checks - check_credential(credential) + controller: dict, + document_loader: DocumentLoader, + suite: LinkedDataSignature, + purpose: ProofPurpose = None, + check_status: Callable = None, +) -> dict: + try: + return await _verify_credential( + credential, controller, document_loader, suite, purpose, check_status + ) + except Exception as e: + return { + "verified": False, + "results": [{"credential": credential, "verified": False, "error": e}], + "error": e, + } - if credential["credentialStatus"] and not check_status: + +async def _verify_presentation( + challenge: str, + presentation: dict = None, + purpose: LinkedDataSignature = None, + unsigned_presentation: dict = None, + suite_map: Mapping[str, LinkedDataSignature] = None, + suite: LinkedDataSignature = None, + controller: dict = None, + domain: str = None, + document_loader: DocumentLoader = None, +): + if presentation and unsigned_presentation: raise Exception( - 'A "check_status" function must be given to verify credentials with "credentialStatus"' + 'Either "presentation" or "unsigned_presentation" must be present, not both.' + ) + + if not purpose: + purpose = AuthenticationProofPurpose(controller, challenge, domain=domain) + + vp, presentation_result = None, None + + if presentation: + # TODO validate presentation structure here + + vp = presentation + + if "proof" not in vp: + raise Exception('presentation must contain "proof"') + + if not purpose and not challenge: + raise Exception( + 'A "challenge" param is required for AuthenticationProofPurpose.' + ) + + suite = suite_map[presentation["proof"]["type"]]() + + presentation_result = await ld_proofs_verify( + document=presentation, + suite=suite, + purpose=purpose, + document_loader=document_loader, ) - # todo: document loader - # see: https://github.com/animo/aries-cloudagent-python/issues/3 + if unsigned_presentation: + # TODO check presentation here + vp = unsigned_presentation + + if vp["proof"]: + raise Exception('"unsigned_presentation" must not contain "proof"') + + credential_results = None + verified = True + + credentials = jsonld.get_values(vp, "verifiableCredential") + + def v(credential: dict): + if suite_map: + suite = suite_map[credential["proof"]["type"]]() + return verify_credential(credential, suite, purpose) + + credential_results = asyncio.gather(*[v(x) for x in credentials]) + + def d(cred: dict, index: int): + cred["credentialId"] = credentials[index]["id"] + return cred - if not suite.verification_method: - raise Exception('"suite.verification_method" property is required') + credential_results = [d(x, i) for x, i in enumerate(credential_results)] + + verified = all([x["verified"] for x in credential_results]) + + if unsigned_presentation: + return { + "verified": verified, + "results": [vp], + "credential_results": credential_results, + } + + return { + "presentation_result": presentation_result, + "verified": verified and presentation_result["verified"], + "credential_results": credential_results, + "error": presentation_result["error"], + } + + +async def verify( + challenge: str, + presentation: dict = None, + purpose: LinkedDataSignature = None, + unsigned_presentation: dict = None, + suite_map: Mapping[str, LinkedDataSignature] = None, + suite: LinkedDataSignature = None, + controller: dict = None, + domain: str = None, + document_loader: DocumentLoader = None, +): + + try: + if not presentation and not unsigned_presentation: + raise TypeError( + 'A "presentation" or "unsignedPresentation" property is required for verifying.' + ) + + return await _verify_presentation( + presentation=presentation, + unsigned_presentation=unsigned_presentation, + challenge=challenge, + purpose=purpose, + suite=suite, + suite_map=suite_map, + controller=controller, + domain=domain, + document_loader=document_loader, + ) + except Exception as e: + return { + "verified": False, + "results": [ + {"presentation": presentation, "verified": False, "error": error} + ], + "error": error, + } - # todo: set purpose to CredentialIssuancePurpose if not present - # with controller as param - # todo: verify credential, dependent on ld-proofs functionality - return credential \ No newline at end of file +__all__ = [verify, verify_credential] From 5e3e25045619348f94f5866695b279edf6607037 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Wed, 3 Mar 2021 17:23:24 +0100 Subject: [PATCH 006/138] add Ed25519Signature2018 signature suite Signed-off-by: Karim Stekelenburg --- .../ld_proofs/suites/Ed25519Signature2018.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py diff --git a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py new file mode 100644 index 0000000000..a65bc1d730 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py @@ -0,0 +1,20 @@ +from ..crypto import Ed25519KeyPair +from .JwsLinkedDataSignature import JwsLinkedDataSignature + + +class Ed25519Signature2018(JwsLinkedDataSignature): + def __init__( + self, + verification_method: str, + proof: dict = None, + date: Union[datetime, str] = None, + ): + super().__init__( + signature_type="Ed25519Signature", + algorithm="EdDSA", + key_pair=Ed25519KeyPair, + verification_method=verification_method, + proof=proof, + date=date, + ) + self.required_key_type = "Ed25519VerificationKey2018" From 23a69775b32acaee23c0fb3b665321cc07e5cf7b Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Fri, 5 Mar 2021 11:22:49 +0100 Subject: [PATCH 007/138] add ld_proof record Signed-off-by: Timo Glastra --- .../issue_credential/v2_0/formats/handler.py | 29 ++-- .../issue_credential/v2_0/formats/indy.py | 44 ++++- .../issue_credential/v2_0/formats/ld_proof.py | 147 +++++++++++++++++ .../v2_0/formats/tests/__init__.py | 0 .../v2_0/formats/tests/test_indy.py | 121 ++++++++++++++ .../issue_credential/v2_0/manager.py | 147 ++++++++++------- .../v2_0/messages/cred_format.py | 10 +- .../v2_0/messages/tests/test_cred_format.py | 7 +- .../v2_0/messages/tests/test_cred_request.py | 20 +-- .../v2_0/models/cred_ex_record.py | 6 - .../models/detail/{dif.py => ld_proof.py} | 48 +++--- .../v2_0/models/detail/tests/test_dif.py | 30 ---- .../v2_0/models/detail/tests/test_ld_proof.py | 30 ++++ .../protocols/issue_credential/v2_0/routes.py | 156 +++++++++++++----- .../v2_0/tests/test_manager.py | 25 +-- .../v2_0/tests/test_routes.py | 24 +-- 16 files changed, 617 insertions(+), 227 deletions(-) create mode 100644 aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py create mode 100644 aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/__init__.py create mode 100644 aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/test_indy.py rename aries_cloudagent/protocols/issue_credential/v2_0/models/detail/{dif.py => ld_proof.py} (55%) delete mode 100644 aries_cloudagent/protocols/issue_credential/v2_0/models/detail/tests/test_dif.py create mode 100644 aries_cloudagent/protocols/issue_credential/v2_0/models/detail/tests/test_ld_proof.py diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py index e12f7b9d7a..e92daa027c 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py @@ -11,10 +11,10 @@ from .....storage.error import StorageNotFoundError from ..messages.cred_format import V20CredFormat -from ..messages.cred_proposal import V20CredProposal from ..messages.cred_offer import V20CredOffer +from ..messages.cred_request import V20CredRequest from ..models.detail.indy import V20CredExRecordIndy -from ..models.detail.dif import V20CredExRecordDIF +from ..models.detail.ld_proof import V20CredExRecordLDProof from ..models.cred_ex_record import V20CredExRecord LOGGER = logging.getLogger(__name__) @@ -48,7 +48,7 @@ def profile(self) -> Profile: async def get_detail_record( self, cred_ex_id: str - ) -> Union[V20CredExRecordIndy, V20CredExRecordDIF]: + ) -> Union[V20CredExRecordIndy, V20CredExRecordLDProof]: """Retrieve credential exchange detail record by cred_ex_id.""" async with self.profile.session() as session: @@ -65,30 +65,33 @@ def validate_filter(cls, data: Mapping): @abstractmethod async def create_proposal( - self, filter: Mapping[str, str] + self, cred_ex_record: V20CredExRecord, filter: Mapping = None ) -> Tuple[V20CredFormat, AttachDecorator]: """""" @abstractmethod - async def receive_offer( - self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer - ): + async def create_offer( + self, cred_ex_record: V20CredExRecord + ) -> Tuple[V20CredFormat, AttachDecorator]: """""" @abstractmethod - async def create_offer( - self, cred_proposal_message: V20CredProposal - ) -> Tuple[V20CredFormat, AttachDecorator]: + async def receive_offer( + self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer + ): """""" @abstractmethod async def create_request( - self, - cred_ex_record: V20CredExRecord, - holder_did: str, + self, cred_ex_record: V20CredExRecord, holder_did: str = None ): """""" + async def receive_request( + self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest + ): + """Format specific handler for receiving credential request message""" + @abstractmethod async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = 5): """""" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py index 8d4305b7b6..9f81c93e8f 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py @@ -59,7 +59,7 @@ async def _match_sent_cred_def_id(self, tag_query: Mapping[str, str]) -> str: return max(found, key=lambda r: int(r.tags["epoch"])).tags["cred_def_id"] async def create_proposal( - self, filter: Mapping[str, str] + self, cred_ex_record: V20CredExRecord, filter: Mapping[str, str] ) -> Tuple[V20CredFormat, AttachDecorator]: id = uuid.uuid4() @@ -98,12 +98,17 @@ async def receive_offer( await cred_ex_record.save(session, reason="receive v2.0 credential offer") async def create_offer( - self, cred_proposal_message: V20CredProposal + self, cred_ex_record: V20CredExRecord ) -> Tuple[V20CredFormat, AttachDecorator]: issuer = self.profile.inject(IndyIssuer) ledger = self.profile.inject(BaseLedger) cache = self.profile.inject(BaseCache, required=False) + # TODO: can't we move cred_def_id and schema_id to detail record? + cred_proposal_message = V20CredProposal.deserialize( + cred_ex_record.cred_proposal + ) + cred_def_id = await self._match_sent_cred_def_id( cred_proposal_message.attachment(self.format) ) @@ -144,6 +149,35 @@ async def _create(): AttachDecorator.data_base64(cred_offer, ident=id), ) + async def receive_offer( + self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer + ): + # TODO: Why move from offer to proposal? + offer = cred_offer_message.offer(self.format) + schema_id = offer["schema_id"] + cred_def_id = offer["cred_def_id"] + + # TODO: this could overwrite proposal for other formats. We should append or something + cred_proposal_ser = V20CredProposal( + comment=cred_offer_message.comment, + credential_preview=cred_offer_message.credential_preview, + formats=[V20CredFormat(attach_id="0", format_=self.format)], + filters_attach=[ + AttachDecorator.data_base64( + { + "schema_id": schema_id, + "cred_def_id": cred_def_id, + }, + ident="0", + ) + ], + ).serialize() # proposal houses filters, preview (possibly with MIME types) + + async with self.profile.session() as session: + # TODO: we should probably not modify cred_ex_record here + cred_ex_record.cred_proposal = cred_proposal_ser + await cred_ex_record.save(session, reason="receive v2.0 credential offer") + async def create_request( self, cred_ex_record: V20CredExRecord, @@ -201,6 +235,11 @@ async def _create(): AttachDecorator.data_base64(cred_req_result["request"], ident=id), ) + async def receive_request( + self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest + ): + assert cred_ex_record.cred_offer + async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = 5): cred_offer = V20CredOffer.deserialize(cred_ex_record.cred_offer).attachment( self.format @@ -392,6 +431,7 @@ async def store_credential(self, cred_ex_record: V20CredExRecord, cred_id: str): rev_reg_def=rev_reg_def, ) + # TODO: doesn't work with multiple attachments cred_ex_record.cred_id_stored = cred_id_stored detail_record.rev_reg_id = cred.get("rev_reg_id", None) detail_record.cred_rev_id = cred.get("cred_rev_id", None) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py new file mode 100644 index 0000000000..421c28d6af --- /dev/null +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py @@ -0,0 +1,147 @@ +"""V2.0 indy issue-credential cred format.""" + +import logging + +import uuid +import json +from typing import Mapping, Tuple + + +from .....messaging.decorators.attach_decorator import AttachDecorator + +from .....wallet.error import WalletNotFoundError +from .....wallet.base import BaseWallet +from .....wallet.util import did_key_to_naked +from ..messages.cred_format import V20CredFormat +from ..messages.cred_offer import V20CredOffer +from ..messages.cred_request import V20CredRequest +from ..models.cred_ex_record import V20CredExRecord +from ..formats.handler import V20CredFormatError, V20CredFormatHandler + +LOGGER = logging.getLogger(__name__) + +# TODO: move to vc util +def get_id(obj) -> str: + if type(obj) is str: + return obj + + if "id" not in obj: + return + + return obj["id"] + + +class LDProofCredFormatHandler(V20CredFormatHandler): + + format = V20CredFormat.Format.LD_PROOF + + @classmethod + def validate_filter(cls, data: Mapping): + # TODO: validate LDProof credential filter + pass + + async def create_proposal( + self, cred_ex_record: V20CredExRecord, filter: Mapping[str, str] + ) -> Tuple[V20CredFormat, AttachDecorator]: + id = uuid.uuid4() + + return ( + V20CredFormat(attach_id=id, format_=self.format), + AttachDecorator.data_base64(filter, ident=id), + ) + + async def receive_offer( + self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer + ): + pass + + # TODO: add filter + async def create_offer( + self, cred_ex_record: V20CredExRecord, filter: Mapping = None + ) -> Tuple[V20CredFormat, AttachDecorator]: + wallet = self.profile.inject(BaseWallet) + + # TODO: + # - Check if first @context is "https://www.w3.org/2018/credentials/v1" + # - Check if first type is "VerifiableCredential" + # - Check if all fields in credentialSubject are present in context + # - Check if all required fields are present (according to RFC). Or is the API going to do this? + # - Other checks (credentialStatus, credentialSchema, etc...) + + try: + # Check if issuer is something we can issue with + issuer_did = get_id(filter["issuer"]) + assert issuer_did.startswith(issuer_did, "did:key") + verkey = did_key_to_naked(issuer_did) + did_info = await wallet.get_local_did_for_verkey(verkey) + + # Check if all proposed proofTypes are supported + supported_proof_types = {"Ed25519VerificationKey2018"} + proof_types = set(filter["proofTypes"]) + if not set(proof_types).issubset(supported_proof_types): + raise V20CredFormatError( + f"Unsupported proof type(s): {proof_types - supported_proof_types}." + ) + + except WalletNotFoundError: + raise V20CredFormatError( + f"Issuer did {did_info} not found. Unable to issue credential with this DID." + ) + + id = uuid.uuid4() + return ( + V20CredFormat(attach_id=id, format_=self.format), + AttachDecorator.data_base64(filter, ident=id), + ) + + async def create_request( + self, + cred_ex_record: V20CredExRecord, + # TODO subject id? + holder_did: str = None, + ): + if cred_ex_record.cred_offer: + cred_detail = V20CredOffer.deserialize(cred_ex_record.cred_offer).offer( + self.format + ) + + if not cred_detail["credentialSubject"]["id"] and holder_did: + # TODO: holder_did is not always did, must transform first + cred_detail["credentialSubject"]["id"] = holder_did + else: + # TODO: start from request + cred_detail = None + + id = uuid.uuid4() + return ( + V20CredFormat(attach_id=id, format_=self.format), + AttachDecorator.data_base64(cred_detail, ident=id), + ) + + async def receive_request( + self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest + ): + pass + + async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = 5): + if cred_ex_record.cred_offer: + cred_detail = V20CredOffer.deserialize(cred_ex_record.cred_offer).offer( + self.format + ) + else: + cred_detail = V20CredRequest.deserialize( + cred_ex_record.cred_request + ).cred_request(self.format) + + # TODO: create credential + # TODO: issue with public did:sov + vc = cred_detail + + id = uuid.uuid4() + return ( + V20CredFormat(attach_id=id, format_=self.format), + AttachDecorator.data_base64(json.loads(vc), ident=id), + ) + + async def store_credential(self, cred_ex_record: V20CredExRecord, cred_id: str): + pass \ No newline at end of file diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/__init__.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/test_indy.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/test_indy.py new file mode 100644 index 0000000000..e8ed1fe3f2 --- /dev/null +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/test_indy.py @@ -0,0 +1,121 @@ +import asyncio +import json + +from asynctest import TestCase as AsyncTestCase +from asynctest import mock as async_mock +from copy import deepcopy +from time import time + +from ......core.in_memory import InMemoryProfile +from ......ledger.base import BaseLedger + +from .. import indy as test_module +from ..indy import IndyCredFormatHandler +from ...messages.cred_format import V20CredFormat +from ...models.detail.indy import V20CredExRecordIndy + + +TEST_DID = "LjgpST2rjsoxYegQDRm7EL" +SCHEMA_NAME = "bc-reg" +SCHEMA_TXN = 12 +SCHEMA_ID = f"{TEST_DID}:2:{SCHEMA_NAME}:1.0" +SCHEMA = { + "ver": "1.0", + "id": SCHEMA_ID, + "name": SCHEMA_NAME, + "version": "1.0", + "attrNames": ["legalName", "jurisdictionId", "incorporationDate"], + "seqNo": SCHEMA_TXN, +} +CRED_DEF_ID = f"{TEST_DID}:3:CL:12:tag1" +CRED_DEF = { + "ver": "1.0", + "id": CRED_DEF_ID, + "schemaId": SCHEMA_TXN, + "type": "CL", + "tag": "tag1", + "value": { + "primary": { + "n": "...", + "s": "...", + "r": { + "master_secret": "...", + "legalName": "...", + "jurisdictionId": "...", + "incorporationDate": "...", + }, + "rctxt": "...", + "z": "...", + }, + "revocation": { + "g": "1 ...", + "g_dash": "1 ...", + "h": "1 ...", + "h0": "1 ...", + "h1": "1 ...", + "h2": "1 ...", + "htilde": "1 ...", + "h_cap": "1 ...", + "u": "1 ...", + "pk": "1 ...", + "y": "1 ...", + }, + }, +} +REV_REG_DEF_TYPE = "CL_ACCUM" +REV_REG_ID = f"{TEST_DID}:4:{CRED_DEF_ID}:{REV_REG_DEF_TYPE}:tag1" +TAILS_DIR = "/tmp/indy/revocation/tails_files" +TAILS_HASH = "8UW1Sz5cqoUnK9hqQk7nvtKK65t7Chu3ui866J23sFyJ" +TAILS_LOCAL = f"{TAILS_DIR}/{TAILS_HASH}" +REV_REG_DEF = { + "ver": "1.0", + "id": REV_REG_ID, + "revocDefType": "CL_ACCUM", + "tag": "tag1", + "credDefId": CRED_DEF_ID, + "value": { + "issuanceType": "ISSUANCE_ON_DEMAND", + "maxCredNum": 5, + "publicKeys": {"accumKey": {"z": "1 ..."}}, + "tailsHash": TAILS_HASH, + "tailsLocation": TAILS_LOCAL, + }, +} + + +class TestV20IndyCredFormatHandler(AsyncTestCase): + async def setUp(self): + self.session = InMemoryProfile.test_session() + self.profile = self.session.profile + self.context = self.profile.context + setattr( + self.profile, "session", async_mock.MagicMock(return_value=self.session) + ) + + Ledger = async_mock.MagicMock() + self.ledger = Ledger() + self.ledger.get_schema = async_mock.CoroutineMock(return_value=SCHEMA) + self.ledger.get_credential_definition = async_mock.CoroutineMock( + return_value=CRED_DEF + ) + self.ledger.get_revoc_reg_def = async_mock.CoroutineMock( + return_value=REV_REG_DEF + ) + self.ledger.__aenter__ = async_mock.CoroutineMock(return_value=self.ledger) + self.ledger.credential_definition_id2schema_id = async_mock.CoroutineMock( + return_value=SCHEMA_ID + ) + self.context.injector.bind_instance(BaseLedger, self.ledger) + + self.handler = IndyCredFormatHandler(self.profile) + assert self.handler.profile + + async def test_get_detail_record(self): + cred_ex_id = "dummy" + detail_indy = V20CredExRecordIndy( + cred_ex_id=cred_ex_id, + rev_reg_id="rr-id", + cred_rev_id="0", + ) + await detail_indy.save(self.session) + assert await self.handler.get_detail_record(cred_ex_id) == detail_indy \ No newline at end of file diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py index b3ab36c13d..4b5de1f213 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py @@ -108,9 +108,20 @@ async def create_proposal( """ + if auto_remove is None: + auto_remove = not self._profile.settings.get("preserve_exchange_records") + cred_ex_record = V20CredExRecord( + connection_id=connection_id, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_HOLDER, + state=V20CredExRecord.STATE_PROPOSAL_SENT, + auto_remove=auto_remove, + trace=trace, + ) + # Format specific create_proposal handler formats = [ - await fmt.handler(self._profile).create_proposal(filter) + await fmt.handler(self._profile).create_proposal(cred_ex_record, filter) for (fmt, filter) in fmt2filter.items() ] @@ -120,20 +131,12 @@ async def create_proposal( formats=[format for (format, _) in formats], filters_attach=[attach for (_, attach) in formats], ) + + cred_ex_record.thread_id = (cred_proposal_message._thread_id,) + cred_ex_record.cred_proposal = cred_proposal_message.serialize() + cred_proposal_message.assign_trace_decorator(self._profile.settings, trace) - if auto_remove is None: - auto_remove = not self._profile.settings.get("preserve_exchange_records") - cred_ex_record = V20CredExRecord( - connection_id=connection_id, - thread_id=cred_proposal_message._thread_id, - initiator=V20CredExRecord.INITIATOR_SELF, - role=V20CredExRecord.ROLE_HOLDER, - state=V20CredExRecord.STATE_PROPOSAL_SENT, - cred_proposal=cred_proposal_message.serialize(), - auto_remove=auto_remove, - trace=trace, - ) async with self._profile.session() as session: await cred_ex_record.save( session, @@ -208,7 +211,7 @@ async def create_offer( formats = [ await V20CredFormat.Format.get(p.format) .handler(self.profile) - .create_offer(cred_proposal_message) + .create_offer(cred_ex_record) for p in cred_proposal_message.formats ] @@ -275,20 +278,17 @@ async def receive_offer( trace=(cred_offer_message._trace is not None), ) + # Format specific receive_offer handler + for cred_format in cred_offer_message.formats: + await V20CredFormat.Format.get(cred_format.format).handler( + self.profile + ).receive_offer(cred_ex_record, cred_offer_message) + cred_ex_record.cred_offer = cred_offer_message.serialize() cred_ex_record.state = V20CredExRecord.STATE_OFFER_RECEIVED await cred_ex_record.save(session, reason="receive v2.0 credential offer") - # TODO: should be called before the code above - # Format specific receive_offer handler - formats = [ - await V20CredFormat.Format.get(p.format) - .handler(self.profile) - .receive_offer(cred_ex_record, cred_offer_message) - for p in cred_offer_message.formats - ] - return cred_ex_record async def create_request( @@ -306,35 +306,36 @@ async def create_request( A tuple (credential exchange record, credential request message) """ - # TODO: allow to start from request message - # TODO: limit the number of formats in the final credential? pick one? - if cred_ex_record.state != V20CredExRecord.STATE_OFFER_RECEIVED: - raise V20CredManagerError( # indy-ism: must change for DIF - f"Credential exchange {cred_ex_record.cred_ex_id} " - f"in {cred_ex_record.state} state " - f"(must be {V20CredExRecord.STATE_OFFER_RECEIVED})" - ) + # react to credential offer + if cred_ex_record.state: + if cred_ex_record.state != V20CredExRecord.STATE_OFFER_RECEIVED: + raise V20CredManagerError( + f"Credential exchange {cred_ex_record.cred_ex_id} " + f"in {cred_ex_record.state} state " + f"(must be {V20CredExRecord.STATE_OFFER_RECEIVED})" + ) - if cred_ex_record.cred_request: - raise V20CredManagerError( - "create_request() called multiple times for " - f"v2.0 credential exchange {cred_ex_record.cred_ex_id}" - ) + cred_offer = V20CredOffer.deserialize(cred_ex_record.cred_offer) - cred_offer_message = V20CredOffer.deserialize(cred_ex_record.cred_offer) + formats = cred_offer.formats + # start with request (not allowed for indy -> checked in indy format handler) + else: + # TODO: where to get data from if starting from request. proposal? + cred_proposal = V20CredOffer.deserialize(cred_ex_record.cred_proposal) + formats = cred_proposal.formats # Format specific create_request handler - formats = [ - await V20CredFormat.Format.get(p.format) - .handler(self.profile) + request_formats = [ + await V20CredFormat.Format.get(p.format).handler(self.profile) + # TODO: retrieve holder did from create_request handler? .create_request(cred_ex_record, holder_did) - for p in cred_offer_message.formats + for p in formats ] cred_request_message = V20CredRequest( comment=comment, - formats=[format for (format, _) in formats], - requests_attach=[attach for (_, attach) in formats], + formats=[format for (format, _) in request_formats], + requests_attach=[attach for (_, attach) in request_formats], ) cred_request_message._thread = {"thid": cred_ex_record.thread_id} @@ -342,7 +343,10 @@ async def create_request( self._profile.settings, cred_ex_record.trace ) + cred_ex_record.thread_id = cred_request_message._thread_id cred_ex_record.state = V20CredExRecord.STATE_REQUEST_SENT + cred_ex_record.cred_request = cred_request_message.serialize() + async with self._profile.session() as session: await cred_ex_record.save(session, reason="create v2.0 credential request") @@ -359,19 +363,36 @@ async def receive_request( connection_id: connection identifier Returns: - credential exchange record, retrieved and updated + credential exchange record, updated """ - assert len(cred_request_message.requests_attach or []) == 1 - async with self._profile.session() as session: - cred_ex_record = await ( - V20CredExRecord.retrieve_by_conn_and_thread( - session, connection_id, cred_request_message._thread_id + try: + cred_ex_record = await ( + V20CredExRecord.retrieve_by_conn_and_thread( + session, connection_id, cred_request_message._thread_id + ) ) - ) + except StorageNotFoundError: # holder sent this request free of any offer + cred_ex_record = V20CredExRecord( + connection_id=connection_id, + thread_id=cred_request_message._thread_id, + initiator=V20CredExRecord.INITIATOR_EXTERNAL, + role=V20CredExRecord.ROLE_ISSUER, + auto_remove=not self._profile.settings.get( + "preserve_exchange_records" + ), + trace=(cred_request_message._trace is not None), + ) + + for cred_format in cred_request_message.formats: + await V20CredFormat.Format.get(cred_format.format).handler( + self.profile + ).receive_request(cred_ex_record, cred_request_message) + cred_ex_record.cred_request = cred_request_message.serialize() cred_ex_record.state = V20CredExRecord.STATE_REQUEST_RECEIVED + await cred_ex_record.save(session, reason="receive v2.0 credential request") return cred_ex_record @@ -407,23 +428,31 @@ async def issue_credential( f"cred ex record {cred_ex_record.cred_ex_id}" ) - cred_offer_message = V20CredOffer.deserialize(cred_ex_record.cred_offer) - cred_request_message = V20CredRequest.deserialize(cred_ex_record.cred_request) - replacement_id = cred_offer_message.replacement_id + # TODO: replacement id for jsonld start from request + replacement_id = None + formats = V20CredRequest.deserialize(cred_ex_record.cred_request).formats + + if cred_ex_record.cred_offer: + cred_offer_message = V20CredOffer.deserialize(cred_ex_record.cred_offer) + replacement_id = cred_offer_message.replacement_id + + # TODO: How do we verify if requests matches offer? + # Use offer formats if offer is sent + formats = cred_offer_message.formats # Format specific issue_credential handler - formats = [ + issue_formats = [ await V20CredFormat.Format.get(p.format) .handler(self.profile) .issue_credential(cred_ex_record) - for p in cred_request_message.formats + for p in formats ] cred_issue_message = V20CredIssue( replacement_id=replacement_id, comment=comment, - formats=[format for (format, _) in formats], - credentials_attach=[attach for (_, attach) in formats], + formats=[format for (format, _) in issue_formats], + credentials_attach=[attach for (_, attach) in issue_formats], ) cred_ex_record.state = V20CredExRecord.STATE_ISSUED @@ -491,8 +520,8 @@ async def store_credential( ) # Format specific store_credential handler - for p in V20CredIssue.deserialize(cred_ex_record.cred_issue).formats: - await V20CredFormat.Format.get(p.format).handler( + for cred_format in V20CredIssue.deserialize(cred_ex_record.cred_issue).formats: + await V20CredFormat.Format.get(cred_format.format).handler( self.profile ).store_credential(cred_ex_record, cred_id) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py index f1e490d516..44c8b2b04d 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py @@ -14,7 +14,7 @@ from .....messaging.decorators.attach_decorator import AttachDecorator from ..message_types import PROTOCOL_PACKAGE from ..models.detail.indy import V20CredExRecordIndy -from ..models.detail.dif import V20CredExRecordDIF +from ..models.detail.ld_proof import V20CredExRecordLDProof from typing import TYPE_CHECKING # TODO: remove @@ -41,11 +41,11 @@ class Format(Enum): V20CredExRecordIndy, f"{PROTOCOL_PACKAGE}.formats.indy.IndyCredFormatHandler", ) - DIF = FormatSpec( + LD_PROOF = FormatSpec( "dif/credential-manifest@v1.0", - ["dif", "w3c", "jsonld"], - V20CredExRecordDIF, - f"{PROTOCOL_PACKAGE}.formats.indy.IndyCredFormatHandler", + ["ldproof", "jsonld"], + V20CredExRecordLDProof, + f"{PROTOCOL_PACKAGE}.formats.ld_proof.LDProofCredFormatHandler", ) @classmethod diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_format.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_format.py index 47cec9708a..4c68e8afa5 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_format.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_format.py @@ -41,7 +41,10 @@ def test_get_completeness(self): assert ( V20CredFormat.Format.get("HL/INDY").detail.__name__ == "V20CredExRecordIndy" ) - assert V20CredFormat.Format.get("dif").detail.__name__ == "V20CredExRecordDIF" + assert ( + V20CredFormat.Format.get("ldproof").detail.__name__ + == "V20CredExRecordLDProof" + ) def test_get_attachment_data(self): assert ( @@ -69,7 +72,7 @@ def test_get_attachment_data(self): ) assert ( - V20CredFormat.Format.DIF.get_attachment_data( + V20CredFormat.Format.LD_PROOF.get_attachment_data( formats=[ V20CredFormat(attach_id="abc", format_=V20CredFormat.Format.INDY) ], diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_request.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_request.py index 4ad556e50d..379c128372 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_request.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_request.py @@ -34,7 +34,7 @@ class TestV20CredRequest(AsyncTestCase): }, } - dif_cred_req = { + ld_proof_cred_req = { "credential-manifest": { "issuer": "did:example:123", "credential": { @@ -119,8 +119,8 @@ async def test_make_model(self): format_=V20CredFormat.Format.INDY, ), V20CredFormat( - attach_id="dif-json", - format_=V20CredFormat.Format.DIF, + attach_id="ld-proof-json", + format_=V20CredFormat.Format.LD_PROOF, ), ], requests_attach=[ @@ -128,7 +128,7 @@ async def test_make_model(self): ident="indy", mapping=TestV20CredRequest.indy_cred_req ), AttachDecorator.data_json( - ident="dif-json", mapping=TestV20CredRequest.dif_cred_req + ident="ld-proof-json", mapping=TestV20CredRequest.ld_proof_cred_req ), ], ) @@ -138,8 +138,8 @@ async def test_make_model(self): == TestV20CredRequest.indy_cred_req ) assert ( - cred_request.attachment(V20CredFormat.Format.DIF) - == TestV20CredRequest.dif_cred_req + cred_request.attachment(V20CredFormat.Format.LD_PROOF) + == TestV20CredRequest.ld_proof_cred_req ) data = cred_request.serialize() model_instance = V20CredRequest.deserialize(data) @@ -153,8 +153,8 @@ async def test_make_model(self): format_=V20CredFormat.Format.INDY, ), V20CredFormat( - attach_id="dif-links", - format_=V20CredFormat.Format.DIF, + attach_id="ld-proof-links", + format_=V20CredFormat.Format.LD_PROOF, ), ], requests_attach=[ @@ -162,13 +162,13 @@ async def test_make_model(self): ident="indy", mapping=TestV20CredRequest.indy_cred_req ), AttachDecorator.data_links( - ident="dif-links", + ident="ld-proof-links", links="http://10.20.30.40/cred-req.json", sha256="00000000000000000000000000000000", ), ], ) - assert cred_request.attachment(V20CredFormat.Format.DIF) == ( + assert cred_request.attachment(V20CredFormat.Format.LD_PROOF) == ( ["http://10.20.30.40/cred-req.json"], "00000000000000000000000000000000", ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/models/cred_ex_record.py b/aries_cloudagent/protocols/issue_credential/v2_0/models/cred_ex_record.py index 6df1df27eb..2aa99e4b96 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/models/cred_ex_record.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/models/cred_ex_record.py @@ -52,7 +52,6 @@ def __init__( cred_proposal: Mapping = None, # serialized cred proposal message cred_offer: Mapping = None, # serialized cred offer message cred_request: Mapping = None, # serialized cred request message - cred_request_metadata: Mapping = None, # credential request metadata cred_issue: Mapping = None, # serialized cred issue message cred_id_stored: str = None, auto_offer: bool = False, @@ -75,7 +74,6 @@ def __init__( self.cred_proposal = cred_proposal self.cred_offer = cred_offer self.cred_request = cred_request - self.cred_request_metadata = cred_request_metadata self.cred_issue = cred_issue self.cred_id_stored = cred_id_stored self.auto_offer = auto_offer @@ -110,7 +108,6 @@ def record_value(self) -> Mapping: "cred_proposal", "cred_offer", "cred_request", - "cred_request_metadata", "cred_issue", "cred_id_stored", "auto_offer", @@ -216,9 +213,6 @@ class Meta: cred_request = fields.Dict( required=False, description="Serialized credential request message" ) - cred_request_metadata = fields.Dict( - required=False, description="(Indy) credential request metadata" - ) cred_issue = fields.Dict( required=False, description="Serialized credential issue message" ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/dif.py b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/ld_proof.py similarity index 55% rename from aries_cloudagent/protocols/issue_credential/v2_0/models/detail/dif.py rename to aries_cloudagent/protocols/issue_credential/v2_0/models/detail/ld_proof.py index 2e8a73c9c6..18483e47fe 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/dif.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/ld_proof.py @@ -1,4 +1,4 @@ -"""DIF-specific credential exchange information with non-secrets storage.""" +"""Linked data proof specific credential exchange information with non-secrets storage.""" from typing import Any @@ -11,37 +11,37 @@ from .. import UNENCRYPTED_TAGS -class V20CredExRecordDIF(BaseRecord): - """Credential exchange DIF detail record.""" +class V20CredExRecordLDProof(BaseRecord): + """Credential exchange linked data proof detail record.""" class Meta: - """V20CredExRecordDIF metadata.""" + """V20CredExRecordLDProof metadata.""" - schema_class = "V20CredExRecordDIFSchema" + schema_class = "V20CredExRecordLDProofSchema" - RECORD_ID_NAME = "cred_ex_dif_id" - RECORD_TYPE = "dif_cred_ex_v20" + RECORD_ID_NAME = "cred_ex_ld_proof_id" + RECORD_TYPE = "ld_proof_cred_ex_v20" TAG_NAMES = {"~cred_ex_id"} if UNENCRYPTED_TAGS else {"cred_ex_id"} - WEBHOOK_TOPIC = "issue_credential_v2_0_dif" + WEBHOOK_TOPIC = "issue_credential_v2_0_ld_proof" def __init__( self, - cred_ex_dif_id: str = None, + cred_ex_ld_proof_id: str = None, *, cred_ex_id: str = None, - # TODO: REMOVE THIS COMMENT AND SET DIF ITEMS BELOW + # TODO: REMOVE THIS COMMENT AND SET LDProof ITEMS BELOW item: str = None, **kwargs, ): - """Initialize DIF credential exchange record details.""" - super().__init__(cred_ex_dif_id, **kwargs) + """Initialize LD Proof credential exchange record details.""" + super().__init__(cred_ex_ld_proof_id, **kwargs) self.cred_ex_id = cred_ex_id - # TODO: REMOVE THIS COMMENT AND SET DIF ITEMS BELOW + # TODO: REMOVE THIS COMMENT AND SET LDProof ITEMS BELOW self.item = item @property - def cred_ex_dif_id(self) -> str: + def cred_ex_ld_proof_id(self) -> str: """Accessor for the ID associated with this exchange.""" return self._id @@ -51,7 +51,7 @@ def record_value(self) -> dict: return { prop: getattr(self, prop) for prop in ( - # TODO: REMOVE THIS COMMENT AND SET DIF ITEMS BELOW + # TODO: REMOVE THIS COMMENT AND SET LDProof ITEMS BELOW "item", ) } @@ -61,8 +61,8 @@ async def retrieve_by_cred_ex_id( cls, session: ProfileSession, cred_ex_id: str, - ) -> "V20CredExRecordDIF": - """Retrieve a credential exchange DIF detail record by its cred ex id.""" + ) -> "V20CredExRecordLDProof": + """Retrieve a credential exchange LDProof detail record by its cred ex id.""" return await cls.retrieve_by_tag_filter( session, {"cred_ex_id": cred_ex_id}, @@ -74,16 +74,16 @@ def __eq__(self, other: Any) -> bool: return super().__eq__(other) -class V20CredExRecordDIFSchema(BaseRecordSchema): - """Credential exchange DIF detail record detail schema.""" +class V20CredExRecordLDProofSchema(BaseRecordSchema): + """Credential exchange linked data proof detail record detail schema.""" class Meta: - """Credential exchange DIF detail record schema metadata.""" + """Credential exchange linked data proof detail record schema metadata.""" - model_class = V20CredExRecordDIF + model_class = V20CredExRecordLDProof unknown = EXCLUDE - cred_ex_dif_id = fields.Str( + cred_ex_ld_proof_id = fields.Str( required=False, description="Record identifier", example=UUIDFour.EXAMPLE, @@ -93,9 +93,9 @@ class Meta: description="Corresponding v2.0 credential exchange record identifier", example=UUIDFour.EXAMPLE, ) - # TODO: REMOVE THIS COMMENT AND SET DIF ITEMS BELOW + # TODO: REMOVE THIS COMMENT AND SET LDProof ITEMS BELOW item = fields.Dict( required=False, - description="DIF item", + description="LDProof item", example=UUIDFour.EXAMPLE, ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/tests/test_dif.py b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/tests/test_dif.py deleted file mode 100644 index 091d434ae0..0000000000 --- a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/tests/test_dif.py +++ /dev/null @@ -1,30 +0,0 @@ -from asynctest import TestCase as AsyncTestCase - -from ..dif import V20CredExRecordDIF - - -class TestV20CredExRecordDIF(AsyncTestCase): - async def test_record(self): - same = [ - V20CredExRecordDIF( - cred_ex_dif_id="dummy-0", cred_ex_id="abc", item="my-item" - ) - ] * 2 - diff = [ - V20CredExRecordDIF( - cred_ex_dif_id="dummy-0", cred_ex_id="def", item="my-cred" - ), - V20CredExRecordDIF( - cred_ex_dif_id="dummy-0", cred_ex_id="abc", item="your-item" - ), - ] - - for i in range(len(same) - 1): - for j in range(i, len(same)): - assert same[i] == same[j] - - for i in range(len(diff) - 1): - for j in range(i, len(diff)): - assert diff[i] == diff[j] if i == j else diff[i] != diff[j] - - assert same[0].cred_ex_dif_id == "dummy-0" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/tests/test_ld_proof.py b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/tests/test_ld_proof.py new file mode 100644 index 0000000000..1c186befc5 --- /dev/null +++ b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/tests/test_ld_proof.py @@ -0,0 +1,30 @@ +from asynctest import TestCase as AsyncTestCase + +from ..ld_proof import V20CredExRecordLDProof + + +class TestV20CredExRecordLDProof(AsyncTestCase): + async def test_record(self): + same = [ + V20CredExRecordLDProof( + cred_ex_ld_proof_id="dummy-0", cred_ex_id="abc", item="my-item" + ) + ] * 2 + diff = [ + V20CredExRecordLDProof( + cred_ex_ld_proof_id="dummy-0", cred_ex_id="def", item="my-cred" + ), + V20CredExRecordLDProof( + cred_ex_ld_proof_id="dummy-0", cred_ex_id="abc", item="your-item" + ), + ] + + for i in range(len(same) - 1): + for j in range(i, len(same)): + assert same[i] == same[j] + + for i in range(len(diff) - 1): + for j in range(i, len(diff)): + assert diff[i] == diff[j] if i == j else diff[i] != diff[j] + + assert same[0].cred_ex_ld_proof_id == "dummy-0" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py index 937f8cd4ca..cb7e41c572 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py @@ -44,7 +44,7 @@ from .messages.cred_proposal import V20CredProposal from .messages.inner.cred_preview import V20CredPreview, V20CredPreviewSchema from .models.cred_ex_record import V20CredExRecord, V20CredExRecordSchema -from .models.detail.dif import V20CredExRecordDIFSchema +from .models.detail.ld_proof import V20CredExRecordLDProofSchema from .models.detail.indy import V20CredExRecordIndySchema @@ -97,12 +97,13 @@ class V20CredExRecordDetailSchema(OpenAPISchema): required=False, description="Credential exchange record", ) + indy = fields.Nested( V20CredExRecordIndySchema, required=False, ) - dif = fields.Nested( - V20CredExRecordDIFSchema, + ld_proof = fields.Nested( + V20CredExRecordLDProofSchema, required=False, ) @@ -147,11 +148,11 @@ class V20CredFilterIndySchema(OpenAPISchema): ) -class V20CredFilterDIFSchema(OpenAPISchema): - """DIF credential filtration criteria.""" +class V20CredFilterLDProofSchema(OpenAPISchema): + """Linked data proof credential filtration criteria.""" - some_dif_criterion = fields.Str( - description="Placeholder for W3C/DIF/JSON-LD filtration criterion", + some_ld_proof_criterion = fields.Str( + description="Placeholder for W3C/JSON-LD proof filtration criterion", required=False, ) @@ -164,10 +165,10 @@ class V20CredFilterSchema(OpenAPISchema): required=False, description="Credential filter for indy", ) - dif = fields.Nested( - V20CredFilterDIFSchema, + ld_proof = fields.Nested( + V20CredFilterLDProofSchema, required=False, - description="Credential filter for DIF", + description="Credential filter for linked data proof", ) @validates_schema @@ -175,17 +176,19 @@ def validate_fields(self, data, **kwargs): """ Validate schema fields. - Data must have indy, dif, or both. + Data must have indy, ld_proof, or both. Args: data: The data to validate Raises: - ValidationError: if data has neither indy nor dif + ValidationError: if data has neither indy nor ld_proof """ if not any(f.api in data for f in V20CredFormat.Format): - raise ValidationError("V20CredFilterSchema requires indy, dif, or both") + raise ValidationError( + "V20CredFilterSchema requires indy, ld_proof, or both" + ) class V20IssueCredSchemaCore(AdminAPIMessageTracingSchema): @@ -320,8 +323,7 @@ async def _get_result_with_details( cred_ex_record.cred_ex_id ) - if detail_record: - result[fmt.aka[0]] = detail_record.serialize() + result[fmt.aka[0]] = detail_record.serialize() if detail_record else None return result @@ -966,13 +968,101 @@ async def credential_exchange_send_bound_offer(request: web.BaseRequest): return web.json_response(result) +@docs( + tags=["issue-credential v2.0"], + summary="Send issuer a credential proposal", +) +@request_schema(V20CredProposalRequestPreviewOptSchema()) +@response_schema(V20CredExRecordSchema(), 200, description="") +async def credential_exchange_send_free_request(request: web.BaseRequest): + """ + Request handler for sending free credential request. + + Args: + request: aiohttp request object + + Returns: + The credential exchange record + + """ + r_time = get_timer() + + context: AdminRequestContext = request["context"] + outbound_handler = request["outbound_message_router"] + + body = await request.json() + + conn_id = body.get("connection_id") + comment = body.get("comment") + filt_spec = body.get("filter") + if not filt_spec: + raise web.HTTPBadRequest(reason="Missing filter") + if "indy" in filt_spec: + raise web.HTTPBadRequest( + reason="Indy credential exchange cannot start with request" + ) + auto_remove = body.get("auto_remove") + trace_msg = body.get("trace") + + conn_record = None + cred_ex_record = None + try: + async with context.session() as session: + conn_record = await ConnRecord.retrieve_by_id(session, conn_id) + if not conn_record.is_ready: + raise web.HTTPForbidden(reason=f"Connection {conn_id} not ready") + + cred_manager = V20CredManager(context.profile) + + cred_proposal = V20CredProposal( + comment=comment, + **_formats_filters(filt_spec), + ) + + cred_ex_record = await V20CredExRecord( + conn_id=conn_id, + auto_remove=auto_remove, + cred_proposal=cred_proposal.serialize(), + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_HOLDER, + trace=trace_msg, + ) + + (cred_ex_record, cred_request_message) = await cred_manager.create_request( + cred_ex_record=cred_ex_record, + holder_did=conn_record.my_did, + comment=comment, + ) + + result = cred_ex_record.serialize() + + except (BaseModelError, StorageError) as err: + await internal_error( + err, + web.HTTPBadRequest, + cred_ex_record or conn_record, + outbound_handler, + ) + + await outbound_handler(cred_request_message, connection_id=conn_id) + + trace_event( + context.settings, + cred_request_message, + outcome="credential_exchange_send_free_request.END", + perf_counter=r_time, + ) + + return web.json_response(result) + + @docs( tags=["issue-credential v2.0"], summary="Send issuer a credential request", ) @match_info_schema(V20CredExIdMatchInfoSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") -async def credential_exchange_send_request(request: web.BaseRequest): +async def credential_exchange_send_bound_request(request: web.BaseRequest): """ Request handler for sending credential request. @@ -1028,7 +1118,7 @@ async def credential_exchange_send_request(request: web.BaseRequest): trace_event( context.settings, cred_request_message, - outcome="credential_exchange_send_request.END", + outcome="credential_exchange_send_bound_request.END", perf_counter=r_time, ) @@ -1085,17 +1175,8 @@ async def credential_exchange_issue(request: web.BaseRequest): cred_ex_record, comment=comment, ) - indy_record = await cred_manager.get_detail_record( - cred_ex_id, V20CredFormat.Format.INDY - ) - dif_record = await cred_manager.get_detail_record( - cred_ex_id, V20CredFormat.Format.DIF - ) - result = { - "cred_ex_record": cred_ex_record.serialize(), - "indy": indy_record.serialize() if indy_record else None, - "dif": dif_record.serialize() if dif_record else None, - } + + result = _get_result_with_details(context.profile, cred_ex_record) except (BaseModelError, V20CredManagerError, IndyIssuerError, StorageError) as err: await internal_error( @@ -1169,17 +1250,8 @@ async def credential_exchange_store(request: web.BaseRequest): cred_ex_record, cred_id, ) - indy_record = await cred_manager.get_detail_record( - cred_ex_id, V20CredFormat.Format.INDY - ) - dif_record = await cred_manager.get_detail_record( - cred_ex_id, V20CredFormat.Format.DIF - ) - result = { - "cred_ex_record": cred_ex_record.serialize(), - "indy": indy_record.serialize() if indy_record else None, - "dif": dif_record.serialize() if dif_record else None, - } + + result = _get_result_with_details(context.profile, cred_ex_record) except (StorageError, V20CredManagerError, BaseModelError) as err: await internal_error( @@ -1300,13 +1372,17 @@ async def register(app: web.Application): web.post( "/issue-credential-2.0/send-offer", credential_exchange_send_free_offer ), + web.post( + "/issue-credential-2.0/send-request", + credential_exchange_send_free_request, + ), web.post( "/issue-credential-2.0/records/{cred_ex_id}/send-offer", credential_exchange_send_bound_offer, ), web.post( "/issue-credential-2.0/records/{cred_ex_id}/send-request", - credential_exchange_send_request, + credential_exchange_send_bound_request, ), web.post( "/issue-credential-2.0/records/{cred_ex_id}/issue", diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py index 2d0b5f84f9..55faf635ed 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py @@ -27,7 +27,7 @@ from ..messages.cred_request import V20CredRequest from ..messages.inner.cred_preview import V20CredPreview, V20CredAttrSpec from ..models.cred_ex_record import V20CredExRecord -from ..models.detail.dif import V20CredExRecordDIF +from ..models.detail.ld_proof import V20CredExRecordLDProof from ..models.detail.indy import V20CredExRecordIndy @@ -126,29 +126,6 @@ async def setUp(self): self.manager = V20CredManager(self.profile) assert self.manager.profile - async def test_get_indy_detail_record(self): - cred_ex_id = "dummy" - detail_indy = V20CredExRecordIndy( - cred_ex_id=cred_ex_id, - rev_reg_id="rr-id", - cred_rev_id="0", - ) - await detail_indy.save(self.session) - assert ( - await self.manager.get_detail_record(cred_ex_id, V20CredFormat.Format.INDY) - == detail_indy - ) - assert ( - await self.manager.get_detail_record(cred_ex_id, V20CredFormat.Format.DIF) - is None - ) - - async def test_get_dif_detail_record(self): - cred_ex_id = "dummy" - detail_dif = V20CredExRecordDIF(cred_ex_id=cred_ex_id, item="my-item") - await detail_dif.save(self.session) - await self.manager.get_detail_record(cred_ex_id, V20CredFormat.Format.DIF) - async def test_prepare_send(self): connection_id = "test_conn_id" cred_preview = V20CredPreview( diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py index b400745043..624e83a1d3 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py @@ -33,13 +33,13 @@ async def test_validate_cred_filter_schema(self): schema.validate_fields( { "indy": {"issuer_did": TEST_DID}, - "dif": {"some_dif_criterion": "..."}, + "ld_proof": {"some_ld_proof_criterion": "..."}, } ) schema.validate_fields( { "indy": {}, - "dif": {"some_dif_criterion": "..."}, + "ld_proof": {"some_ld_proof_criterion": "..."}, } ) with self.assertRaises(test_module.ValidationError): @@ -81,7 +81,7 @@ async def test_credential_exchange_list(self): { "cred_ex_record": mock_cx_rec.serialize.return_value, "indy": None, - "dif": None, + "ld_proof": None, } ] } @@ -126,7 +126,7 @@ async def test_credential_exchange_retrieve(self): async_mock.MagicMock( # indy serialize=async_mock.MagicMock(return_value={"...": "..."}) ), - None, # dif + None, # ld_proof ] ) @@ -138,7 +138,7 @@ async def test_credential_exchange_retrieve(self): { "cred_ex_record": mock_cx_rec.serialize.return_value, "indy": {"...": "..."}, - "dif": None, + "ld_proof": None, } ) @@ -1101,7 +1101,7 @@ async def test_credential_exchange_issue(self): async_mock.MagicMock( # indy serialize=async_mock.MagicMock(return_value={"...": "..."}) ), - None, # dif + None, # ld_proof ] ) @@ -1116,7 +1116,7 @@ async def test_credential_exchange_issue(self): { "cred_ex_record": mock_cx_rec.serialize.return_value, "indy": {"...": "..."}, - "dif": None, + "ld_proof": None, } ) @@ -1252,7 +1252,7 @@ async def test_credential_exchange_issue_deser_x(self): async_mock.MagicMock( # indy serialize=async_mock.MagicMock(return_value={"...": "..."}) ), - None, # dif + None, # ld_proof ] ) @@ -1283,7 +1283,7 @@ async def test_credential_exchange_store(self): async_mock.MagicMock( # indy serialize=async_mock.MagicMock(return_value={"...": "..."}) ), - None, # dif + None, # ld_proof ] ) @@ -1300,7 +1300,7 @@ async def test_credential_exchange_store(self): { "cred_ex_record": mock_cx_rec.serialize.return_value, "indy": {"...": "..."}, - "dif": None, + "ld_proof": None, } ) @@ -1332,7 +1332,7 @@ async def test_credential_exchange_store_bad_cred_id_json(self): async_mock.MagicMock( # indy serialize=async_mock.MagicMock(return_value={"...": "..."}) ), - None, # dif + None, # ld_proof ] ) @@ -1347,7 +1347,7 @@ async def test_credential_exchange_store_bad_cred_id_json(self): { "cred_ex_record": mock_cx_rec.serialize.return_value, "indy": {"...": "..."}, - "dif": None, + "ld_proof": None, } ) From 8aa0c34055ef714f14ab9f132a17d474e9d135a7 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Tue, 9 Mar 2021 14:29:03 +0100 Subject: [PATCH 008/138] fixed various syntax issues Signed-off-by: Karim Stekelenburg --- aries_cloudagent/vc/__init__.py | 0 aries_cloudagent/vc/ld_proofs/ProofSet.py | 10 +- aries_cloudagent/vc/ld_proofs/__init__.py | 8 +- .../vc/ld_proofs/document_loader.py | 2 +- .../purposes/PublicKeyProofPurpose.py | 18 +-- .../suites/JwsLinkedDataSignature.py | 149 +++++++++--------- .../vc/ld_proofs/suites/LinkedDataProof.py | 4 +- .../vc/ld_proofs/suites/__init__.py | 8 +- aries_cloudagent/vc/vc_ld/verify.py | 8 +- 9 files changed, 111 insertions(+), 96 deletions(-) create mode 100644 aries_cloudagent/vc/__init__.py diff --git a/aries_cloudagent/vc/__init__.py b/aries_cloudagent/vc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py index f0f8aa19a4..043301e9f3 100644 --- a/aries_cloudagent/vc/ld_proofs/ProofSet.py +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -5,7 +5,7 @@ from .suites import LinkedDataProof, LinkedDataSignature from .purposes.ProofPurpose import ProofPurpose -from .document_loader import document_loader +from .document_loader import DocumentLoader from .constants import SECURITY_CONTEXT_V2_URL @@ -16,7 +16,7 @@ async def add( document: Union[dict, str], suite: LinkedDataProof, purpose: ProofPurpose, - document_loader: document_loader + document_loader: DocumentLoader ) -> dict: if isinstance(document, str): @@ -42,7 +42,7 @@ async def verify( document: Union[dict, str], suites: List[LinkedDataProof], purpose: ProofPurpose, - document_loader: document_loader + document_loader: DocumentLoader ): try: if isinstance(document, str): @@ -75,7 +75,7 @@ async def verify( return {"verified": verified, "error": e} @staticmethod - async def _get_proofs(document: dict, document_loader: document_loader) -> dict: + async def _get_proofs(document: dict, document_loader: DocumentLoader) -> dict: proof_set = jsonld.get_values(document, "proof") del document["proof"] @@ -95,7 +95,7 @@ async def _verify( suites: List[LinkedDataProof], proof_set: List[dict], purpose: ProofPurpose, - document_loader: document_loader, + document_loader: DocumentLoader, ): result = await asyncio.gather( diff --git a/aries_cloudagent/vc/ld_proofs/__init__.py b/aries_cloudagent/vc/ld_proofs/__init__.py index c8b37c7a4d..6b6bb22c5c 100644 --- a/aries_cloudagent/vc/ld_proofs/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/__init__.py @@ -8,7 +8,12 @@ AssertionProofPurpose, IssueCredentialProofPurpose, ) -from .suites import LinkedDataProof, LinkedDataSignature, JwsLinkedDataSignature +from .suites import ( + LinkedDataProof, + LinkedDataSignature, + JwsLinkedDataSignature, + Ed25519Signature2018, +) from .crypto import Base58Encoder, KeyPair, Ed25519KeyPair from .document_loader import DocumentLoader, did_key_document_loader @@ -25,6 +30,7 @@ LinkedDataProof, LinkedDataSignature, JwsLinkedDataSignature, + Ed25519Signature2018, Base58Encoder, KeyPair, Ed25519KeyPair, diff --git a/aries_cloudagent/vc/ld_proofs/document_loader.py b/aries_cloudagent/vc/ld_proofs/document_loader.py index 0e7cf3774a..4ccbcdb1b0 100644 --- a/aries_cloudagent/vc/ld_proofs/document_loader.py +++ b/aries_cloudagent/vc/ld_proofs/document_loader.py @@ -1,6 +1,6 @@ from pyld.documentloader import requests -from .wallet_util import did_key_to_naked from ...wallet.util import did_key_to_naked + from typing import Callable diff --git a/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py index a2e0aff0d5..3080306e7d 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py @@ -4,18 +4,16 @@ class PublicKeyProofPurpose(ControllerProofPurpose): - def __init__( - self, - controller: dict, - date: datetime, - max_timestamp_delta: timedelta = None): - super().__init__('publicKey', controller, date, max_timestamp_delta) + def __init__( + self, controller: dict, date: datetime, max_timestamp_delta: timedelta = None + ): + super().__init__("publicKey", controller, date, max_timestamp_delta) - async def update(self, proof: dict) -> Awaitable[dict]: - return proof + async def update(self, proof: dict) -> Awaitable[dict]: + return proof - async def match(self, proof: dict) -> Awaitable[boo]: - return proof.get('proofPurpose') == None + async def match(self, proof: dict) -> Awaitable[bool]: + return proof.get("proofPurpose") is None __all__ = [PublicKeyProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py index 5bc62f2821..c19d94ee8d 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py @@ -1,109 +1,116 @@ from .LinkedDataSignature import LinkedDataSignature from .LinkedDataProof import LinkedDataProof -from ....wallet.util import str_to_b64, b64_to_str, create_jws +from ....wallet.util import str_to_b64, b64_to_str +from ....messaging.jsonld.credential import create_jws import json from pyld import jsonld from typing import Union from datetime import datetime -from ..KeyPair import KeyPair +from ..crypto import KeyPair class JwsLinkedDataSignature(LinkedDataSignature): - def __init__( - self, - signature_type: str, - algorithm: str, - key_pair: KeyPair, - verification_method: str, - *, - proof: dict = None, - date: Union[datetime, str], - ): + def __init__( + self, + signature_type: str, + algorithm: str, + key_pair: KeyPair, + verification_method: str, + *, + proof: dict = None, + date: Union[datetime, str], + ): - super().__init__( - signature_type, verification_method, proof=proof, date=date) + super().__init__(signature_type, verification_method, proof=proof, date=date) - self.algorithm = algorithm - self.key_pair = key_pair + self.algorithm = algorithm + self.key_pair = key_pair - def decode_header(self, encoded_header: str) -> dict: - header = None - try: - header = json.loads(b64_to_str(encoded_header, urlsafe=True)) - except Exception: - raise Exception('Could not parse JWS header.') - return header + def decode_header(self, encoded_header: str) -> dict: + header = None + try: + header = json.loads(b64_to_str(encoded_header, urlsafe=True)) + except Exception: + raise Exception("Could not parse JWS header.") + return header - def validate_header(self, header: dict): - """ Validates the JWS header, throws if not ok """ - if not (header and isinstance(header, dict)): - raise Exception('Invalid JWS header.') + def validate_header(self, header: dict): + """ Validates the JWS header, throws if not ok """ + if not (header and isinstance(header, dict)): + raise Exception("Invalid JWS header.") - if not (header['alg'] == self.algorithm and header['b64'] is False - and isinstance(header['crit'], list) and header['crit'].len() == 1 - and header['crit'][0] == 'b64' and header.keys().len() == 3): - raise Exception(f'Invalid JWS header params for {self.signature_type}') + if not ( + header["alg"] == self.algorithm + and header["b64"] is False + and isinstance(header["crit"], list) + and header["crit"].len() == 1 + and header["crit"][0] == "b64" + and header.keys().len() == 3 + ): + raise Exception(f"Invalid JWS header params for {self.signature_type}") - async def sign(self, verify_data: bytes, proof: dict): + async def sign(self, verify_data: bytes, proof: dict): - header = {'alg': self.algorithm, 'b64': False, 'crit': ['b64']} + header = {"alg": self.algorithm, "b64": False, "crit": ["b64"]} - encoded_header = str_to_b64(json.dumps(header), urlsafe=True, pad=False) + encoded_header = str_to_b64(json.dumps(header), urlsafe=True, pad=False) - data = create_jws(encoded_header, verify_data) + data = create_jws(encoded_header, verify_data) - signature = self.key_pair.sign(data).signature + signature = self.key_pair.sign(data).signature - encoded_signature = bytes_to_b64(signature, urlsafe=True, pad=False) + encoded_signature = bytes_to_b64(signature, urlsafe=True, pad=False) - proof['jws'] = str(encoded_header) + '..' + encoded_signature + proof["jws"] = str(encoded_header) + ".." + encoded_signature - return proof + return proof - async def verify_signature( - self, verify_data: bytes, verification_method: dict, proof: dict): + async def verify_signature( + self, verify_data: bytes, verification_method: dict, proof: dict + ): - if not (('jws' in proof) and isinstance(proof['jws'], str) and - ('.' in proof['jws'])): - raise Exception('The proof does not contain a valid "jws" property.') + if not ( + ("jws" in proof) and isinstance(proof["jws"], str) and ("." in proof["jws"]) + ): + raise Exception('The proof does not contain a valid "jws" property.') - encoded_header, payload, encoded_signature = proof['jws'].split('.') + encoded_header, payload, encoded_signature = proof["jws"].split(".") - header = self.decode_header(encoded_header) + header = self.decode_header(encoded_header) - self.validate_header(header) + self.validate_header(header) - signature = b64_to_str(encoded_signature, urlsafe=True) - data = create_jws(encoded_header, verify_data) + signature = b64_to_str(encoded_signature, urlsafe=True) + data = create_jws(encoded_header, verify_data) - return self.key_pair.verify(data, signature) + return self.key_pair.verify(data, signature) - def assert_verification_method(self, verification_method: dict): - if not jsonld.has_value(verification_method, 'type', - self.required_key_type): - raise Exception( - f'Invalid key type. The key type must be {self.required_key_type}') + def assert_verification_method(self, verification_method: dict): + if not jsonld.has_value(verification_method, "type", self.required_key_type): + raise Exception( + f"Invalid key type. The key type must be {self.required_key_type}" + ) - async def get_verification_method( - self, proof: dict, document_loader: callable): - verification_method = await super(LinkedDataSignature, self).\ - get_verification_method(proof, document_loader) - self.assert_verification_method(verification_method) - return verification_method + async def get_verification_method(self, proof: dict, document_loader: callable): + verification_method = await super( + LinkedDataSignature, self + ).get_verification_method(proof, document_loader) + self.assert_verification_method(verification_method) + return verification_method - # async def match_proof(self, proof, document, purpose, document_loader): - # if not super(LinkedDataProof, self).match_proof(proof['type']): - # return False + # async def match_proof(self, proof, document, purpose, document_loader): + # if not super(LinkedDataProof, self).match_proof(proof['type']): + # return False - # if not self.key: - # return True + # if not self.key: + # return True - # verification_method = proof['verificationMethod'] + # verification_method = proof['verificationMethod'] - # if isinstance(verification_method, dict): - # return verification_method['id'] == self.key.id + # if isinstance(verification_method, dict): + # return verification_method['id'] == self.key.id + + # return verification_method == self.key.id - # return verification_method == self.key.id - __all__ = [JwsLinkedDataSignature] diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py index a1e2e22d2e..d210e96de4 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py @@ -1,6 +1,6 @@ """Abstract base class for linked data proofs.""" from ..purposes.ProofPurpose import ProofPurpose -from ..document_loader import document_loader +from ..document_loader import DocumentLoader from abc import ABCMeta, abstractmethod @@ -11,7 +11,7 @@ def __init__(self, signature_type: str): @abstractmethod async def create_proof( - self, *, document: dict, purpose: ProofPurpose, document_loader: document_loader + self, *, document: dict, purpose: ProofPurpose, document_loader: DocumentLoader ): pass diff --git a/aries_cloudagent/vc/ld_proofs/suites/__init__.py b/aries_cloudagent/vc/ld_proofs/suites/__init__.py index 93be0b735a..c5a1fcbc72 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/suites/__init__.py @@ -1,5 +1,11 @@ from .LinkedDataProof import LinkedDataProof from .LinkedDataSignature import LinkedDataSignature from .JwsLinkedDataSignature import JwsLinkedDataSignature +from .Ed25519Signature2018 import Ed25519Signature2018 -__all__ = [LinkedDataProof, LinkedDataSignature, JwsLinkedDataSignature] +__all__ = [ + LinkedDataProof, + LinkedDataSignature, + JwsLinkedDataSignature, + Ed25519Signature2018, +] diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index a0099f9b1e..870f6e7492 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -20,7 +20,7 @@ async def _verify_credential( suite: LinkedDataSignature, purpose: ProofPurpose = None, check_status: Callable = None, -) -> Awaitable(dict): +) -> dict: # TODO: validate credential structure if credential["credentialStatus"] and not check_status: @@ -188,10 +188,8 @@ async def verify( except Exception as e: return { "verified": False, - "results": [ - {"presentation": presentation, "verified": False, "error": error} - ], - "error": error, + "results": [{"presentation": presentation, "verified": False, "error": e}], + "error": e, } From f20f2406cddc767367571c9bd52a56e5c26dce1e Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Tue, 9 Mar 2021 14:39:02 +0100 Subject: [PATCH 009/138] black formatting Signed-off-by: Timo Glastra --- aries_cloudagent/indy/credx/issuer.py | 578 ++++++++++++++++++ .../issue_credential/v2_0/formats/handler.py | 2 +- .../v2_0/formats/tests/test_indy.py | 2 +- aries_cloudagent/vc/ld_proofs/constants.py | 2 +- .../vc/ld_proofs/crypto/Base58Encoder.py | 12 +- .../vc/ld_proofs/crypto/Ed25519KeyPair.py | 42 +- .../vc/ld_proofs/crypto/KeyPair.py | 12 +- .../vc/ld_proofs/mock/test_documents.py | 25 +- .../purposes/AssertionProofPurpose.py | 5 +- .../purposes/IssueCredentialProofPurpose.py | 46 +- .../vc/ld_proofs/purposes/__init__.py | 10 +- .../ld_proofs/suites/Ed25519Signature2018.py | 5 +- .../ld_proofs/suites/LinkedDataSignature.py | 325 +++++----- aries_cloudagent/vc/vc_ld/checker.py | 2 +- 14 files changed, 837 insertions(+), 231 deletions(-) create mode 100644 aries_cloudagent/indy/credx/issuer.py diff --git a/aries_cloudagent/indy/credx/issuer.py b/aries_cloudagent/indy/credx/issuer.py new file mode 100644 index 0000000000..d506078fcd --- /dev/null +++ b/aries_cloudagent/indy/credx/issuer.py @@ -0,0 +1,578 @@ +"""Indy issuer implementation.""" + +import asyncio +import logging + +from typing import Sequence, Tuple + +from aries_askar import StoreError + +from indy_credx import ( + Credential, + CredentialDefinition, + CredentialOffer, + CredentialRevocationConfig, + CredxError, + RevocationRegistry, + RevocationRegistryDefinition, + RevocationRegistryDelta, + Schema, +) + +from ...askar.profile import AskarProfile + +from ..issuer import ( + IndyIssuer, + IndyIssuerError, + IndyIssuerRevocationRegistryFullError, + DEFAULT_CRED_DEF_TAG, + DEFAULT_SIGNATURE_TYPE, +) + +LOGGER = logging.getLogger(__name__) + +CATEGORY_CRED_DEF = "credential_definition" +CATEGORY_CRED_DEF_PRIVATE = "credential_def_private" +CATEGORY_CRED_DEF_KEY_PROOF = "credential_def_key_proof" +CATEGORY_SCHEMA = "schema" +CATEGORY_REV_REG = "revocation_registry" +CATEGORY_REV_REG_INFO = "revocation_registry_info" +CATEGORY_REV_REG_DEF = "revocation_reg_def" +CATEGORY_REV_REG_DEF_PRIVATE = "revocation_reg_def_private" +CATEGORY_REV_REG_ISSUER = "revocation_reg_def_issuer" + + +class IndyCredxIssuer(IndyIssuer): + """Indy-Credx issuer class.""" + + def __init__(self, profile: AskarProfile): + """ + Initialize an IndyCredxIssuer instance. + + Args: + profile: The active profile instance + + """ + self._profile = profile + + @property + def profile(self) -> AskarProfile: + """Accessor for the profile instance.""" + return self._profile + + async def create_schema( + self, + origin_did: str, + schema_name: str, + schema_version: str, + attribute_names: Sequence[str], + ) -> Tuple[str, str]: + """ + Create a new credential schema and store it in the wallet. + + Args: + origin_did: the DID issuing the credential definition + schema_name: the schema name + schema_version: the schema version + attribute_names: a sequence of schema attribute names + + Returns: + A tuple of the schema ID and JSON + + """ + try: + schema = Schema.create( + origin_did, schema_name, schema_version, attribute_names + ) + schema_id = schema.id + schema_json = schema.to_json() + async with self._profile.session() as session: + await session.handle.insert(CATEGORY_SCHEMA, schema_id, schema_json) + except CredxError as err: + raise IndyIssuerError("Error creating schema") from err + except StoreError as err: + raise IndyIssuerError("Error storing schema") from err + return (schema_id, schema_json) + + async def credential_definition_in_wallet( + self, credential_definition_id: str + ) -> bool: + """ + Check whether a given credential definition ID is present in the wallet. + + Args: + credential_definition_id: The credential definition ID to check + """ + try: + async with self._profile.session() as session: + return ( + await session.handle.fetch( + CATEGORY_CRED_DEF_KEY_PROOF, credential_definition_id + ) + ) is not None + except StoreError as err: + raise IndyIssuerError("Error checking for credential definition") from err + + async def create_and_store_credential_definition( + self, + origin_did: str, + schema: dict, + signature_type: str = None, + tag: str = None, + support_revocation: bool = False, + ) -> Tuple[str, str]: + """ + Create a new credential definition and store it in the wallet. + + Args: + origin_did: the DID issuing the credential definition + schema_json: the schema used as a basis + signature_type: the credential definition signature type (default 'CL') + tag: the credential definition tag + support_revocation: whether to enable revocation for this credential def + + Returns: + A tuple of the credential definition ID and JSON + + """ + try: + ( + cred_def, + cred_def_private, + key_proof, + ) = await asyncio.get_event_loop().run_in_executor( + None, + lambda: CredentialDefinition.create( + origin_did, + schema, + signature_type or DEFAULT_SIGNATURE_TYPE, + tag or DEFAULT_CRED_DEF_TAG, + support_revocation=support_revocation, + ), + ) + cred_def_id = cred_def.id + cred_def_json = cred_def.to_json() + except CredxError as err: + raise IndyIssuerError("Error creating credential definition") from err + try: + async with self._profile.transaction() as txn: + await txn.handle.insert( + CATEGORY_CRED_DEF, + cred_def_id, + cred_def_json, + # Note: Indy-SDK uses a separate SchemaId record for this + tags={"schema_id": schema["id"]}, + ) + await txn.handle.insert( + CATEGORY_CRED_DEF_PRIVATE, + cred_def_id, + cred_def_private.to_json_buffer(), + ) + await txn.handle.insert( + CATEGORY_CRED_DEF_KEY_PROOF, cred_def_id, key_proof.to_json_buffer() + ) + await txn.commit() + except StoreError as err: + raise IndyIssuerError("Error storing credential definition") from err + return (cred_def_id, cred_def_json) + + async def create_credential_offer(self, credential_definition_id: str) -> str: + """ + Create a credential offer for the given credential definition id. + + Args: + credential_definition_id: The credential definition to create an offer for + + Returns: + The new credential offer + + """ + try: + async with self._profile.session() as session: + cred_def = await session.handle.fetch( + CATEGORY_CRED_DEF, credential_definition_id + ) + key_proof = await session.handle.fetch( + CATEGORY_CRED_DEF_KEY_PROOF, credential_definition_id + ) + except StoreError as err: + raise IndyIssuerError("Error retrieving credential definition") from err + if not cred_def or not key_proof: + raise IndyIssuerError( + "Credential definition not found for credential offer" + ) + try: + # The tag holds the full name of the schema, + # as opposed to just the sequence number + schema_id = cred_def.tags.get("schema_id") + cred_def = CredentialDefinition.load(cred_def.raw_value) + + credential_offer = CredentialOffer.create( + schema_id or cred_def.schema_id, + cred_def, + key_proof.raw_value, + ) + except CredxError as err: + raise IndyIssuerError("Error creating credential offer") from err + + return credential_offer.to_json() + + async def create_credential( + self, + schema: dict, + credential_offer: dict, + credential_request: dict, + credential_values: dict, + cred_ex_id: str, + revoc_reg_id: str = None, + tails_file_path: str = None, + ) -> Tuple[str, str]: + """ + Create a credential. + + Args + schema: Schema to create credential for + credential_offer: Credential Offer to create credential for + credential_request: Credential request to create credential for + credential_values: Values to go in credential + cred_ex_id: credential exchange identifier to use in issuer cred rev rec + revoc_reg_id: ID of the revocation registry + tails_file_path: The location of the tails file + + Returns: + A tuple of created credential and revocation id + + """ + credential_definition_id = credential_offer["cred_def_id"] + try: + async with self._profile.session() as session: + cred_def = await session.handle.fetch( + CATEGORY_CRED_DEF, credential_definition_id + ) + cred_def_private = await session.handle.fetch( + CATEGORY_CRED_DEF_PRIVATE, credential_definition_id + ) + except StoreError as err: + raise IndyIssuerError("Error retrieving credential definition") from err + if not cred_def or not cred_def_private: + raise IndyIssuerError( + "Credential definition not found for credential issuance" + ) + + raw_values = {} + schema_attributes = schema["attrNames"] + for attribute in schema_attributes: + # Ensure every attribute present in schema to be set. + # Extraneous attribute names are ignored. + try: + credential_value = credential_values[attribute] + except KeyError: + raise IndyIssuerError( + "Provided credential values are missing a value " + f"for the schema attribute '{attribute}'" + ) + + raw_values[attribute] = str(credential_value) + + if revoc_reg_id: + try: + txn = await self._profile.transaction() + rev_reg = await txn.handle.fetch(CATEGORY_REV_REG, revoc_reg_id) + rev_reg_info = await txn.handle.fetch( + CATEGORY_REV_REG_INFO, revoc_reg_id, for_update=True + ) + rev_reg_def = await txn.handle.fetch(CATEGORY_REV_REG_DEF, revoc_reg_id) + rev_key = await txn.handle.fetch( + CATEGORY_REV_REG_DEF_PRIVATE, revoc_reg_id + ) + if not rev_reg: + raise IndyIssuerError("Revocation registry not found") + if not rev_reg_info: + raise IndyIssuerError("Revocation registry metadata not found") + if not rev_reg_def: + raise IndyIssuerError("Revocation registry definition not found") + if not rev_key: + raise IndyIssuerError( + "Revocation registry definition private data not found" + ) + # NOTE: we increment the index ahead of time to keep the + # transaction short. The revocation registry itself will NOT + # be updated because we always use ISSUANCE_BY_DEFAULT. + # If something goes wrong later, the index will be skipped. + # FIXME - double check issuance type in case of upgraded wallet? + rev_info = rev_reg_info.value_json + rev_reg_index = rev_info["curr_id"] + 1 + try: + rev_reg_def = RevocationRegistryDefinition.load( + rev_reg_def.raw_value + ) + except CredxError as err: + raise IndyIssuerError( + "Error loading revocation registry definition" + ) from err + if rev_reg_index > rev_reg_def.max_cred_num: + raise IndyIssuerRevocationRegistryFullError( + "Revocation registry is full" + ) + rev_info["curr_id"] = rev_reg_index + await txn.handle.replace( + CATEGORY_REV_REG_INFO, revoc_reg_id, value_json=rev_info + ) + await txn.commit() + except StoreError as err: + raise IndyIssuerError( + "Error updating revocation registry index" + ) from err + + revoc = CredentialRevocationConfig( + rev_reg_def, + rev_key.raw_value, + rev_reg.raw_value, + rev_reg_index, + rev_info.get("used_ids") or [], + tails_file_path, + ) + credential_revocation_id = str(rev_reg_index) + else: + revoc = None + credential_revocation_id = None + + try: + ( + credential, + _upd_rev_reg, + _delta, + ) = await asyncio.get_event_loop().run_in_executor( + None, + Credential.create, + cred_def.raw_value, + cred_def_private.raw_value, + credential_offer, + credential_request, + raw_values, + None, + revoc, + ) + except CredxError as err: + raise IndyIssuerError("Error creating credential") from err + + return credential.to_json(), credential_revocation_id + + async def revoke_credentials( + self, revoc_reg_id: str, tails_file_path: str, cred_revoc_ids: Sequence[str] + ) -> (str, Sequence[str]): + """ + Revoke a set of credentials in a revocation registry. + + Args: + revoc_reg_id: ID of the revocation registry + tails_file_path: path to the local tails file + cred_revoc_ids: sequences of credential indexes in the revocation registry + + Returns: + Tuple with the combined revocation delta, list of cred rev ids not revoked + + """ + + txn = await self._profile.transaction() + try: + rev_reg_def = await txn.handle.fetch(CATEGORY_REV_REG_DEF, revoc_reg_id) + rev_reg = await txn.handle.fetch( + CATEGORY_REV_REG, revoc_reg_id, for_update=True + ) + rev_reg_info = await txn.handle.fetch( + CATEGORY_REV_REG_INFO, revoc_reg_id, for_update=True + ) + if not rev_reg_def: + raise IndyIssuerError("Revocation registry definition not found") + if not rev_reg: + raise IndyIssuerError("Revocation registry not found") + if not rev_reg_info: + raise IndyIssuerError("Revocation registry metadata not found") + except StoreError as err: + raise IndyIssuerError("Error retrieving revocation registry") from err + + try: + rev_reg_def = RevocationRegistryDefinition.load(rev_reg_def.raw_value) + except CredxError as err: + raise IndyIssuerError( + "Error loading revocation registry definition" + ) from err + + rev_crids = [] + failed_crids = [] + max_cred_num = rev_reg_def.max_cred_num + rev_info = rev_reg_info.value_json + used_ids = set(rev_info.get("used_ids") or []) + + for rev_id in cred_revoc_ids: + rev_id = int(rev_id) + if rev_id < 1 or rev_id > max_cred_num: + LOGGER.error( + "Skipping requested credential revocation" + "on rev reg id %s, cred rev id=%s not in range", + revoc_reg_id, + rev_id, + ) + elif rev_id > rev_info["curr_id"]: + LOGGER.warn( + "Skipping requested credential revocation" + "on rev reg id %s, cred rev id=%s not yet issued", + revoc_reg_id, + rev_id, + ) + elif rev_id in used_ids: + LOGGER.warn( + "Skipping requested credential revocation" + "on rev reg id %s, cred rev id=%s already revoked", + revoc_reg_id, + rev_id, + ) + else: + rev_crids.append(rev_id) + + if rev_crids: + try: + rev_reg = RevocationRegistry.load(rev_reg.raw_value) + delta = await asyncio.get_event_loop().run_in_executor( + None, + lambda: rev_reg.update( + rev_reg_def, + None, # issued + list(rev_crids), # revoked + tails_file_path, + ), + ) + except CredxError as err: + raise IndyIssuerError("Error updating revocation registry") from err + + try: + await txn.handle.replace( + CATEGORY_REV_REG, revoc_reg_id, rev_reg.to_json_buffer() + ) + used_ids.update(rev_crids) + rev_info["used_ids"] = list(used_ids) + await txn.handle.replace( + CATEGORY_REV_REG_INFO, revoc_reg_id, value_json=rev_info + ) + await txn.commit() + except StoreError as err: + raise IndyIssuerError("Error saving revocation registry") from err + else: + delta = None + + return (delta and delta.to_json(), failed_crids) + + async def merge_revocation_registry_deltas( + self, fro_delta: str, to_delta: str + ) -> str: + """ + Merge revocation registry deltas. + + Args: + fro_delta: original delta in JSON format + to_delta: incoming delta in JSON format + + Returns: + Merged delta in JSON format + + """ + + def update(d1, d2): + try: + delta = RevocationRegistryDelta.load(d1) + delta.update_with(d2) + return delta.to_json() + except CredxError as err: + raise IndyIssuerError( + "Error merging revocation registry deltas" + ) from err + + return await asyncio.get_event_loop().run_in_executor( + None, update, fro_delta, to_delta + ) + + async def create_and_store_revocation_registry( + self, + origin_did: str, + cred_def_id: str, + revoc_def_type: str, + tag: str, + max_cred_num: int, + tails_base_path: str, + ) -> Tuple[str, str, str]: + """ + Create a new revocation registry and store it in the wallet. + + Args: + origin_did: the DID issuing the revocation registry + cred_def_id: the identifier of the related credential definition + revoc_def_type: the revocation registry type (default CL_ACCUM) + tag: the unique revocation registry tag + max_cred_num: the number of credentials supported in the registry + tails_base_path: where to store the tails file + issuance_type: optionally override the issuance type + + Returns: + A tuple of the revocation registry ID, JSON, and entry JSON + + """ + try: + async with self._profile.session() as session: + cred_def = await session.handle.fetch(CATEGORY_CRED_DEF, cred_def_id) + except StoreError as err: + raise IndyIssuerError("Error retrieving credential definition") from err + if not cred_def: + raise IndyIssuerError( + "Credential definition not found for revocation registry" + ) + + try: + ( + rev_reg_def, + rev_reg_def_private, + rev_reg, + rev_reg_delta, + ) = await asyncio.get_event_loop().run_in_executor( + None, + lambda: RevocationRegistryDefinition.create( + origin_did, + cred_def.raw_value, + tag, + revoc_def_type, + max_cred_num, + tails_dir_path=tails_base_path, + ), + ) + except CredxError as err: + raise IndyIssuerError("Error creating revocation registry") from err + + rev_reg_def_id = rev_reg_def.id + rev_reg_def_json = rev_reg_def.to_json() + rev_reg_json = rev_reg.to_json() + + try: + async with self._profile.transaction() as txn: + await txn.handle.insert(CATEGORY_REV_REG, rev_reg_def_id, rev_reg_json) + await txn.handle.insert( + CATEGORY_REV_REG_INFO, + rev_reg_def_id, + value_json={"curr_id": 0, "used_ids": []}, + ) + await txn.handle.insert( + CATEGORY_REV_REG_DEF, rev_reg_def_id, rev_reg_def_json + ) + await txn.handle.insert( + CATEGORY_REV_REG_DEF_PRIVATE, + rev_reg_def_id, + rev_reg_def_private.to_json_buffer(), + ) + await txn.commit() + except StoreError as err: + raise IndyIssuerError("Error saving new revocation registry") from err + + return ( + rev_reg_def_id, + rev_reg_def_json, + rev_reg_json, + ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py index e92daa027c..8caa3b4250 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py @@ -98,4 +98,4 @@ async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = @abstractmethod async def store_credential(self, cred_ex_record: V20CredExRecord, cred_id: str): - """""" \ No newline at end of file + """""" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/test_indy.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/test_indy.py index e8ed1fe3f2..f068b6553e 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/test_indy.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/test_indy.py @@ -118,4 +118,4 @@ async def test_get_detail_record(self): cred_rev_id="0", ) await detail_indy.save(self.session) - assert await self.handler.get_detail_record(cred_ex_id) == detail_indy \ No newline at end of file + assert await self.handler.get_detail_record(cred_ex_id) == detail_indy diff --git a/aries_cloudagent/vc/ld_proofs/constants.py b/aries_cloudagent/vc/ld_proofs/constants.py index 58a829645f..1cb6ad055d 100644 --- a/aries_cloudagent/vc/ld_proofs/constants.py +++ b/aries_cloudagent/vc/ld_proofs/constants.py @@ -1 +1 @@ -SECURITY_CONTEXT_V2_URL = 'https://w3id.org/security/v2' +SECURITY_CONTEXT_V2_URL = "https://w3id.org/security/v2" diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Base58Encoder.py b/aries_cloudagent/vc/ld_proofs/crypto/Base58Encoder.py index 7159d77563..9b1e72cf4f 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/Base58Encoder.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/Base58Encoder.py @@ -2,10 +2,10 @@ class Base58Encoder(object): - @staticmethod - def encode(data): - return base58.b58encode(data) + @staticmethod + def encode(data): + return base58.b58encode(data) - @staticmethod - def decode(data): - return base58.b58decode(data) + @staticmethod + def decode(data): + return base58.b58decode(data) diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py index 5eb8e17549..da30f0f7ca 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py @@ -5,30 +5,30 @@ class Ed25519KeyPair(KeyPair): - def __init__(self, public_key_base_58: bytes): - self.public_key = b58decode(public_key_base_58) - self.verifier = VerifyKey(self.public_key) - self.signer = None + def __init__(self, public_key_base_58: bytes): + self.public_key = b58decode(public_key_base_58) + self.verifier = VerifyKey(self.public_key) + self.signer = None - @classmethod - def generate(cls, seed: str = None): - if seed: - signer = SigningKey(seed) - else: - signer = SigningKey.generate() + @classmethod + def generate(cls, seed: str = None): + if seed: + signer = SigningKey(seed) + else: + signer = SigningKey.generate() - key_pair = cls(b58encode(signer.verify_key._key)) - key_pair.signer = signer - key_pair.public_key = key_pair.verifier._key - key_pair.private_key = signer._signing_key + key_pair = cls(b58encode(signer.verify_key._key)) + key_pair.signer = signer + key_pair.public_key = key_pair.verifier._key + key_pair.private_key = signer._signing_key - return key_pair + return key_pair - def sign(self, message): - if not self.signer: - raise Exception('No signer defined') + def sign(self, message): + if not self.signer: + raise Exception("No signer defined") - return self.signer.sign(message, Base64Encoder) + return self.signer.sign(message, Base64Encoder) - def verify(self, message): - return self.verifier.verify(message) + def verify(self, message): + return self.verifier.verify(message) diff --git a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py index 44511fbc96..8f6c390558 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py @@ -2,10 +2,10 @@ class KeyPair(ABC): - @abstractmethod - def sign(self, message): - pass + @abstractmethod + def sign(self, message): + pass - @abstractmethod - def verify(self, message): - pass + @abstractmethod + def verify(self, message): + pass diff --git a/aries_cloudagent/vc/ld_proofs/mock/test_documents.py b/aries_cloudagent/vc/ld_proofs/mock/test_documents.py index 0c5e4ef9b4..08c6fa127e 100644 --- a/aries_cloudagent/vc/ld_proofs/mock/test_documents.py +++ b/aries_cloudagent/vc/ld_proofs/mock/test_documents.py @@ -1,19 +1,22 @@ from ..constants import SECURITY_CONTEXT_V2_URL non_security_context_test_doc = { - '@context': { - 'schema': 'http://schema.org/', - 'name': 'schema:name', - 'homepage': 'schema:url', - 'image': 'schema:image' + "@context": { + "schema": "http://schema.org/", + "name": "schema:name", + "homepage": "schema:url", + "image": "schema:image", }, - 'name': 'Manu Sporny', - 'homepage': 'https://manu.sporny.org/', - 'image': 'https://manu.sporny.org/images/manu.png' + "name": "Manu Sporny", + "homepage": "https://manu.sporny.org/", + "image": "https://manu.sporny.org/images/manu.png", } security_context_test_doc = { - **non_security_context_test_doc, '@context': [{ - '@version': 1.1 - }, non_security_context_test_doc['@context'], SECURITY_CONTEXT_V2_URL] + **non_security_context_test_doc, + "@context": [ + {"@version": 1.1}, + non_security_context_test_doc["@context"], + SECURITY_CONTEXT_V2_URL, + ], } diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py index 87199703e3..11f617a317 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py @@ -3,9 +3,8 @@ class AssertionProofPurpose(ControllerProofPurpose): - def __init__(self, date: datetime, max_timestamp_delta: timedelta = None): - super().__init__('assertionMethod', date, max_timestamp_delta) - + def __init__(self, date: datetime, max_timestamp_delta: timedelta = None): + super().__init__("assertionMethod", date, max_timestamp_delta) __all__ = [AssertionProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py index 3c2df1d71c..4d26fa26f5 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py @@ -6,31 +6,39 @@ # TODO Move this file to the vc lib class IssueCredentialProofPurpose(AssertionProofPurpose): - def __init__(self, date: datetime, max_timestamp_delta: None): - super().__init__(date, max_timestamp_delta) + def __init__(self, date: datetime, max_timestamp_delta: None): + super().__init__(date, max_timestamp_delta) - async def validate( - self, proof: dict, document: dict, suite: LinkedDataSignature, - verification_method: str, document_loader: callable): - try: - result = super().validate(proof, verification_method, document_loader) + async def validate( + self, + proof: dict, + document: dict, + suite: LinkedDataSignature, + verification_method: str, + document_loader: callable, + ): + try: + result = super().validate(proof, verification_method, document_loader) - if not result['valid']: - raise result['error'] + if not result["valid"]: + raise result["error"] - issuer = jsonld.get_values( - document, 'https://www.w3.org/2018/credentials#issuer') + issuer = jsonld.get_values( + document, "https://www.w3.org/2018/credentials#issuer" + ) - if not issuer or issuer.len() == 0: - raise Exception('Credential issuer is required.') + if not issuer or issuer.len() == 0: + raise Exception("Credential issuer is required.") - if result['controller']['id'] != issuer[0]['id']: - raise Exception( - 'Credential issuer must match the verification method controller.') + if result["controller"]["id"] != issuer[0]["id"]: + raise Exception( + "Credential issuer must match the verification method controller." + ) - return {'valid': True} + return {"valid": True} + + except Exception as e: + return {"valid": False, "error": e} - except Exception as e: - return {'valid': False, 'error': e} __all__ = [IssueCredentialProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/purposes/__init__.py b/aries_cloudagent/vc/ld_proofs/purposes/__init__.py index 4cac8d326d..5b44f5d362 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/__init__.py @@ -5,9 +5,9 @@ from .PublicKeyProofPurpose import PublicKeyProofPurpose __all__ = [ - ProofPurpose, - ControllerProofPurpose, - AssertionProofPurpose, - AuthenticationProofPurpose, - PublicKeyProofPurpose + ProofPurpose, + ControllerProofPurpose, + AssertionProofPurpose, + AuthenticationProofPurpose, + PublicKeyProofPurpose, ] diff --git a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py index a65bc1d730..061e163642 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py +++ b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py @@ -1,3 +1,6 @@ +from datetime import datetime +from typing import Union + from ..crypto import Ed25519KeyPair from .JwsLinkedDataSignature import JwsLinkedDataSignature @@ -10,7 +13,7 @@ def __init__( date: Union[datetime, str] = None, ): super().__init__( - signature_type="Ed25519Signature", + signature_type="Ed25519Signature2018", algorithm="EdDSA", key_pair=Ed25519KeyPair, verification_method=verification_method, diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py index 5cca3db3d4..5161876164 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -7,162 +7,177 @@ from typing import Union from ..constants import SECURITY_CONTEXT_V2_URL + class LinkedDataSignature(LinkedDataProof, metaclass=ABCMeta): - def __init__( - self, - signature_type: str, - verification_method: str, - *, - proof: dict = None, - date: Union[datetime, str]): - super().__init__(signature_type) - self.verification_method = verification_method - self.proof = proof - self.date = date - - async def create_proof( - self, document: dict, purpose: ProofPurpose, document_loader: callable, - compact_proof: bool) -> dict: - proof = None - if self.proof: - # TODO remove hardcoded security context - # TODO verify if the other optional params shown in jsonld-signatures are - # required - proof = jsonld.compact( - self.proof, SECURITY_CONTEXT_V2_URL, - {'documentLoader': document_loader}) - else: - proof = {'@context': SECURITY_CONTEXT_V2_URL} - - proof['type'] = self.signature_type - - # TODO validate existance and type of date more carefully - # see: jsonld-signatures implementation - if not self.date: - self.date = datetime.now().isoformat() - - if not proof.get('created'): - proof['created'] = str(self.date.isoformat()) - - if self.verification_method: - proof[ - 'verificationMethod'] = f'{self.verification_method}#{self.verification_method[8:]}' - - proof = await self.update_proof(proof) - - proof = purpose.update(proof) - verify_data = await self.create_verify_data( - document, proof, document_loader) - - proof = await self.sign(verify_data, proof) - return proof - - async def create_verify_data( - self, document: dict, proof: dict, document_loader: dict) -> str: - c14n_proof_options = await self.canonize_proof( - proof, document_loader=document_loader) - print(c14n_proof_options) - c14n_doc = await self.canonize(document, document_loader=document_loader) - hash = sha256(c14n_proof_options.encode()) - hash.update(c14n_doc.encode()) - - # TODO verify this is the right return type - return hash.digest() - - async def canonize(self, input_, *, document_loader: callable = None): - return jsonld.normalize( - input_, { - 'algorithm': 'URDNA2015', - 'format': 'application/n-quads', - 'documentLoader': document_loader - }) - - async def canonize_proof( - self, proof: dict, *, document_loader: callable = None): - print(proof) - proof = proof.copy() - - # TODO check if these values ever exist in our use case - # del proof['jws'] - # del proof['signatureValue'] - # del proof['proofValue'] - - return await self.canonize(proof, document_loader=document_loader) - - async def update_proof(self, proof: dict): - """ - Extending classes may do more - """ - return proof - - async def verify_proof( - self, - proof: dict, - document: dict, - purpose: ProofPurpose, - document_loader: callable, - ): - try: - verify_data = await self.create_verify_data( - document, proof, document_loader=document_loader) - verification_method = await self.get_verification_method( - proof, document_loader=document_loader) - - verified = await self.verify_signature( - verify_data=verify_data, - verification_method=verification_method, - document=document, - proof=proof, - document_loader=document_loader) - - if not verified: - raise Exception('Invalid signature') - - purpose_result = await purpose.validate( - proof, - document=document, - suite=self, - verification_method=verification_method, - document_loader=document_loader) - - if not purpose_result['valid']: - raise purpose_result['error'] - - return {'verified': True, 'purpose_result': purpose_result} - except Exception as err: - return {'verified': False, 'error': err} - - async def get_verification_method( - self, proof: dict, document_loader: callable): - - verification_method = proof.get('verificationMethod') - if not verification_method: - raise Exception('No "verificationMethod" found in proof') - - framed = await jsonld.frame( - verification_method, { - '@context': SECURITY_CONTEXT_V2_URL, - '@embed': '@always', - 'id': verification_method - }, - options={'documentLoader': document_loader}) - - if not framed: - raise Exception(f'Verification method {verification_method} not found') - - if framed.get('revoked'): - raise Exception('The verification method has been revoked.') - - return framed - - @abstractmethod - def sign(self, verify_data: bytes, proof: dict): - pass - - @abstractmethod - def verify_signature( - self, verify_data: bytes, verification_method: dict, proof: dict): - pass + def __init__( + self, + signature_type: str, + verification_method: str, + *, + proof: dict = None, + date: Union[datetime, str], + ): + super().__init__(signature_type) + self.verification_method = verification_method + self.proof = proof + self.date = date + + async def create_proof( + self, + document: dict, + purpose: ProofPurpose, + document_loader: callable, + compact_proof: bool, + ) -> dict: + proof = None + if self.proof: + # TODO remove hardcoded security context + # TODO verify if the other optional params shown in jsonld-signatures are + # required + proof = jsonld.compact( + self.proof, SECURITY_CONTEXT_V2_URL, {"documentLoader": document_loader} + ) + else: + proof = {"@context": SECURITY_CONTEXT_V2_URL} + + proof["type"] = self.signature_type + + # TODO validate existance and type of date more carefully + # see: jsonld-signatures implementation + if not self.date: + self.date = datetime.now().isoformat() + + if not proof.get("created"): + proof["created"] = str(self.date.isoformat()) + + if self.verification_method: + proof[ + "verificationMethod" + ] = f"{self.verification_method}#{self.verification_method[8:]}" + + proof = await self.update_proof(proof) + + proof = purpose.update(proof) + verify_data = await self.create_verify_data(document, proof, document_loader) + + proof = await self.sign(verify_data, proof) + return proof + + async def create_verify_data( + self, document: dict, proof: dict, document_loader: dict + ) -> str: + c14n_proof_options = await self.canonize_proof( + proof, document_loader=document_loader + ) + print(c14n_proof_options) + c14n_doc = await self.canonize(document, document_loader=document_loader) + hash = sha256(c14n_proof_options.encode()) + hash.update(c14n_doc.encode()) + + # TODO verify this is the right return type + return hash.digest() + + async def canonize(self, input_, *, document_loader: callable = None): + return jsonld.normalize( + input_, + { + "algorithm": "URDNA2015", + "format": "application/n-quads", + "documentLoader": document_loader, + }, + ) + + async def canonize_proof(self, proof: dict, *, document_loader: callable = None): + print(proof) + proof = proof.copy() + + # TODO check if these values ever exist in our use case + # del proof['jws'] + # del proof['signatureValue'] + # del proof['proofValue'] + + return await self.canonize(proof, document_loader=document_loader) + + async def update_proof(self, proof: dict): + """ + Extending classes may do more + """ + return proof + + async def verify_proof( + self, + proof: dict, + document: dict, + purpose: ProofPurpose, + document_loader: callable, + ): + try: + verify_data = await self.create_verify_data( + document, proof, document_loader=document_loader + ) + verification_method = await self.get_verification_method( + proof, document_loader=document_loader + ) + + verified = await self.verify_signature( + verify_data=verify_data, + verification_method=verification_method, + document=document, + proof=proof, + document_loader=document_loader, + ) + + if not verified: + raise Exception("Invalid signature") + + purpose_result = await purpose.validate( + proof, + document=document, + suite=self, + verification_method=verification_method, + document_loader=document_loader, + ) + + if not purpose_result["valid"]: + raise purpose_result["error"] + + return {"verified": True, "purpose_result": purpose_result} + except Exception as err: + return {"verified": False, "error": err} + + async def get_verification_method(self, proof: dict, document_loader: callable): + + verification_method = proof.get("verificationMethod") + if not verification_method: + raise Exception('No "verificationMethod" found in proof') + + framed = await jsonld.frame( + verification_method, + { + "@context": SECURITY_CONTEXT_V2_URL, + "@embed": "@always", + "id": verification_method, + }, + options={"documentLoader": document_loader}, + ) + + if not framed: + raise Exception(f"Verification method {verification_method} not found") + + if framed.get("revoked"): + raise Exception("The verification method has been revoked.") + + return framed + + @abstractmethod + def sign(self, verify_data: bytes, proof: dict): + pass + + @abstractmethod + def verify_signature( + self, verify_data: bytes, verification_method: dict, proof: dict + ): + pass __all__ = [LinkedDataSignature] diff --git a/aries_cloudagent/vc/vc_ld/checker.py b/aries_cloudagent/vc/vc_ld/checker.py index 9977c57b55..ec3d6315f3 100644 --- a/aries_cloudagent/vc/vc_ld/checker.py +++ b/aries_cloudagent/vc/vc_ld/checker.py @@ -79,4 +79,4 @@ def check_credential(credential: dict): ): raise Exception( f'"expirationDate" must be a valid date {credential["expirationDate"]}' - ) \ No newline at end of file + ) From 45b92b36cc9644a4047a44496bd21da3baae20a1 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Wed, 10 Mar 2021 13:53:39 +0100 Subject: [PATCH 010/138] initial issuance flow working Signed-off-by: Timo Glastra --- aries_cloudagent/messaging/valid.py | 77 ++++++++- .../issue_credential/v2_0/formats/ld_proof.py | 162 +++++++++++++----- .../v2_0/handlers/cred_request_handler.py | 14 +- .../v2_0/messages/cred_format.py | 2 +- .../protocols/issue_credential/v2_0/routes.py | 101 +++++++++-- aries_cloudagent/vc/ld_proofs/ProofSet.py | 10 +- aries_cloudagent/vc/ld_proofs/__init__.py | 5 +- .../vc/ld_proofs/crypto/Ed25519KeyPair.py | 37 +++- .../vc/ld_proofs/crypto/KeyPair.py | 4 +- .../vc/ld_proofs/crypto/__init__.py | 4 +- .../vc/ld_proofs/document_loader.py | 2 +- aries_cloudagent/vc/ld_proofs/ld_proofs.py | 8 +- .../purposes/AssertionProofPurpose.py | 8 +- .../purposes/ControllerProofPurpose.py | 14 +- .../purposes/IssueCredentialProofPurpose.py | 44 ----- .../ld_proofs/suites/Ed25519Signature2018.py | 5 +- .../suites/JwsLinkedDataSignature.py | 38 ++-- .../ld_proofs/suites/LinkedDataSignature.py | 33 ++-- aries_cloudagent/vc/vc_ld/checker.py | 2 +- aries_cloudagent/vc/vc_ld/issue.py | 13 +- aries_cloudagent/vc/vc_ld/prove.py | 15 +- .../purposes/IssueCredentialProofPurpose.py | 13 +- .../vc/vc_ld/purposes/__init__.py | 3 + aries_cloudagent/vc/vc_ld/verify.py | 25 ++- aries_cloudagent/wallet/routes.py | 3 + 25 files changed, 428 insertions(+), 214 deletions(-) delete mode 100644 aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py create mode 100644 aries_cloudagent/vc/vc_ld/purposes/__init__.py diff --git a/aries_cloudagent/messaging/valid.py b/aries_cloudagent/messaging/valid.py index 6e92233eaf..3907537151 100644 --- a/aries_cloudagent/messaging/valid.py +++ b/aries_cloudagent/messaging/valid.py @@ -5,7 +5,7 @@ from datetime import datetime from base58 import alphabet -from marshmallow.validate import OneOf, Range, Regexp +from marshmallow.validate import OneOf, Range, Regexp, Validator from marshmallow.exceptions import ValidationError from .util import epoch_to_str @@ -490,6 +490,16 @@ def __init__(self): ) +class URI(Regexp): + """Validate value against URI on any scheme.""" + + EXAMPLE = "https://www.w3.org/2018/credentials/v1" + PATTERN = r"^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?" + + def __init__(self): + super().__init__(URI.PATTERN, error="Value {input} is not URI") + + class Endpoint(Regexp): # using Regexp brings in nice visual validator cue """Validate value against endpoint URL on any scheme.""" @@ -524,6 +534,62 @@ def __init__(self): ) +class CredentialType(Validator): + + FIRST_TYPE = "VerifiableCredential" + EXAMPLE = [FIRST_TYPE, "AlumniCredential"] + + def __init__(self) -> None: + super().__init__() + + def __call__(self, value): + length = len(value) + + if length < 1 or value[0] != CredentialType.FIRST_TYPE: + raise ValidationError( + f"First type {value[0]} must be {CredentialType.FIRST_TYPE}" + ) + + return value + + +class CredentialContext(Validator): + FIRST_CONTEXT = "https://www.w3.org/2018/credentials/v1" + EXAMPLE = [FIRST_CONTEXT, "https://www.w3.org/2018/credentials/examples/v1"] + + def __init__(self) -> None: + super().__init__() + + def __call__(self, value): + length = len(value) + + if length < 1 or value[0] != CredentialContext.FIRST_CONTEXT: + raise ValidationError( + f"First context {value[0]} must be {CredentialContext.FIRST_CONTEXT}" + ) + + return value + + +class CredentialSubject(Validator): + + EXAMPLE = { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "alumniOf": {"id": "did:example:c276e12ec21ebfeb1f712ebc6f1"}, + } + + def __init__(self) -> None: + super().__init__() + + def __call__(self, value): + if "id" in value: + uri_validator = URI() + if not uri_validator(value["id"]): + raise ValidationError(f"credential subject id {value[0]} must be URI") + + return value + + # Instances for marshmallow schema specification INT_EPOCH = {"validate": IntEpoch(), "example": IntEpoch.EXAMPLE} WHOLE_NUM = {"validate": WholeNumber(), "example": WholeNumber.EXAMPLE} @@ -566,3 +632,12 @@ def __init__(self): UUID4 = {"validate": UUIDFour(), "example": UUIDFour.EXAMPLE} ENDPOINT = {"validate": Endpoint(), "example": Endpoint.EXAMPLE} ENDPOINT_TYPE = {"validate": EndpointType(), "example": EndpointType.EXAMPLE} +CREDENTIAL_TYPE = {"validate": CredentialType(), "example": CredentialType.EXAMPLE} +CREDENTIAL_CONTEXT = { + "validate": CredentialContext(), + "example": CredentialType.EXAMPLE, +} +CREDENTIAL_SUBJECT = { + "validate": CredentialSubject(), + "example": CredentialSubject.EXAMPLE, +} diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py index 421c28d6af..5cb05a1b25 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py @@ -1,19 +1,27 @@ -"""V2.0 indy issue-credential cred format.""" +"""V2.0 linked data proof issue-credential cred format.""" + import logging import uuid -import json -from typing import Mapping, Tuple +from typing import List, Mapping, Tuple from .....messaging.decorators.attach_decorator import AttachDecorator - +from .....vc.vc_ld import issue, verify_credential +from .....vc.ld_proofs import ( + Ed25519Signature2018, + Ed25519WalletKeyPair, + did_key_document_loader, + LinkedDataProof, +) from .....wallet.error import WalletNotFoundError from .....wallet.base import BaseWallet -from .....wallet.util import did_key_to_naked +from .....wallet.util import did_key_to_naked, naked_to_did_key from ..messages.cred_format import V20CredFormat from ..messages.cred_offer import V20CredOffer +from ..messages.cred_proposal import V20CredProposal +from ..messages.cred_issue import V20CredIssue from ..messages.cred_request import V20CredRequest from ..models.cred_ex_record import V20CredExRecord from ..formats.handler import V20CredFormatError, V20CredFormatHandler @@ -31,6 +39,10 @@ def get_id(obj) -> str: return obj["id"] +# TODO: move to vc/proof library +SUPPORTED_PROOF_TYPES = {"Ed25519Signature2018"} + + class LDProofCredFormatHandler(V20CredFormatHandler): format = V20CredFormat.Format.LD_PROOF @@ -40,14 +52,57 @@ def validate_filter(cls, data: Mapping): # TODO: validate LDProof credential filter pass + async def _assert_can_sign_with_did(self, did: str): + async with self.profile.session() as session: + try: + wallet = session.inject(BaseWallet) + + # Check if issuer is something we can issue with + assert did.startswith("did:key") + verkey = did_key_to_naked(did) + await wallet.get_local_did_for_verkey(verkey) + except WalletNotFoundError: + raise V20CredFormatError( + f"Issuer did {did} not found. Unable to issue credential with this DID." + ) + + async def _assert_can_sign_with_types(self, proof_types: List[str]): + # Check if all proof types are supported + if not set(proof_types).issubset(SUPPORTED_PROOF_TYPES): + raise V20CredFormatError( + f"Unsupported proof type(s): {proof_types - SUPPORTED_PROOF_TYPES}." + ) + + async def _get_suite_for_type(self, did: str, proof_type: str) -> LinkedDataProof: + await self._assert_can_sign_with_types([proof_type]) + + async with self.profile.session() as session: + # TODO: maybe keypair should start session and inject wallet (for shorter sessions) + wallet = session.inject(BaseWallet) + + if proof_type == "Ed25519Signature2018": + verification_method = self._get_verification_method(did) + verkey = did_key_to_naked(did) + + return Ed25519Signature2018( + verification_method=verification_method, + key_pair=Ed25519WalletKeyPair(verkey, wallet), + ) + else: + raise V20CredFormatError(f"Unsupported proof type {proof_type}") + + def _get_verification_method(self, did: str): + verification_method = did + "#" + did.replace("did:key:", "") + + return verification_method + async def create_proposal( self, cred_ex_record: V20CredExRecord, filter: Mapping[str, str] ) -> Tuple[V20CredFormat, AttachDecorator]: - id = uuid.uuid4() - + # TODO: validate credential proposal structure return ( - V20CredFormat(attach_id=id, format_=self.format), - AttachDecorator.data_base64(filter, ident=id), + V20CredFormat(attach_id="ld_proof", format_=self.format), + AttachDecorator.data_base64(filter, ident="ld_proof"), ) async def receive_offer( @@ -57,41 +112,28 @@ async def receive_offer( # TODO: add filter async def create_offer( - self, cred_ex_record: V20CredExRecord, filter: Mapping = None + self, cred_ex_record: V20CredExRecord ) -> Tuple[V20CredFormat, AttachDecorator]: - wallet = self.profile.inject(BaseWallet) - # TODO: - # - Check if first @context is "https://www.w3.org/2018/credentials/v1" - # - Check if first type is "VerifiableCredential" # - Check if all fields in credentialSubject are present in context # - Check if all required fields are present (according to RFC). Or is the API going to do this? # - Other checks (credentialStatus, credentialSchema, etc...) - try: - # Check if issuer is something we can issue with - issuer_did = get_id(filter["issuer"]) - assert issuer_did.startswith(issuer_did, "did:key") - verkey = did_key_to_naked(issuer_did) - did_info = await wallet.get_local_did_for_verkey(verkey) - - # Check if all proposed proofTypes are supported - supported_proof_types = {"Ed25519VerificationKey2018"} - proof_types = set(filter["proofTypes"]) - if not set(proof_types).issubset(supported_proof_types): - raise V20CredFormatError( - f"Unsupported proof type(s): {proof_types - supported_proof_types}." - ) + # TODO: validate credential structure + filter = V20CredProposal.deserialize(cred_ex_record.cred_proposal).filter( + self.format + ) - except WalletNotFoundError: - raise V20CredFormatError( - f"Issuer did {did_info} not found. Unable to issue credential with this DID." - ) + credential = filter["credential"] + options = filter["options"] + + await self._assert_can_sign_with_did(credential["issuer"]) + await self._assert_can_sign_with_types(options["proofType"]) id = uuid.uuid4() return ( V20CredFormat(attach_id=id, format_=self.format), - AttachDecorator.data_base64(filter, ident=id), + AttachDecorator.data_json(filter, ident=id), ) async def create_request( @@ -105,9 +147,15 @@ async def create_request( self.format ) - if not cred_detail["credentialSubject"]["id"] and holder_did: - # TODO: holder_did is not always did, must transform first - cred_detail["credentialSubject"]["id"] = holder_did + if not cred_detail["credential"]["credentialSubject"]["id"] and holder_did: + async with self.profile.session() as session: + wallet = session.inject(BaseWallet) + + did_info = await wallet.get_local_did(holder_did) + did_key = naked_to_did_key(did_info.verkey) + + cred_detail["credential"]["credentialSubject"]["id"] = did_key + else: # TODO: start from request cred_detail = None @@ -115,16 +163,18 @@ async def create_request( id = uuid.uuid4() return ( V20CredFormat(attach_id=id, format_=self.format), - AttachDecorator.data_base64(cred_detail, ident=id), + AttachDecorator.data_json(cred_detail, ident=id), ) async def receive_request( self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest ): + # TODO: check if request matches offer. (If not send problem report?) pass async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = 5): if cred_ex_record.cred_offer: + # TODO: match offer with request. Use request (because of credential subject id) cred_detail = V20CredOffer.deserialize(cred_ex_record.cred_offer).offer( self.format ) @@ -133,15 +183,39 @@ async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = cred_ex_record.cred_request ).cred_request(self.format) - # TODO: create credential - # TODO: issue with public did:sov - vc = cred_detail + issuer_did = get_id(cred_detail["credential"]["issuer"]) + proof_types = cred_detail["options"]["proofType"] + + if len(proof_types) > 1: + raise V20CredFormatError( + "Issuing credential with multiple proof types not supported." + ) + + await self._assert_can_sign_with_did(issuer_did) + suite = await self._get_suite_for_type(issuer_did, proof_types[0]) + + vc = await issue(cred_detail["credential"], suite) - id = uuid.uuid4() return ( - V20CredFormat(attach_id=id, format_=self.format), - AttachDecorator.data_base64(json.loads(vc), ident=id), + V20CredFormat(attach_id="ld_proof", format_=self.format), + AttachDecorator.data_json(vc, ident="ld_proof"), ) async def store_credential(self, cred_ex_record: V20CredExRecord, cred_id: str): - pass \ No newline at end of file + vc = V20CredIssue.deserialize(cred_ex_record.cred_issue).cred(self.format) + + async with self.profile.session() as session: + wallet = session.inject(BaseWallet) + + verification_method: str = vc["proof"]["verificationMethod"] + verkey = did_key_to_naked(verification_method.split("#")[0]) + + # TODO: API rework. + suite = Ed25519Signature2018( + verification_method=verification_method, + key_pair=Ed25519WalletKeyPair(verkey, wallet), + ) + + valid = await verify_credential(vc, did_key_document_loader, suite) + + print("is valid: ", valid) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py index 94327b5ea3..b8b2a355d3 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py @@ -52,12 +52,14 @@ async def handle(self, context: RequestContext, responder: BaseResponder): # If auto_issue is enabled, respond immediately if cred_ex_record.auto_issue: - if ( - cred_ex_record.cred_proposal - and V20CredProposal.deserialize( - cred_ex_record.cred_proposal - ).credential_preview - ): + # TODO: can only auto_issue for indy if cred proposal is present + # if ( + # cred_ex_record.cred_proposal + # and V20CredProposal.deserialize( + # cred_ex_record.cred_proposal + # ).credential_preview + # ): + if True: ( cred_ex_record, cred_issue_message, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py index 44c8b2b04d..efe1b2e00d 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py @@ -43,7 +43,7 @@ class Format(Enum): ) LD_PROOF = FormatSpec( "dif/credential-manifest@v1.0", - ["ldproof", "jsonld"], + ["ld_proof", "ldproof", "jsonld"], V20CredExRecordLDProof, f"{PROTOCOL_PACKAGE}.formats.ld_proof.LDProofCredFormatHandler", ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py index cb7e41c572..3eda12f84d 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py @@ -11,7 +11,7 @@ request_schema, response_schema, ) -from marshmallow import fields, validate, validates_schema, ValidationError +from marshmallow import fields, validate, validates_schema, ValidationError, Schema from ....admin.request_context import AdminRequestContext from ....connections.models.conn_record import ConnRecord @@ -21,10 +21,14 @@ from ....messaging.decorators.attach_decorator import AttachDecorator from ....messaging.models.base import BaseModelError, OpenAPISchema from ....messaging.valid import ( + CREDENTIAL_CONTEXT, + CREDENTIAL_SUBJECT, + CREDENTIAL_TYPE, INDY_CRED_DEF_ID, INDY_DID, INDY_SCHEMA_ID, INDY_VERSION, + URI, UUIDFour, UUID4, ) @@ -148,13 +152,77 @@ class V20CredFilterIndySchema(OpenAPISchema): ) +class LDCredential(Schema): + context = fields.List( + fields.Str(), + data_key="@context", + required=True, + description="The JSON-LD context of the credential", + **CREDENTIAL_CONTEXT, + ) + id = fields.Str( + required=False, + desscription="The ID of the credential", + example="http://example.edu/credentials/1872", + validate=URI(), + ) + type = fields.List( + fields.Str(), + required=True, + description="The JSON-LD type of the credential", + **CREDENTIAL_TYPE, + ) + issuer = fields.Str( + required=False, description="The JSON-LD Verifiable Credential Issuer" + ) + issuance_date = fields.DateTime( + data_key="issuanceDate", + required=False, + description="The issuance date", + example="2010-01-01T19:73:24Z", + ) + expiration_date = fields.DateTime( + data_key="expirationDate", + required=False, + description="The expiration date", + example="2010-01-01T19:73:24Z", + ) + credential_subject = fields.Dict( + required=True, + keys=fields.Str(), + data_key="credentialSubject", + **CREDENTIAL_SUBJECT, + ) + # TODO: add typing + credential_schema = fields.Dict(required=False, data_key="credentialSchema") + + +class LDCredentialOptions(Schema): + proof_type = fields.List( + fields.Str( + validate=validate.OneOf(["Ed25519Signature2018", "BbsBlsSignature2020"]) + ), + required=True, + data_key="proofType", + description="Proof types to use for the linked data proof. Entries should match suites registered in the Linked Data Cryptographic Suite Registry.", + example=[ + "Ed25519VerificationKey", + ], + ) + + # TODO: add typing + credential_status = fields.Dict(required=False, data_key="credentialStatus") + + class V20CredFilterLDProofSchema(OpenAPISchema): """Linked data proof credential filtration criteria.""" - some_ld_proof_criterion = fields.Str( - description="Placeholder for W3C/JSON-LD proof filtration criterion", - required=False, + credential = fields.Nested( + LDCredential, + required=True, + description="JSON-LD credential data", ) + options = fields.Nested(LDCredentialOptions) class V20CredFilterSchema(OpenAPISchema): @@ -488,7 +556,8 @@ async def credential_exchange_create(request: web.BaseRequest): tags=["issue-credential v2.0"], summary="Send holder a credential, automating entire flow", ) -@request_schema(V20CredProposalRequestPreviewMandSchema()) +# TODO: make credential preview mandatory if indy filter is present +@request_schema(V20CredProposalRequestPreviewOptSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") async def credential_exchange_send(request: web.BaseRequest): """ @@ -514,19 +583,23 @@ async def credential_exchange_send(request: web.BaseRequest): comment = body.get("comment") connection_id = body.get("connection_id") - preview_spec = body.get("credential_preview") - if not preview_spec: - raise web.HTTPBadRequest(reason="Missing credential_preview") filt_spec = body.get("filter") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") + preview_spec = body.get("credential_preview") + # TODO: use generic format identifier + if "indy" in filt_spec and not preview_spec: + raise web.HTTPBadRequest(reason="Missing credential_preview for indy filter") auto_remove = body.get("auto_remove") trace_msg = body.get("trace") conn_record = None cred_ex_record = None try: - cred_preview = V20CredPreview.deserialize(preview_spec) + # Not all formats use credential preview + cred_preview = ( + V20CredPreview.deserialize(preview_spec) if preview_spec else None + ) async with context.session() as session: conn_record = await ConnRecord.retrieve_by_id(session, connection_id) if not conn_record.is_ready: @@ -992,7 +1065,7 @@ async def credential_exchange_send_free_request(request: web.BaseRequest): body = await request.json() - conn_id = body.get("connection_id") + connection_id = body.get("connection_id") comment = body.get("comment") filt_spec = body.get("filter") if not filt_spec: @@ -1008,9 +1081,9 @@ async def credential_exchange_send_free_request(request: web.BaseRequest): cred_ex_record = None try: async with context.session() as session: - conn_record = await ConnRecord.retrieve_by_id(session, conn_id) + conn_record = await ConnRecord.retrieve_by_id(session, connection_id) if not conn_record.is_ready: - raise web.HTTPForbidden(reason=f"Connection {conn_id} not ready") + raise web.HTTPForbidden(reason=f"Connection {connection_id} not ready") cred_manager = V20CredManager(context.profile) @@ -1020,7 +1093,7 @@ async def credential_exchange_send_free_request(request: web.BaseRequest): ) cred_ex_record = await V20CredExRecord( - conn_id=conn_id, + connection_id=connection_id, auto_remove=auto_remove, cred_proposal=cred_proposal.serialize(), initiator=V20CredExRecord.INITIATOR_SELF, @@ -1044,7 +1117,7 @@ async def credential_exchange_send_free_request(request: web.BaseRequest): outbound_handler, ) - await outbound_handler(cred_request_message, connection_id=conn_id) + await outbound_handler(cred_request_message, connection_id=connection_id) trace_event( context.settings, diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py index 043301e9f3..69c539f87e 100644 --- a/aries_cloudagent/vc/ld_proofs/ProofSet.py +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -1,9 +1,9 @@ """Class to represent a linked data proof set.""" import asyncio from typing import Union, List -from pyld import jsonld +from pyld.jsonld import JsonLdProcessor -from .suites import LinkedDataProof, LinkedDataSignature +from .suites import LinkedDataProof from .purposes.ProofPurpose import ProofPurpose from .document_loader import DocumentLoader from .constants import SECURITY_CONTEXT_V2_URL @@ -32,9 +32,9 @@ async def add( if "@context" in proof: del proof["@context"] - jsonld.add_value(document, "proof", proof) + JsonLdProcessor.add_value(document, "proof", proof) - return proof + return document async def verify( self, @@ -76,7 +76,7 @@ async def verify( @staticmethod async def _get_proofs(document: dict, document_loader: DocumentLoader) -> dict: - proof_set = jsonld.get_values(document, "proof") + proof_set = JsonLdProcessor.get_values(document, "proof") del document["proof"] diff --git a/aries_cloudagent/vc/ld_proofs/__init__.py b/aries_cloudagent/vc/ld_proofs/__init__.py index 6b6bb22c5c..b4a13bb508 100644 --- a/aries_cloudagent/vc/ld_proofs/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/__init__.py @@ -6,7 +6,6 @@ AuthenticationProofPurpose, PublicKeyProofPurpose, AssertionProofPurpose, - IssueCredentialProofPurpose, ) from .suites import ( LinkedDataProof, @@ -14,7 +13,7 @@ JwsLinkedDataSignature, Ed25519Signature2018, ) -from .crypto import Base58Encoder, KeyPair, Ed25519KeyPair +from .crypto import Base58Encoder, KeyPair, Ed25519KeyPair, Ed25519WalletKeyPair from .document_loader import DocumentLoader, did_key_document_loader __all__ = [ @@ -26,7 +25,6 @@ AssertionProofPurpose, AuthenticationProofPurpose, PublicKeyProofPurpose, - IssueCredentialProofPurpose, LinkedDataProof, LinkedDataSignature, JwsLinkedDataSignature, @@ -34,6 +32,7 @@ Base58Encoder, KeyPair, Ed25519KeyPair, + Ed25519WalletKeyPair, DocumentLoader, did_key_document_loader, ] diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py index da30f0f7ca..e666d713fb 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py @@ -1,17 +1,21 @@ -from .KeyPair import KeyPair +from typing import Optional from base58 import b58decode, b58encode from nacl.encoding import Base64Encoder +from nacl.exceptions import BadSignatureError from nacl.signing import VerifyKey, SigningKey +from .KeyPair import KeyPair +from ....wallet.base import BaseWallet + class Ed25519KeyPair(KeyPair): def __init__(self, public_key_base_58: bytes): self.public_key = b58decode(public_key_base_58) self.verifier = VerifyKey(self.public_key) - self.signer = None + self.signer: Optional[SigningKey] = None @classmethod - def generate(cls, seed: str = None): + def generate(cls, seed: str = None) -> "Ed25519KeyPair": if seed: signer = SigningKey(seed) else: @@ -24,11 +28,30 @@ def generate(cls, seed: str = None): return key_pair - def sign(self, message): + async def sign(self, message: bytes) -> bytes: if not self.signer: raise Exception("No signer defined") - return self.signer.sign(message, Base64Encoder) + return self.signer.sign(message, Base64Encoder).signature + + async def verify(self, message: bytes) -> bool: + try: + self.verifier.verify(message) + return True + except BadSignatureError: + return False + + +class Ed25519WalletKeyPair(KeyPair): + def __init__(self, verkey: str, wallet: BaseWallet): + self.verkey = verkey + self.wallet = wallet + + async def sign(self, message: bytes) -> bytes: + return await self.wallet.sign_message( + message, + self.verkey, + ) - def verify(self, message): - return self.verifier.verify(message) + async def verify(self, message: bytes, signature: bytes) -> bool: + return await self.wallet.verify_message(message, signature, self.verkey) diff --git a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py index 8f6c390558..3f160a37c9 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py @@ -3,9 +3,9 @@ class KeyPair(ABC): @abstractmethod - def sign(self, message): + async def sign(self, message: bytes) -> bytes: pass @abstractmethod - def verify(self, message): + async def verify(self, message: bytes) -> bool: pass diff --git a/aries_cloudagent/vc/ld_proofs/crypto/__init__.py b/aries_cloudagent/vc/ld_proofs/crypto/__init__.py index 36578d8573..bece5c1415 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/__init__.py @@ -1,5 +1,5 @@ from .Base58Encoder import Base58Encoder from .KeyPair import KeyPair -from .Ed25519KeyPair import Ed25519KeyPair +from .Ed25519KeyPair import Ed25519KeyPair, Ed25519WalletKeyPair -__all__ = [Base58Encoder, KeyPair, Ed25519KeyPair] +__all__ = [Base58Encoder, KeyPair, Ed25519KeyPair, Ed25519WalletKeyPair] diff --git a/aries_cloudagent/vc/ld_proofs/document_loader.py b/aries_cloudagent/vc/ld_proofs/document_loader.py index 4ccbcdb1b0..9217425078 100644 --- a/aries_cloudagent/vc/ld_proofs/document_loader.py +++ b/aries_cloudagent/vc/ld_proofs/document_loader.py @@ -46,6 +46,6 @@ def did_key_document_loader(url: str, options: dict): ) -DocumentLoader = Callable[str, dict] +DocumentLoader = Callable[[str, dict], dict] __all__ = [DocumentLoader, did_key_document_loader] diff --git a/aries_cloudagent/vc/ld_proofs/ld_proofs.py b/aries_cloudagent/vc/ld_proofs/ld_proofs.py index 5801606606..6d027c7a82 100644 --- a/aries_cloudagent/vc/ld_proofs/ld_proofs.py +++ b/aries_cloudagent/vc/ld_proofs/ld_proofs.py @@ -19,7 +19,7 @@ async def sign( return await ProofSet().add( document=document, suite=suite, - purpose=ProofPurpose, + purpose=purpose, document_loader=document_loader, ) @@ -35,14 +35,14 @@ async def sign( async def verify( *, document: Union[dict, str], - suite: LinkedDataSignature, + suites: LinkedDataSignature, purpose: ProofPurpose, document_loader: DocumentLoader, ): result = await ProofSet().verify( document=document, - suite=suite, - purpose=ProofPurpose, + suites=suites, + purpose=purpose, document_loader=document_loader, ) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py index 11f617a317..6f48a4babe 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py @@ -1,10 +1,8 @@ -from .ControllerProofPurpose import ControllerProofPurpose from datetime import datetime, timedelta +from .ControllerProofPurpose import ControllerProofPurpose + class AssertionProofPurpose(ControllerProofPurpose): - def __init__(self, date: datetime, max_timestamp_delta: timedelta = None): + def __init__(self, date: datetime = None, max_timestamp_delta: timedelta = None): super().__init__("assertionMethod", date, max_timestamp_delta) - - -__all__ = [AssertionProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py index 7fb8fb857b..1e772e05e5 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py @@ -1,9 +1,11 @@ -from .ProofPurpose import ProofPurpose -from ..constants import SECURITY_CONTEXT_V2_URL -from pyld import jsonld -from typing import Union from datetime import datetime, timedelta +from pyld import jsonld +from pyld.jsonld import JsonLdProcessor + +from ..constants import SECURITY_CONTEXT_V2_URL +from .ProofPurpose import ProofPurpose + class ControllerProofPurpose(ProofPurpose): def __init__( @@ -33,7 +35,9 @@ async def validate( result["controller"] = framed verification_id = verification_method["id"] - verification_methods = jsonld.get_values(result["controller"], self.term) + verification_methods = JsonLdProcessor.get_values( + result["controller"], self.term + ) result["valid"] = any( method == verification_id for method in verification_methods ) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py deleted file mode 100644 index 4d26fa26f5..0000000000 --- a/aries_cloudagent/vc/ld_proofs/purposes/IssueCredentialProofPurpose.py +++ /dev/null @@ -1,44 +0,0 @@ -from .AssertionProofPurpose import AssertionProofPurpose -from datetime import datetime, timedelta -from ..suites import LinkedDataSignature -from pyld import jsonld - - -# TODO Move this file to the vc lib -class IssueCredentialProofPurpose(AssertionProofPurpose): - def __init__(self, date: datetime, max_timestamp_delta: None): - super().__init__(date, max_timestamp_delta) - - async def validate( - self, - proof: dict, - document: dict, - suite: LinkedDataSignature, - verification_method: str, - document_loader: callable, - ): - try: - result = super().validate(proof, verification_method, document_loader) - - if not result["valid"]: - raise result["error"] - - issuer = jsonld.get_values( - document, "https://www.w3.org/2018/credentials#issuer" - ) - - if not issuer or issuer.len() == 0: - raise Exception("Credential issuer is required.") - - if result["controller"]["id"] != issuer[0]["id"]: - raise Exception( - "Credential issuer must match the verification method controller." - ) - - return {"valid": True} - - except Exception as e: - return {"valid": False, "error": e} - - -__all__ = [IssueCredentialProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py index 061e163642..e52b848798 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py +++ b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Union -from ..crypto import Ed25519KeyPair +from ..crypto import Ed25519WalletKeyPair, Ed25519KeyPair from .JwsLinkedDataSignature import JwsLinkedDataSignature @@ -9,13 +9,14 @@ class Ed25519Signature2018(JwsLinkedDataSignature): def __init__( self, verification_method: str, + key_pair: Union[Ed25519WalletKeyPair, Ed25519KeyPair], proof: dict = None, date: Union[datetime, str] = None, ): super().__init__( signature_type="Ed25519Signature2018", algorithm="EdDSA", - key_pair=Ed25519KeyPair, + key_pair=key_pair, verification_method=verification_method, proof=proof, date=date, diff --git a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py index c19d94ee8d..a745f7916c 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py @@ -1,12 +1,11 @@ -from .LinkedDataSignature import LinkedDataSignature -from .LinkedDataProof import LinkedDataProof -from ....wallet.util import str_to_b64, b64_to_str -from ....messaging.jsonld.credential import create_jws -import json from pyld import jsonld -from typing import Union from datetime import datetime +from typing import Union +import json + +from ....wallet.util import bytes_to_b64, str_to_b64, b64_to_str from ..crypto import KeyPair +from .LinkedDataSignature import LinkedDataSignature class JwsLinkedDataSignature(LinkedDataSignature): @@ -26,7 +25,7 @@ def __init__( self.algorithm = algorithm self.key_pair = key_pair - def decode_header(self, encoded_header: str) -> dict: + def _decode_header(self, encoded_header: str) -> dict: header = None try: header = json.loads(b64_to_str(encoded_header, urlsafe=True)) @@ -34,7 +33,7 @@ def decode_header(self, encoded_header: str) -> dict: raise Exception("Could not parse JWS header.") return header - def validate_header(self, header: dict): + def _validate_header(self, header: dict): """ Validates the JWS header, throws if not ok """ if not (header and isinstance(header, dict)): raise Exception("Invalid JWS header.") @@ -49,15 +48,19 @@ def validate_header(self, header: dict): ): raise Exception(f"Invalid JWS header params for {self.signature_type}") + def _create_jws( + self, encoded_header: Union[str, bytes], verify_data: bytes + ) -> bytes: + """Compose JWS.""" + return (encoded_header + ".").encode("utf-8") + verify_data + async def sign(self, verify_data: bytes, proof: dict): header = {"alg": self.algorithm, "b64": False, "crit": ["b64"]} - encoded_header = str_to_b64(json.dumps(header), urlsafe=True, pad=False) - data = create_jws(encoded_header, verify_data) - - signature = self.key_pair.sign(data).signature + data = self._create_jws(encoded_header, verify_data) + signature = await self.key_pair.sign(data) encoded_signature = bytes_to_b64(signature, urlsafe=True, pad=False) @@ -76,14 +79,14 @@ async def verify_signature( encoded_header, payload, encoded_signature = proof["jws"].split(".") - header = self.decode_header(encoded_header) + header = self._decode_header(encoded_header) - self.validate_header(header) + self._validate_header(header) signature = b64_to_str(encoded_signature, urlsafe=True) - data = create_jws(encoded_header, verify_data) + data = self._create_jws(encoded_header, verify_data) - return self.key_pair.verify(data, signature) + return await self.key_pair.verify(data, signature) def assert_verification_method(self, verification_method: dict): if not jsonld.has_value(verification_method, "type", self.required_key_type): @@ -111,6 +114,3 @@ async def get_verification_method(self, proof: dict, document_loader: callable): # return verification_method['id'] == self.key.id # return verification_method == self.key.id - - -__all__ = [JwsLinkedDataSignature] diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py index 5161876164..c110f895bb 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -1,11 +1,13 @@ -from abc import abstractmethod, ABCMeta -from .LinkedDataProof import LinkedDataProof -from ..purposes import ProofPurpose +from copy import Error from pyld import jsonld from datetime import datetime from hashlib import sha256 from typing import Union +from abc import abstractmethod, ABCMeta + +from ..purposes import ProofPurpose from ..constants import SECURITY_CONTEXT_V2_URL +from .LinkedDataProof import LinkedDataProof class LinkedDataSignature(LinkedDataProof, metaclass=ABCMeta): @@ -15,19 +17,19 @@ def __init__( verification_method: str, *, proof: dict = None, - date: Union[datetime, str], + date: Union[datetime, str, None] = None, ): super().__init__(signature_type) self.verification_method = verification_method self.proof = proof self.date = date + if isinstance(date, datetime): + # cast date to datetime if str + self.date = datetime.strptime(date) + async def create_proof( - self, - document: dict, - purpose: ProofPurpose, - document_loader: callable, - compact_proof: bool, + self, document: dict, purpose: ProofPurpose, document_loader: callable ) -> dict: proof = None if self.proof: @@ -42,22 +44,19 @@ async def create_proof( proof["type"] = self.signature_type - # TODO validate existance and type of date more carefully - # see: jsonld-signatures implementation if not self.date: - self.date = datetime.now().isoformat() + self.date = datetime.now() if not proof.get("created"): - proof["created"] = str(self.date.isoformat()) + proof["created"] = self.date.isoformat() if self.verification_method: - proof[ - "verificationMethod" - ] = f"{self.verification_method}#{self.verification_method[8:]}" + proof["verificationMethod"] = self.verification_method proof = await self.update_proof(proof) proof = purpose.update(proof) + verify_data = await self.create_verify_data(document, proof, document_loader) proof = await self.sign(verify_data, proof) @@ -69,7 +68,6 @@ async def create_verify_data( c14n_proof_options = await self.canonize_proof( proof, document_loader=document_loader ) - print(c14n_proof_options) c14n_doc = await self.canonize(document, document_loader=document_loader) hash = sha256(c14n_proof_options.encode()) hash.update(c14n_doc.encode()) @@ -88,7 +86,6 @@ async def canonize(self, input_, *, document_loader: callable = None): ) async def canonize_proof(self, proof: dict, *, document_loader: callable = None): - print(proof) proof = proof.copy() # TODO check if these values ever exist in our use case diff --git a/aries_cloudagent/vc/vc_ld/checker.py b/aries_cloudagent/vc/vc_ld/checker.py index ec3d6315f3..d0b551b9d6 100644 --- a/aries_cloudagent/vc/vc_ld/checker.py +++ b/aries_cloudagent/vc/vc_ld/checker.py @@ -1,7 +1,7 @@ from pyld.jsonld import JsonLdProcessor import re -from ..messaging.valid import RFC3339DateTime +from ...messaging.valid import RFC3339DateTime from .constants import CREDENTIALS_CONTEXT_V1_URL diff --git a/aries_cloudagent/vc/vc_ld/issue.py b/aries_cloudagent/vc/vc_ld/issue.py index f6bf89ff68..f3241f85d9 100644 --- a/aries_cloudagent/vc/vc_ld/issue.py +++ b/aries_cloudagent/vc/vc_ld/issue.py @@ -1,23 +1,20 @@ -from typing import Any - - -from ..ld_proofs import LinkedDataSignature, ProofPurpose, sign, document_loader +from ..ld_proofs import LinkedDataSignature, ProofPurpose, sign, did_key_document_loader from .purposes import IssueCredentialProofPurpose from .checker import check_credential -def issue( +async def issue( credential: dict, suite: LinkedDataSignature, *, purpose: ProofPurpose = None -): +) -> dict: # TODO: validate credential format if not purpose: purpose = IssueCredentialProofPurpose() - signed_credential = sign( + signed_credential = await sign( document=credential, suite=suite, purpose=purpose, - document_loader=document_loader, + document_loader=did_key_document_loader, ) return signed_credential diff --git a/aries_cloudagent/vc/vc_ld/prove.py b/aries_cloudagent/vc/vc_ld/prove.py index fab209ffb1..2e7b85efc2 100644 --- a/aries_cloudagent/vc/vc_ld/prove.py +++ b/aries_cloudagent/vc/vc_ld/prove.py @@ -1,4 +1,13 @@ -from ..ld_proofs import AuthenticationProofPurpose, ProofPurpose, DocumentLoader, sign +from typing import List, Union + + +from ..ld_proofs import ( + AuthenticationProofPurpose, + ProofPurpose, + DocumentLoader, + sign, + LinkedDataProof, +) from .constants import CREDENTIALS_CONTEXT_V1_URL @@ -27,7 +36,7 @@ async def create_presentation( async def sign_presentation( presentation: dict, - suite: LinkedProofSignature, + suite: LinkedDataProof, document_loader: DocumentLoader, domain: str, challenge: str, @@ -42,7 +51,7 @@ async def sign_presentation( purpose = AuthenticationProofPurpose(challenge=challenge, domain=domain) return await sign( - document=presentaton, + document=presentation, suite=suite, purpose=purpose, document_loader=document_loader, diff --git a/aries_cloudagent/vc/vc_ld/purposes/IssueCredentialProofPurpose.py b/aries_cloudagent/vc/vc_ld/purposes/IssueCredentialProofPurpose.py index e6151fdc91..c2d40362c3 100644 --- a/aries_cloudagent/vc/vc_ld/purposes/IssueCredentialProofPurpose.py +++ b/aries_cloudagent/vc/vc_ld/purposes/IssueCredentialProofPurpose.py @@ -1,11 +1,12 @@ -from .AssertionProofPurpose import AssertionProofPurpose -from datetime import datetime -from ..suites import LinkedDataSignature -from pyld import jsonld +from datetime import datetime, timedelta +from pyld.jsonld import JsonLdProcessor + +from ...ld_proofs.purposes import AssertionProofPurpose +from ...ld_proofs.suites import LinkedDataSignature class IssueCredentialProofPurpose(AssertionProofPurpose): - def __init__(self, date: datetime, max_timestamp_delta: None): + def __init__(self, date: datetime = None, max_timestamp_delta: timedelta = None): super().__init__(date, max_timestamp_delta) async def validate( @@ -22,7 +23,7 @@ async def validate( if not result["valid"]: raise result["error"] - issuer = jsonld.get_values( + issuer = JsonLdProcessor.get_values( document, "https://www.w3.org/2018/credentials#issuer" ) diff --git a/aries_cloudagent/vc/vc_ld/purposes/__init__.py b/aries_cloudagent/vc/vc_ld/purposes/__init__.py new file mode 100644 index 0000000000..8f121ee924 --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/purposes/__init__.py @@ -0,0 +1,3 @@ +from .IssueCredentialProofPurpose import IssueCredentialProofPurpose + +__all__ = [IssueCredentialProofPurpose] \ No newline at end of file diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index 870f6e7492..235d5c72e0 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -1,7 +1,8 @@ -from typing import Any, Awaitable, Callable, Mapping +from aries_cloudagent.vc.ld_proofs.suites.LinkedDataProof import LinkedDataProof +from typing import Callable, Mapping import asyncio +from pyld.jsonld import JsonLdProcessor -from pyld import jsonld from ..ld_proofs import ( DocumentLoader, LinkedDataSignature, @@ -15,7 +16,6 @@ async def _verify_credential( credential: dict, - controller: dict, document_loader: DocumentLoader, suite: LinkedDataSignature, purpose: ProofPurpose = None, @@ -23,7 +23,7 @@ async def _verify_credential( ) -> dict: # TODO: validate credential structure - if credential["credentialStatus"] and not check_status: + if "credentialStatus" in credential and not check_status: raise Exception( 'A "check_status function must be provided to verify credentials with "credentialStatus" set.' ) @@ -33,7 +33,7 @@ async def _verify_credential( result = await ld_proofs_verify( document=credential, - suite=suite, + suites=[suite], purpose=purpose, document_loader=document_loader, ) @@ -41,9 +41,9 @@ async def _verify_credential( if not result["verified"]: return result - if credential["credentialStatus"]: + if "credentialStatus" in credential: # CHECK make sure this is how check_status should be called - result["credentialStatus"] = await check_status(credential) + result["statusResult"] = await check_status(credential) if not result["statusResult"]["verified"]: result["verified"] = False @@ -53,7 +53,6 @@ async def _verify_credential( async def verify_credential( credential: dict, - controller: dict, document_loader: DocumentLoader, suite: LinkedDataSignature, purpose: ProofPurpose = None, @@ -61,7 +60,7 @@ async def verify_credential( ) -> dict: try: return await _verify_credential( - credential, controller, document_loader, suite, purpose, check_status + credential, document_loader, suite, purpose, check_status ) except Exception as e: return { @@ -74,10 +73,10 @@ async def verify_credential( async def _verify_presentation( challenge: str, presentation: dict = None, - purpose: LinkedDataSignature = None, + purpose: ProofPurpose = None, unsigned_presentation: dict = None, - suite_map: Mapping[str, LinkedDataSignature] = None, - suite: LinkedDataSignature = None, + suite_map: Mapping[str, LinkedDataProof] = None, + suite: LinkedDataProof = None, controller: dict = None, domain: str = None, document_loader: DocumentLoader = None, @@ -124,7 +123,7 @@ async def _verify_presentation( credential_results = None verified = True - credentials = jsonld.get_values(vp, "verifiableCredential") + credentials = JsonLdProcessor.get_values(vp, "verifiableCredential") def v(credential: dict): if suite_map: diff --git a/aries_cloudagent/wallet/routes.py b/aries_cloudagent/wallet/routes.py index 990292224a..28e9e91a1a 100644 --- a/aries_cloudagent/wallet/routes.py +++ b/aries_cloudagent/wallet/routes.py @@ -1,5 +1,6 @@ """Wallet admin routes.""" +from aries_cloudagent.wallet.util import naked_to_did_key from aiohttp import web from aiohttp_apispec import ( docs, @@ -129,6 +130,8 @@ def format_did_info(info: DIDInfo): "did": info.did, "verkey": info.verkey, "posture": DIDPosture.get(info.metadata).moniker, + # TODO: if did is public use did:sov + "full_did": naked_to_did_key(info.verkey), } From d3dbc2cf0ee8d456582b0652e50cef6cbf415fa0 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Fri, 12 Mar 2021 13:25:40 +0100 Subject: [PATCH 011/138] ld_proofs / vc improvments and refinements Signed-off-by: Timo Glastra --- aries_cloudagent/vc/ld_proofs/ProofSet.py | 40 ++++--- aries_cloudagent/vc/ld_proofs/__init__.py | 8 +- aries_cloudagent/vc/ld_proofs/constants.py | 2 + .../vc/ld_proofs/crypto/Base58Encoder.py | 11 -- .../vc/ld_proofs/crypto/Ed25519KeyPair.py | 57 ---------- .../ld_proofs/crypto/Ed25519WalletKeyPair.py | 23 ++++ .../vc/ld_proofs/crypto/__init__.py | 5 +- aries_cloudagent/vc/ld_proofs/ld_proofs.py | 2 +- .../purposes/AssertionProofPurpose.py | 6 +- .../purposes/AuthenticationProofPurpose.py | 45 +++++--- .../purposes/ControllerProofPurpose.py | 51 +++++---- .../purposes/CredentialIssuancePurpose.py | 48 ++++++++ .../vc/ld_proofs/purposes/ProofPurpose.py | 30 ++--- .../purposes/PublicKeyProofPurpose.py | 19 ---- .../vc/ld_proofs/purposes/__init__.py | 4 +- .../ld_proofs/suites/Ed25519Signature2018.py | 6 +- .../suites/JwsLinkedDataSignature.py | 98 +++++++---------- .../vc/ld_proofs/suites/LinkedDataProof.py | 17 +-- .../ld_proofs/suites/LinkedDataSignature.py | 103 +++++++++--------- aries_cloudagent/vc/ld_proofs/util.py | 6 + aries_cloudagent/vc/vc_ld/issue.py | 11 +- .../purposes/IssueCredentialProofPurpose.py | 41 ------- .../vc/vc_ld/purposes/__init__.py | 3 - aries_cloudagent/vc/vc_ld/verify.py | 8 +- aries_cloudagent/wallet/util.py | 28 +++-- 25 files changed, 327 insertions(+), 345 deletions(-) delete mode 100644 aries_cloudagent/vc/ld_proofs/crypto/Base58Encoder.py delete mode 100644 aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py create mode 100644 aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py create mode 100644 aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py delete mode 100644 aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py create mode 100644 aries_cloudagent/vc/ld_proofs/util.py delete mode 100644 aries_cloudagent/vc/vc_ld/purposes/IssueCredentialProofPurpose.py delete mode 100644 aries_cloudagent/vc/vc_ld/purposes/__init__.py diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py index 69c539f87e..017c5ee208 100644 --- a/aries_cloudagent/vc/ld_proofs/ProofSet.py +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -10,18 +10,17 @@ class ProofSet: + def __init__(self, document: dict) -> None: + self._document = document.copy() + async def add( self, *, - document: Union[dict, str], - suite: LinkedDataProof, + document: dict, + suite: List[LinkedDataProof], purpose: ProofPurpose, document_loader: DocumentLoader ) -> dict: - - if isinstance(document, str): - document = await document_loader(document) - input_ = document.copy() if "proof" in input_: @@ -97,22 +96,27 @@ async def _verify( purpose: ProofPurpose, document_loader: DocumentLoader, ): - - result = await asyncio.gather( - *[ - purpose.match(proof["type"], document, document_loader) - for proof in proof_set - ] - ) - - matches = [x for i, x in enumerate(proof_set) if result[i]] - - if matches.len() == 0: + # Matches proof purposes proof set to passed purpose. + # Only proofs with a `proofPurpose` that match the purpose are verified + # e.g.: + # purpose = {term = 'assertionMethod'} + # proof_set = [ { proofPurpose: 'assertionMethod' }, { proofPurpose: 'anotherPurpose' }] + # return = [ { proofPurpose: 'assertionMethod' } ] + matches = [proof for proof in proof_set if purpose.match(proof)] + + if len(matches) == 0: return [] - out = [] + results = [] + + for proof in matches: + for suite in suites: + if suite.match_proof(proof["type"]): + suite.verify_proof() + results.append() for m in matches: + for s in suites: if await s.match_proof(m["type"]): out.append(s.verify_proof(m, document, purpose, document_loader)) diff --git a/aries_cloudagent/vc/ld_proofs/__init__.py b/aries_cloudagent/vc/ld_proofs/__init__.py index b4a13bb508..68e30b33aa 100644 --- a/aries_cloudagent/vc/ld_proofs/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/__init__.py @@ -4,7 +4,7 @@ ProofPurpose, ControllerProofPurpose, AuthenticationProofPurpose, - PublicKeyProofPurpose, + CredentialIssuancePurpose, AssertionProofPurpose, ) from .suites import ( @@ -13,7 +13,7 @@ JwsLinkedDataSignature, Ed25519Signature2018, ) -from .crypto import Base58Encoder, KeyPair, Ed25519KeyPair, Ed25519WalletKeyPair +from .crypto import KeyPair, Ed25519WalletKeyPair from .document_loader import DocumentLoader, did_key_document_loader __all__ = [ @@ -24,14 +24,12 @@ ControllerProofPurpose, AssertionProofPurpose, AuthenticationProofPurpose, - PublicKeyProofPurpose, + CredentialIssuancePurpose, LinkedDataProof, LinkedDataSignature, JwsLinkedDataSignature, Ed25519Signature2018, - Base58Encoder, KeyPair, - Ed25519KeyPair, Ed25519WalletKeyPair, DocumentLoader, did_key_document_loader, diff --git a/aries_cloudagent/vc/ld_proofs/constants.py b/aries_cloudagent/vc/ld_proofs/constants.py index 1cb6ad055d..8cf708b0e4 100644 --- a/aries_cloudagent/vc/ld_proofs/constants.py +++ b/aries_cloudagent/vc/ld_proofs/constants.py @@ -1 +1,3 @@ SECURITY_CONTEXT_V2_URL = "https://w3id.org/security/v2" +CREDENTIALS_CONTEXT_V1_URL = "https://www.w3.org/2018/credentials/v1" +CREDENTIALS_ISSUER_URL = "https://www.w3.org/2018/credentials#issuer" diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Base58Encoder.py b/aries_cloudagent/vc/ld_proofs/crypto/Base58Encoder.py deleted file mode 100644 index 9b1e72cf4f..0000000000 --- a/aries_cloudagent/vc/ld_proofs/crypto/Base58Encoder.py +++ /dev/null @@ -1,11 +0,0 @@ -import base58 - - -class Base58Encoder(object): - @staticmethod - def encode(data): - return base58.b58encode(data) - - @staticmethod - def decode(data): - return base58.b58decode(data) diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py deleted file mode 100644 index e666d713fb..0000000000 --- a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519KeyPair.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import Optional -from base58 import b58decode, b58encode -from nacl.encoding import Base64Encoder -from nacl.exceptions import BadSignatureError -from nacl.signing import VerifyKey, SigningKey - -from .KeyPair import KeyPair -from ....wallet.base import BaseWallet - - -class Ed25519KeyPair(KeyPair): - def __init__(self, public_key_base_58: bytes): - self.public_key = b58decode(public_key_base_58) - self.verifier = VerifyKey(self.public_key) - self.signer: Optional[SigningKey] = None - - @classmethod - def generate(cls, seed: str = None) -> "Ed25519KeyPair": - if seed: - signer = SigningKey(seed) - else: - signer = SigningKey.generate() - - key_pair = cls(b58encode(signer.verify_key._key)) - key_pair.signer = signer - key_pair.public_key = key_pair.verifier._key - key_pair.private_key = signer._signing_key - - return key_pair - - async def sign(self, message: bytes) -> bytes: - if not self.signer: - raise Exception("No signer defined") - - return self.signer.sign(message, Base64Encoder).signature - - async def verify(self, message: bytes) -> bool: - try: - self.verifier.verify(message) - return True - except BadSignatureError: - return False - - -class Ed25519WalletKeyPair(KeyPair): - def __init__(self, verkey: str, wallet: BaseWallet): - self.verkey = verkey - self.wallet = wallet - - async def sign(self, message: bytes) -> bytes: - return await self.wallet.sign_message( - message, - self.verkey, - ) - - async def verify(self, message: bytes, signature: bytes) -> bool: - return await self.wallet.verify_message(message, signature, self.verkey) diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py new file mode 100644 index 0000000000..2c9f6208d0 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py @@ -0,0 +1,23 @@ +from ....wallet.base import BaseWallet +from ....wallet.util import public_key_base58_to_fingerprint +from .KeyPair import KeyPair + + +class Ed25519WalletKeyPair(KeyPair): + def __init__(self, wallet: BaseWallet, public_key_base58: str): + self.wallet = wallet + self.public_key_base58 = public_key_base58 + + def fingerprint(self) -> str: + return public_key_base58_to_fingerprint(self.public_key_base58) + + async def sign(self, message: bytes) -> bytes: + return await self.wallet.sign_message( + message, + self.public_key_base58, + ) + + async def verify(self, message: bytes, signature: bytes) -> bool: + return await self.wallet.verify_message( + message, signature, self.public_key_base58 + ) diff --git a/aries_cloudagent/vc/ld_proofs/crypto/__init__.py b/aries_cloudagent/vc/ld_proofs/crypto/__init__.py index bece5c1415..6d9f20e860 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/__init__.py @@ -1,5 +1,4 @@ -from .Base58Encoder import Base58Encoder from .KeyPair import KeyPair -from .Ed25519KeyPair import Ed25519KeyPair, Ed25519WalletKeyPair +from .Ed25519WalletKeyPair import Ed25519WalletKeyPair -__all__ = [Base58Encoder, KeyPair, Ed25519KeyPair, Ed25519WalletKeyPair] +__all__ = [KeyPair, Ed25519WalletKeyPair] diff --git a/aries_cloudagent/vc/ld_proofs/ld_proofs.py b/aries_cloudagent/vc/ld_proofs/ld_proofs.py index 6d027c7a82..c6babbf722 100644 --- a/aries_cloudagent/vc/ld_proofs/ld_proofs.py +++ b/aries_cloudagent/vc/ld_proofs/ld_proofs.py @@ -10,7 +10,7 @@ async def sign( *, - document: Union[dict, str], + document: dict, suite: LinkedDataSignature, purpose: ProofPurpose, document_loader: DocumentLoader, diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py index 6f48a4babe..d494787bbe 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py @@ -4,5 +4,7 @@ class AssertionProofPurpose(ControllerProofPurpose): - def __init__(self, date: datetime = None, max_timestamp_delta: timedelta = None): - super().__init__("assertionMethod", date, max_timestamp_delta) + def __init__(self, *, date: datetime = None, max_timestamp_delta: timedelta = None): + super().__init__( + term="assertionMethod", date=date, max_timestamp_delta=max_timestamp_delta + ) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py index 10d2a3d808..4c33b18fff 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py @@ -1,51 +1,62 @@ -from .ControllerProofPurpose import ControllerProofPurpose from datetime import datetime, timedelta from typing import Awaitable +from ..document_loader import DocumentLoader +from ..suites import LinkedDataProof +from .ControllerProofPurpose import ControllerProofPurpose + class AuthenticationProofPurpose(ControllerProofPurpose): def __init__( self, - controller: dict, challenge: str, - date: datetime = None, + *, domain: str = None, + date: datetime = None, max_timestamp_delta: timedelta = None, ): - super(ControllerProofPurpose, self).__init__( - "authentication", controller, date, max_timestamp_delta + super().__init__( + term="authentication", date=date, max_timestamp_delta=max_timestamp_delta ) + self.challenge = challenge self.domain = domain - async def validate( - self, proof: dict, verification_method: dict, document_loader: callable + def validate( + self, + proof: dict, + *, + document: dict, + suite: LinkedDataProof, + verification_method: dict, + document_loader: DocumentLoader, ) -> dict: try: - if proof["challenge"] != self.challenge: + if proof.get("challenge") != self.challenge: raise Exception( - f'The challenge is not expected; challenge={proof["challenge"]}, expected=[self.challenge]' + f'The challenge is not expected; challenge={proof.get("challenge")}, expected=[{self.challenge}]' ) - if self.domain and (proof["domain"] != self.domain): + if self.domain and (proof.get("domain") != self.domain): raise Exception( - f'The domain is not as expected; domain={proof["domain"]}, expected={self.domain}' + f'The domain is not as expected; domain={proof.get("domain")}, expected={self.domain}' ) - return await super(ControllerProofPurpose, self).validate( - proof, verification_method, document_loader + return super().validate( + proof, + document=document, + suite=suite, + verification_method=verification_method, + document_loader=document_loader, ) except Exception as e: return {"valid": False, "error": e} async def update(self, proof: dict) -> Awaitable[dict]: - proof = await super().update(proof) + proof = super().update(proof) proof["challenge"] = self.challenge if self.domain: proof["domain"] = self.domain return proof - - -__all__ = [AuthenticationProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py index 1e772e05e5..b27a6faf98 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py @@ -1,56 +1,67 @@ from datetime import datetime, timedelta - from pyld import jsonld from pyld.jsonld import JsonLdProcessor from ..constants import SECURITY_CONTEXT_V2_URL +from ..suites import LinkedDataProof +from ..document_loader import DocumentLoader from .ProofPurpose import ProofPurpose class ControllerProofPurpose(ProofPurpose): def __init__( - self, term: str, date: datetime = None, max_timestamp_delta: timedelta = None + self, term: str, *, date: datetime = None, max_timestamp_delta: timedelta = None ): - super().__init__(term, date, max_timestamp_delta) + super().__init__(term=term, date=date, max_timestamp_delta=max_timestamp_delta) - async def validate( - self, proof: dict, verification_method: str, document_loader: callable - ): + def validate( + self, + proof: dict, + *, + document: dict, + suite: LinkedDataProof, + verification_method: dict, + document_loader: DocumentLoader, + ) -> dict: try: - result = await super(ProofPurpose, self).validate(proof) + result = super().validate(proof) - if not result["valid"]: - raise result["error"] + if not result.get("valid"): + raise result.get("error") + + verification_id = verification_method.get("id") + controller = verification_method.get("controller") + + if isinstance(controller, dict): + controller_id = controller.get("id") + elif isinstance(controller, str): + controller_id = controller + else: + raise Exception('"controller" must be a string or dict') framed = jsonld.frame( - verification_method, + controller_id, { "@context": SECURITY_CONTEXT_V2_URL, - "@embed": "@always", - "id": verification_method, + "id": controller_id, + self.term: {"@embed": "@never", "id": verification_id}, }, {"documentLoader": document_loader}, ) result["controller"] = framed - verification_id = verification_method["id"] - verification_methods = JsonLdProcessor.get_values( - result["controller"], self.term - ) + verification_methods = JsonLdProcessor.get_values(framed, self.term) result["valid"] = any( method == verification_id for method in verification_methods ) if not result["valid"]: raise Exception( - f"Verification method {verification_method['id']} not authorized by controller for proof purpose {self.term}" + f"Verification method {verification_id} not authorized by controller for proof purpose {self.term}" ) return result except Exception as e: return {"valid": False, "error": e} - - -__all__ = [ControllerProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py new file mode 100644 index 0000000000..6b1c027c76 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py @@ -0,0 +1,48 @@ +from datetime import datetime, timedelta +from pyld.jsonld import JsonLdProcessor + +from ..suites import LinkedDataProof +from ..document_loader import DocumentLoader +from ..constants import CREDENTIALS_ISSUER_URL +from .AssertionProofPurpose import AssertionProofPurpose + + +class CredentialIssuancePurpose(AssertionProofPurpose): + def __init__(self, date: datetime = None, max_timestamp_delta: timedelta = None): + super().__init__(date=date, max_timestamp_delta=max_timestamp_delta) + + def validate( + self, + proof: dict, + *, + document: dict, + suite: LinkedDataProof, + verification_method: str, + document_loader: DocumentLoader, + ): + try: + result = super().validate( + proof=proof, + document=document, + suite=suite, + verification_method=verification_method, + document_loader=document_loader, + ) + + if not result.get("valid"): + raise result.get("error") + + issuer: list = JsonLdProcessor.get_values(document, CREDENTIALS_ISSUER_URL) + + if not issuer or len(issuer) == 0: + raise Exception("Credential issuer is required.") + + if result.get("controller", {}).get("id") != issuer[0].get("id"): + raise Exception( + "Credential issuer must match the verification method controller." + ) + + return {"valid": True} + + except Exception as e: + return {"valid": False, "error": e} diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py index 3c793e88f7..7c7db96a1c 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py @@ -1,25 +1,30 @@ from datetime import datetime, timedelta +from ..document_loader import DocumentLoader +from ..suites import LinkedDataProof + class ProofPurpose: def __init__( - self, term: str, date: datetime = None, max_timestamp_delta: timedelta = None + self, term: str, *, date: datetime = None, max_timestamp_delta: timedelta = None ): self.term = term - - if not date: - date = datetime.now() - - self.date = date + self.date = date or datetime.now() self.max_timestamp_delta = max_timestamp_delta - def validate(self, proof: dict) -> bool: + def validate( + self, + proof: dict, + *, + document: dict, + suite: LinkedDataProof, + verification_method: dict, + document_loader: DocumentLoader + ) -> dict: try: if self.max_timestamp_delta is not None: expected = self.date.time() - created = datetime.datetime.strptime( - proof["created"], "%Y-%m-%dT%H:%M:%SZ" - ) + created = datetime.strptime(proof.get("created"), "%Y-%m-%dT%H:%M:%SZ") if not ( created >= (expected - self.max_timestamp_delta) @@ -36,7 +41,4 @@ def update(self, proof: dict) -> dict: return proof def match(self, proof: dict) -> bool: - return proof["proofPurpose"] == self.term - - -__all__ = [ProofPurpose] + return proof.get("proofPurpose") == self.term diff --git a/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py deleted file mode 100644 index 3080306e7d..0000000000 --- a/aries_cloudagent/vc/ld_proofs/purposes/PublicKeyProofPurpose.py +++ /dev/null @@ -1,19 +0,0 @@ -from datetime import datetime, timedelta -from typing import Awaitable -from .ControllerProofPurpose import ControllerProofPurpose - - -class PublicKeyProofPurpose(ControllerProofPurpose): - def __init__( - self, controller: dict, date: datetime, max_timestamp_delta: timedelta = None - ): - super().__init__("publicKey", controller, date, max_timestamp_delta) - - async def update(self, proof: dict) -> Awaitable[dict]: - return proof - - async def match(self, proof: dict) -> Awaitable[bool]: - return proof.get("proofPurpose") is None - - -__all__ = [PublicKeyProofPurpose] diff --git a/aries_cloudagent/vc/ld_proofs/purposes/__init__.py b/aries_cloudagent/vc/ld_proofs/purposes/__init__.py index 5b44f5d362..7b24e9323b 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/__init__.py @@ -2,12 +2,12 @@ from .ControllerProofPurpose import ControllerProofPurpose from .AssertionProofPurpose import AssertionProofPurpose from .AuthenticationProofPurpose import AuthenticationProofPurpose -from .PublicKeyProofPurpose import PublicKeyProofPurpose +from .CredentialIssuancePurpose import CredentialIssuancePurpose __all__ = [ ProofPurpose, ControllerProofPurpose, AssertionProofPurpose, AuthenticationProofPurpose, - PublicKeyProofPurpose, + CredentialIssuancePurpose, ] diff --git a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py index e52b848798..b2e07ad9ed 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py +++ b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Union -from ..crypto import Ed25519WalletKeyPair, Ed25519KeyPair +from ..crypto import Ed25519WalletKeyPair from .JwsLinkedDataSignature import JwsLinkedDataSignature @@ -9,16 +9,16 @@ class Ed25519Signature2018(JwsLinkedDataSignature): def __init__( self, verification_method: str, - key_pair: Union[Ed25519WalletKeyPair, Ed25519KeyPair], + key_pair: Ed25519WalletKeyPair, proof: dict = None, date: Union[datetime, str] = None, ): super().__init__( signature_type="Ed25519Signature2018", algorithm="EdDSA", + required_key_type="Ed25519VerificationKey2018", key_pair=key_pair, verification_method=verification_method, proof=proof, date=date, ) - self.required_key_type = "Ed25519VerificationKey2018" diff --git a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py index a745f7916c..7eaff157f4 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py @@ -1,65 +1,40 @@ -from pyld import jsonld +from pyld.jsonld import JsonLdProcessor from datetime import datetime from typing import Union import json from ....wallet.util import bytes_to_b64, str_to_b64, b64_to_str from ..crypto import KeyPair +from ..document_loader import DocumentLoader from .LinkedDataSignature import LinkedDataSignature +from ..util import create_jws class JwsLinkedDataSignature(LinkedDataSignature): def __init__( self, + *, signature_type: str, algorithm: str, + required_key_type: str, key_pair: KeyPair, - verification_method: str, - *, + verification_method: dict, proof: dict = None, - date: Union[datetime, str], + date: Union[datetime, str] = None, ): super().__init__(signature_type, verification_method, proof=proof, date=date) self.algorithm = algorithm self.key_pair = key_pair - - def _decode_header(self, encoded_header: str) -> dict: - header = None - try: - header = json.loads(b64_to_str(encoded_header, urlsafe=True)) - except Exception: - raise Exception("Could not parse JWS header.") - return header - - def _validate_header(self, header: dict): - """ Validates the JWS header, throws if not ok """ - if not (header and isinstance(header, dict)): - raise Exception("Invalid JWS header.") - - if not ( - header["alg"] == self.algorithm - and header["b64"] is False - and isinstance(header["crit"], list) - and header["crit"].len() == 1 - and header["crit"][0] == "b64" - and header.keys().len() == 3 - ): - raise Exception(f"Invalid JWS header params for {self.signature_type}") - - def _create_jws( - self, encoded_header: Union[str, bytes], verify_data: bytes - ) -> bytes: - """Compose JWS.""" - return (encoded_header + ".").encode("utf-8") + verify_data + self.required_key_type = required_key_type async def sign(self, verify_data: bytes, proof: dict): - header = {"alg": self.algorithm, "b64": False, "crit": ["b64"]} + encoded_header = str_to_b64(json.dumps(header), urlsafe=True, pad=False) - data = self._create_jws(encoded_header, verify_data) + data = create_jws(encoded_header, verify_data) signature = await self.key_pair.sign(data) encoded_signature = bytes_to_b64(signature, urlsafe=True, pad=False) @@ -84,33 +59,42 @@ async def verify_signature( self._validate_header(header) signature = b64_to_str(encoded_signature, urlsafe=True) - data = self._create_jws(encoded_header, verify_data) + data = create_jws(encoded_header, verify_data) return await self.key_pair.verify(data, signature) - def assert_verification_method(self, verification_method: dict): - if not jsonld.has_value(verification_method, "type", self.required_key_type): + def _decode_header(self, encoded_header: str) -> dict: + header = None + try: + header = json.loads(b64_to_str(encoded_header, urlsafe=True)) + except Exception: + raise Exception("Could not parse JWS header.") + return header + + def _validate_header(self, header: dict): + """ Validates the JWS header, throws if not ok """ + if not (header and isinstance(header, dict)): + raise Exception("Invalid JWS header.") + + if not ( + header["alg"] == self.algorithm + and header["b64"] is False + and isinstance(header["crit"], list) + and header["crit"].len() == 1 + and header["crit"][0] == "b64" + and header.keys().len() == 3 + ): + raise Exception(f"Invalid JWS header params for {self.signature_type}") + + def _assert_verification_method(self, verification_method: dict): + if not JsonLdProcessor.has_value( + verification_method, "type", self.required_key_type + ): raise Exception( f"Invalid key type. The key type must be {self.required_key_type}" ) - async def get_verification_method(self, proof: dict, document_loader: callable): - verification_method = await super( - LinkedDataSignature, self - ).get_verification_method(proof, document_loader) - self.assert_verification_method(verification_method) + def _get_verification_method(self, proof: dict, document_loader: DocumentLoader): + verification_method = super()._get_verification_method(proof, document_loader) + self._assert_verification_method(verification_method) return verification_method - - # async def match_proof(self, proof, document, purpose, document_loader): - # if not super(LinkedDataProof, self).match_proof(proof['type']): - # return False - - # if not self.key: - # return True - - # verification_method = proof['verificationMethod'] - - # if isinstance(verification_method, dict): - # return verification_method['id'] == self.key.id - - # return verification_method == self.key.id diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py index d210e96de4..1c7fafdbb1 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py @@ -11,17 +11,20 @@ def __init__(self, signature_type: str): @abstractmethod async def create_proof( - self, *, document: dict, purpose: ProofPurpose, document_loader: DocumentLoader + self, document: dict, *, purpose: ProofPurpose, document_loader: DocumentLoader ): pass @abstractmethod - async def verify_proof(self, **kwargs): - """TODO update method signature""" + async def verify_proof( + self, + proof: dict, + *, + document: dict, + purpose: ProofPurpose, + document_loader: DocumentLoader + ): pass - async def match_proof(self, signature_type: str) -> bool: + def match_proof(self, signature_type: str) -> bool: return signature_type == self.signature_type - - -__all__ = [LinkedDataProof] diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py index c110f895bb..ff8b90b7f3 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -1,10 +1,10 @@ -from copy import Error from pyld import jsonld from datetime import datetime from hashlib import sha256 from typing import Union from abc import abstractmethod, ABCMeta +from ..document_loader import DocumentLoader from ..purposes import ProofPurpose from ..constants import SECURITY_CONTEXT_V2_URL from .LinkedDataProof import LinkedDataProof @@ -28,8 +28,22 @@ def __init__( # cast date to datetime if str self.date = datetime.strptime(date) + # ABSTRACT METHODS + + @abstractmethod + def sign(self, verify_data: bytes, proof: dict): + pass + + @abstractmethod + def verify_signature( + self, verify_data: bytes, verification_method: dict, proof: dict + ): + pass + + # PUBLIC METHODS + async def create_proof( - self, document: dict, purpose: ProofPurpose, document_loader: callable + self, document: dict, *, purpose: ProofPurpose, document_loader: DocumentLoader ) -> dict: proof = None if self.proof: @@ -62,39 +76,6 @@ async def create_proof( proof = await self.sign(verify_data, proof) return proof - async def create_verify_data( - self, document: dict, proof: dict, document_loader: dict - ) -> str: - c14n_proof_options = await self.canonize_proof( - proof, document_loader=document_loader - ) - c14n_doc = await self.canonize(document, document_loader=document_loader) - hash = sha256(c14n_proof_options.encode()) - hash.update(c14n_doc.encode()) - - # TODO verify this is the right return type - return hash.digest() - - async def canonize(self, input_, *, document_loader: callable = None): - return jsonld.normalize( - input_, - { - "algorithm": "URDNA2015", - "format": "application/n-quads", - "documentLoader": document_loader, - }, - ) - - async def canonize_proof(self, proof: dict, *, document_loader: callable = None): - proof = proof.copy() - - # TODO check if these values ever exist in our use case - # del proof['jws'] - # del proof['signatureValue'] - # del proof['proofValue'] - - return await self.canonize(proof, document_loader=document_loader) - async def update_proof(self, proof: dict): """ Extending classes may do more @@ -104,15 +85,16 @@ async def update_proof(self, proof: dict): async def verify_proof( self, proof: dict, + *, document: dict, purpose: ProofPurpose, - document_loader: callable, + document_loader: DocumentLoader, ): try: - verify_data = await self.create_verify_data( - document, proof, document_loader=document_loader + verify_data = self._create_verify_data( + document=document, proof=proof, document_loader=document_loader ) - verification_method = await self.get_verification_method( + verification_method = self._get_verification_method( proof, document_loader=document_loader ) @@ -142,13 +124,13 @@ async def verify_proof( except Exception as err: return {"verified": False, "error": err} - async def get_verification_method(self, proof: dict, document_loader: callable): - + def _get_verification_method(self, proof: dict, *, document_loader: DocumentLoader): verification_method = proof.get("verificationMethod") + if not verification_method: raise Exception('No "verificationMethod" found in proof') - framed = await jsonld.frame( + framed = jsonld.frame( verification_method, { "@context": SECURITY_CONTEXT_V2_URL, @@ -166,15 +148,34 @@ async def get_verification_method(self, proof: dict, document_loader: callable): return framed - @abstractmethod - def sign(self, verify_data: bytes, proof: dict): - pass + def _create_verify_data( + self, *, document: dict, proof: dict, document_loader: DocumentLoader + ) -> str: + c14n_proof_options = self._canonize_proof( + proof, document_loader=document_loader + ) + c14n_doc = self._canonize(document, document_loader=document_loader) + hash = sha256(c14n_proof_options.encode()) + hash.update(c14n_doc.encode()) - @abstractmethod - def verify_signature( - self, verify_data: bytes, verification_method: dict, proof: dict - ): - pass + return hash.digest() + + def _canonize(self, input, *, document_loader: DocumentLoader = None) -> str: + # application/n-quads format always returns str + return jsonld.normalize( + input, + { + "algorithm": "URDNA2015", + "format": "application/n-quads", + "documentLoader": document_loader, + }, + ) + + def _canonize_proof(self, proof: dict, *, document_loader: DocumentLoader = None): + proof = proof.copy() + proof.pop("jws", None) + proof.pop("signatureValue", None) + proof.pop("proofValue", None) -__all__ = [LinkedDataSignature] + return self._canonize(proof, document_loader=document_loader) diff --git a/aries_cloudagent/vc/ld_proofs/util.py b/aries_cloudagent/vc/ld_proofs/util.py new file mode 100644 index 0000000000..98931898ff --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/util.py @@ -0,0 +1,6 @@ +from typing import Union + + +def create_jws(encoded_header: str, verify_data: bytes) -> bytes: + """Compose JWS.""" + return (encoded_header + ".").encode("utf-8") + verify_data \ No newline at end of file diff --git a/aries_cloudagent/vc/vc_ld/issue.py b/aries_cloudagent/vc/vc_ld/issue.py index f3241f85d9..3c2e6706fb 100644 --- a/aries_cloudagent/vc/vc_ld/issue.py +++ b/aries_cloudagent/vc/vc_ld/issue.py @@ -1,5 +1,10 @@ -from ..ld_proofs import LinkedDataSignature, ProofPurpose, sign, did_key_document_loader -from .purposes import IssueCredentialProofPurpose +from ..ld_proofs import ( + LinkedDataSignature, + ProofPurpose, + sign, + did_key_document_loader, + CredentialIssuancePurpose, +) from .checker import check_credential @@ -9,7 +14,7 @@ async def issue( # TODO: validate credential format if not purpose: - purpose = IssueCredentialProofPurpose() + purpose = CredentialIssuancePurpose() signed_credential = await sign( document=credential, diff --git a/aries_cloudagent/vc/vc_ld/purposes/IssueCredentialProofPurpose.py b/aries_cloudagent/vc/vc_ld/purposes/IssueCredentialProofPurpose.py deleted file mode 100644 index c2d40362c3..0000000000 --- a/aries_cloudagent/vc/vc_ld/purposes/IssueCredentialProofPurpose.py +++ /dev/null @@ -1,41 +0,0 @@ -from datetime import datetime, timedelta -from pyld.jsonld import JsonLdProcessor - -from ...ld_proofs.purposes import AssertionProofPurpose -from ...ld_proofs.suites import LinkedDataSignature - - -class IssueCredentialProofPurpose(AssertionProofPurpose): - def __init__(self, date: datetime = None, max_timestamp_delta: timedelta = None): - super().__init__(date, max_timestamp_delta) - - async def validate( - self, - proof: dict, - document: dict, - suite: LinkedDataSignature, - verification_method: str, - document_loader: callable, - ): - try: - result = super().validate(proof, verification_method, document_loader) - - if not result["valid"]: - raise result["error"] - - issuer = JsonLdProcessor.get_values( - document, "https://www.w3.org/2018/credentials#issuer" - ) - - if not issuer or issuer.len() == 0: - raise Exception("Credential issuer is required.") - - if result["controller"]["id"] != issuer[0]["id"]: - raise Exception( - "Credential issuer must match the verification method controller." - ) - - return {"valid": True} - - except Exception as e: - return {"valid": False, "error": e} diff --git a/aries_cloudagent/vc/vc_ld/purposes/__init__.py b/aries_cloudagent/vc/vc_ld/purposes/__init__.py deleted file mode 100644 index 8f121ee924..0000000000 --- a/aries_cloudagent/vc/vc_ld/purposes/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .IssueCredentialProofPurpose import IssueCredentialProofPurpose - -__all__ = [IssueCredentialProofPurpose] \ No newline at end of file diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index 235d5c72e0..8cd77b04b8 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -1,9 +1,10 @@ -from aries_cloudagent.vc.ld_proofs.suites.LinkedDataProof import LinkedDataProof -from typing import Callable, Mapping import asyncio +from typing import Callable, Mapping from pyld.jsonld import JsonLdProcessor from ..ld_proofs import ( + LinkedDataProof, + CredentialIssuancePurpose, DocumentLoader, LinkedDataSignature, ProofPurpose, @@ -11,7 +12,6 @@ verify as ld_proofs_verify, ) from .checker import check_credential -from .purposes import IssueCredentialProofPurpose async def _verify_credential( @@ -29,7 +29,7 @@ async def _verify_credential( ) if not purpose: - purpose = IssueCredentialProofPurpose() + purpose = CredentialIssuancePurpose() result = await ld_proofs_verify( document=credential, diff --git a/aries_cloudagent/wallet/util.py b/aries_cloudagent/wallet/util.py index 73e48be4e8..16d2dc4b3a 100644 --- a/aries_cloudagent/wallet/util.py +++ b/aries_cloudagent/wallet/util.py @@ -70,17 +70,31 @@ def full_verkey(did: str, abbr_verkey: str) -> str: ) +def public_key_base58_to_fingerprint(public_key_base58: str) -> str: + key_bytes = b58_to_bytes(public_key_base58) + prefixed_key_bytes = add_prefix("ed25519-pub", key_bytes) + fingerprint = f"z{bytes_to_b58(prefixed_key_bytes)}" + + return fingerprint + + +def fingerprint_to_public_key_base58(fingerprint: str): + assert fingerprint[0] == "z" + + # skip leading `z` that indicates base58 encoding + stripped_key_bytes = b58_to_bytes(fingerprint[1:]) + naked_key_bytes = remove_prefix(stripped_key_bytes) + return bytes_to_b58(naked_key_bytes) + + def naked_to_did_key(key: str) -> str: """Convert a naked ed25519 verkey to W3C did:key format.""" - key_bytes = b58_to_bytes(key) - prefixed_key_bytes = add_prefix("ed25519-pub", key_bytes) - did_key = f"did:key:z{bytes_to_b58(prefixed_key_bytes)}" + fingerprint = public_key_base58_to_fingerprint(key) + did_key = f"did:key:{fingerprint}" return did_key def did_key_to_naked(did_key: str) -> str: """Convert a W3C did:key to naked ed25519 verkey format.""" - stripped_key = did_key.split("did:key:z").pop() - stripped_key_bytes = b58_to_bytes(stripped_key) - naked_key_bytes = remove_prefix(stripped_key_bytes) - return bytes_to_b58(naked_key_bytes) + fingerprint = did_key.split("did:key:").pop() + return fingerprint_to_public_key_base58(fingerprint) From ee8fed389a971cda9e540dc32037b287234adffa Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 15 Mar 2021 16:18:10 +0100 Subject: [PATCH 012/138] linked data proof overhaul Signed-off-by: Timo Glastra --- .../suites/JwsLinkedDataSignature.py | 12 +++++-- .../vc/ld_proofs/suites/LinkedDataProof.py | 5 ++- .../ld_proofs/suites/LinkedDataSignature.py | 36 +++++++++---------- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py index 7eaff157f4..5602cb1008 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py @@ -23,7 +23,12 @@ def __init__( date: Union[datetime, str] = None, ): - super().__init__(signature_type, verification_method, proof=proof, date=date) + super().__init__( + signature_type=signature_type, + verification_method=verification_method, + proof=proof, + date=date, + ) self.algorithm = algorithm self.key_pair = key_pair @@ -44,7 +49,10 @@ async def sign(self, verify_data: bytes, proof: dict): return proof async def verify_signature( - self, verify_data: bytes, verification_method: dict, proof: dict + self, + verify_data: bytes, + proof: dict, + verification_method: dict, ): if not ( diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py index 1c7fafdbb1..e3787f3f4f 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py @@ -11,7 +11,7 @@ def __init__(self, signature_type: str): @abstractmethod async def create_proof( - self, document: dict, *, purpose: ProofPurpose, document_loader: DocumentLoader + self, document: dict, purpose: ProofPurpose, document_loader: DocumentLoader ): pass @@ -19,10 +19,9 @@ async def create_proof( async def verify_proof( self, proof: dict, - *, document: dict, purpose: ProofPurpose, - document_loader: DocumentLoader + document_loader: DocumentLoader, ): pass diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py index ff8b90b7f3..23fada113b 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -15,11 +15,10 @@ def __init__( self, signature_type: str, verification_method: str, - *, proof: dict = None, date: Union[datetime, str, None] = None, ): - super().__init__(signature_type) + super().__init__(signature_type=signature_type) self.verification_method = verification_method self.proof = proof self.date = date @@ -36,14 +35,14 @@ def sign(self, verify_data: bytes, proof: dict): @abstractmethod def verify_signature( - self, verify_data: bytes, verification_method: dict, proof: dict + self, verify_data: bytes, proof: dict, verification_method: dict ): pass # PUBLIC METHODS async def create_proof( - self, document: dict, *, purpose: ProofPurpose, document_loader: DocumentLoader + self, document: dict, purpose: ProofPurpose, document_loader: DocumentLoader ) -> dict: proof = None if self.proof: @@ -67,13 +66,15 @@ async def create_proof( if self.verification_method: proof["verificationMethod"] = self.verification_method - proof = await self.update_proof(proof) + proof = await self.update_proof(proof=proof) proof = purpose.update(proof) - verify_data = await self.create_verify_data(document, proof, document_loader) + verify_data = await self._create_verify_data( + proof=proof, document=document, document_loader=document_loader + ) - proof = await self.sign(verify_data, proof) + proof = await self.sign(verify_data=verify_data, proof=proof) return proof async def update_proof(self, proof: dict): @@ -85,17 +86,16 @@ async def update_proof(self, proof: dict): async def verify_proof( self, proof: dict, - *, document: dict, purpose: ProofPurpose, document_loader: DocumentLoader, ): try: verify_data = self._create_verify_data( - document=document, proof=proof, document_loader=document_loader + proof=proof, document=document, document_loader=document_loader ) verification_method = self._get_verification_method( - proof, document_loader=document_loader + proof=proof, document_loader=document_loader ) verified = await self.verify_signature( @@ -110,7 +110,7 @@ async def verify_proof( raise Exception("Invalid signature") purpose_result = await purpose.validate( - proof, + proof=proof, document=document, suite=self, verification_method=verification_method, @@ -124,7 +124,7 @@ async def verify_proof( except Exception as err: return {"verified": False, "error": err} - def _get_verification_method(self, proof: dict, *, document_loader: DocumentLoader): + def _get_verification_method(self, proof: dict, document_loader: DocumentLoader): verification_method = proof.get("verificationMethod") if not verification_method: @@ -149,18 +149,18 @@ def _get_verification_method(self, proof: dict, *, document_loader: DocumentLoad return framed def _create_verify_data( - self, *, document: dict, proof: dict, document_loader: DocumentLoader + self, proof: dict, document: dict, document_loader: DocumentLoader ) -> str: c14n_proof_options = self._canonize_proof( - proof, document_loader=document_loader + proof=proof, document_loader=document_loader ) - c14n_doc = self._canonize(document, document_loader=document_loader) + c14n_doc = self._canonize(input=document, document_loader=document_loader) hash = sha256(c14n_proof_options.encode()) hash.update(c14n_doc.encode()) return hash.digest() - def _canonize(self, input, *, document_loader: DocumentLoader = None) -> str: + def _canonize(self, input, document_loader: DocumentLoader = None) -> str: # application/n-quads format always returns str return jsonld.normalize( input, @@ -171,11 +171,11 @@ def _canonize(self, input, *, document_loader: DocumentLoader = None) -> str: }, ) - def _canonize_proof(self, proof: dict, *, document_loader: DocumentLoader = None): + def _canonize_proof(self, proof: dict, document_loader: DocumentLoader = None): proof = proof.copy() proof.pop("jws", None) proof.pop("signatureValue", None) proof.pop("proofValue", None) - return self._canonize(proof, document_loader=document_loader) + return self._canonize(input=proof, document_loader=document_loader) From b294962f7dc9483b24e29ebe89bc8040c887786b Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 15 Mar 2021 16:51:41 +0100 Subject: [PATCH 013/138] proof purpose overhaul Signed-off-by: Timo Glastra --- .../vc/ld_proofs/purposes/AssertionProofPurpose.py | 2 +- .../vc/ld_proofs/purposes/AuthenticationProofPurpose.py | 6 ++---- .../vc/ld_proofs/purposes/ControllerProofPurpose.py | 9 +++++---- .../vc/ld_proofs/purposes/CredentialIssuancePurpose.py | 1 - aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py | 5 ++--- aries_cloudagent/vc/vc_ld/verify.py | 2 +- 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py index d494787bbe..1d4a0a7146 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py @@ -4,7 +4,7 @@ class AssertionProofPurpose(ControllerProofPurpose): - def __init__(self, *, date: datetime = None, max_timestamp_delta: timedelta = None): + def __init__(self, date: datetime = None, max_timestamp_delta: timedelta = None): super().__init__( term="assertionMethod", date=date, max_timestamp_delta=max_timestamp_delta ) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py index 4c33b18fff..b60783dae1 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py @@ -10,7 +10,6 @@ class AuthenticationProofPurpose(ControllerProofPurpose): def __init__( self, challenge: str, - *, domain: str = None, date: datetime = None, max_timestamp_delta: timedelta = None, @@ -25,7 +24,6 @@ def __init__( def validate( self, proof: dict, - *, document: dict, suite: LinkedDataProof, verification_method: dict, @@ -34,7 +32,7 @@ def validate( try: if proof.get("challenge") != self.challenge: raise Exception( - f'The challenge is not expected; challenge={proof.get("challenge")}, expected=[{self.challenge}]' + f'The challenge is not expected; challenge={proof.get("challenge")}, expected={self.challenge}' ) if self.domain and (proof.get("domain") != self.domain): @@ -43,7 +41,7 @@ def validate( ) return super().validate( - proof, + proof=proof, document=document, suite=suite, verification_method=verification_method, diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py index b27a6faf98..945b3aff6a 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py @@ -10,14 +10,13 @@ class ControllerProofPurpose(ProofPurpose): def __init__( - self, term: str, *, date: datetime = None, max_timestamp_delta: timedelta = None + self, term: str, date: datetime = None, max_timestamp_delta: timedelta = None ): super().__init__(term=term, date=date, max_timestamp_delta=max_timestamp_delta) def validate( self, proof: dict, - *, document: dict, suite: LinkedDataProof, verification_method: dict, @@ -51,12 +50,14 @@ def validate( result["controller"] = framed - verification_methods = JsonLdProcessor.get_values(framed, self.term) + verification_methods = JsonLdProcessor.get_values( + result.get("controller"), self.term + ) result["valid"] = any( method == verification_id for method in verification_methods ) - if not result["valid"]: + if not result.get("valid"): raise Exception( f"Verification method {verification_id} not authorized by controller for proof purpose {self.term}" ) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py index 6b1c027c76..2b3d675775 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py @@ -14,7 +14,6 @@ def __init__(self, date: datetime = None, max_timestamp_delta: timedelta = None) def validate( self, proof: dict, - *, document: dict, suite: LinkedDataProof, verification_method: str, diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py index 7c7db96a1c..c7f0668468 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py @@ -6,7 +6,7 @@ class ProofPurpose: def __init__( - self, term: str, *, date: datetime = None, max_timestamp_delta: timedelta = None + self, term: str, date: datetime = None, max_timestamp_delta: timedelta = None ): self.term = term self.date = date or datetime.now() @@ -15,11 +15,10 @@ def __init__( def validate( self, proof: dict, - *, document: dict, suite: LinkedDataProof, verification_method: dict, - document_loader: DocumentLoader + document_loader: DocumentLoader, ) -> dict: try: if self.max_timestamp_delta is not None: diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index 8cd77b04b8..d144595cb3 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -87,7 +87,7 @@ async def _verify_presentation( ) if not purpose: - purpose = AuthenticationProofPurpose(controller, challenge, domain=domain) + purpose = AuthenticationProofPurpose(challenge=challenge, domain=domain) vp, presentation_result = None, None From 9bd23582b2741319b8031b048416fed466a35284 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Wed, 17 Mar 2021 15:17:43 +0100 Subject: [PATCH 014/138] issue / verify credential with basic test Signed-off-by: Timo Glastra --- .../protocols/issue_credential/v2_0/routes.py | 51 +--- aries_cloudagent/vc/ld_proofs/ProofSet.py | 96 ++++--- .../vc/ld_proofs/VerificationException.py | 4 +- aries_cloudagent/vc/ld_proofs/constants.py | 9 +- .../vc/ld_proofs/crypto/KeyPair.py | 2 +- aries_cloudagent/vc/ld_proofs/ld_proofs.py | 82 ++++-- .../vc/ld_proofs/mock/test_documents.py | 22 -- .../purposes/ControllerProofPurpose.py | 24 +- .../vc/ld_proofs/purposes/ProofPurpose.py | 2 +- .../suites/JwsLinkedDataSignature.py | 49 ++-- .../vc/ld_proofs/suites/LinkedDataProof.py | 17 +- .../ld_proofs/suites/LinkedDataSignature.py | 67 +++-- .../vc/ld_proofs/{mock => tests}/__init__.py | 0 .../vc/ld_proofs/tests/test_doc.py | 80 ++++++ .../vc/ld_proofs/tests/test_ld_proofs.py | 62 +++++ aries_cloudagent/vc/ld_proofs/util.py | 31 ++- aries_cloudagent/vc/tests/__init__.py | 0 .../vc/tests/contexts/__init__.py | 20 ++ aries_cloudagent/vc/tests/contexts/bbs_v1.py | 91 +++++++ .../vc/tests/contexts/citizenship_v1.py | 45 ++++ .../vc/tests/contexts/credentials_v1.py | 250 ++++++++++++++++++ aries_cloudagent/vc/tests/contexts/did_v1.py | 64 +++++ .../vc/tests/contexts/examples_v1.py | 46 ++++ aries_cloudagent/vc/tests/contexts/odrl.py | 181 +++++++++++++ .../vc/tests/contexts/security_v1.py | 50 ++++ .../vc/tests/contexts/security_v2.py | 93 +++++++ aries_cloudagent/vc/tests/dids.py | 32 +++ aries_cloudagent/vc/tests/document_loader.py | 46 ++++ aries_cloudagent/vc/vc_ld/__init__.py | 10 +- aries_cloudagent/vc/vc_ld/checker.py | 31 +-- aries_cloudagent/vc/vc_ld/constants.py | 5 - aries_cloudagent/vc/vc_ld/issue.py | 14 +- .../vc/vc_ld/models/Credential.py | 64 +++++ aries_cloudagent/vc/vc_ld/prove.py | 4 +- aries_cloudagent/vc/vc_ld/tests/__init__.py | 0 .../vc/vc_ld/tests/test_credential.py | 86 ++++++ aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py | 62 +++++ aries_cloudagent/vc/vc_ld/verify.py | 41 +-- aries_cloudagent/wallet/util.py | 6 +- 39 files changed, 1575 insertions(+), 264 deletions(-) delete mode 100644 aries_cloudagent/vc/ld_proofs/mock/test_documents.py rename aries_cloudagent/vc/ld_proofs/{mock => tests}/__init__.py (100%) create mode 100644 aries_cloudagent/vc/ld_proofs/tests/test_doc.py create mode 100644 aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py create mode 100644 aries_cloudagent/vc/tests/__init__.py create mode 100644 aries_cloudagent/vc/tests/contexts/__init__.py create mode 100644 aries_cloudagent/vc/tests/contexts/bbs_v1.py create mode 100644 aries_cloudagent/vc/tests/contexts/citizenship_v1.py create mode 100644 aries_cloudagent/vc/tests/contexts/credentials_v1.py create mode 100644 aries_cloudagent/vc/tests/contexts/did_v1.py create mode 100644 aries_cloudagent/vc/tests/contexts/examples_v1.py create mode 100644 aries_cloudagent/vc/tests/contexts/odrl.py create mode 100644 aries_cloudagent/vc/tests/contexts/security_v1.py create mode 100644 aries_cloudagent/vc/tests/contexts/security_v2.py create mode 100644 aries_cloudagent/vc/tests/dids.py create mode 100644 aries_cloudagent/vc/tests/document_loader.py delete mode 100644 aries_cloudagent/vc/vc_ld/constants.py create mode 100644 aries_cloudagent/vc/vc_ld/models/Credential.py create mode 100644 aries_cloudagent/vc/vc_ld/tests/__init__.py create mode 100644 aries_cloudagent/vc/vc_ld/tests/test_credential.py create mode 100644 aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py index 3eda12f84d..880a0f6f63 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py @@ -21,14 +21,10 @@ from ....messaging.decorators.attach_decorator import AttachDecorator from ....messaging.models.base import BaseModelError, OpenAPISchema from ....messaging.valid import ( - CREDENTIAL_CONTEXT, - CREDENTIAL_SUBJECT, - CREDENTIAL_TYPE, INDY_CRED_DEF_ID, INDY_DID, INDY_SCHEMA_ID, INDY_VERSION, - URI, UUIDFour, UUID4, ) @@ -51,6 +47,8 @@ from .models.detail.ld_proof import V20CredExRecordLDProofSchema from .models.detail.indy import V20CredExRecordIndySchema +from ....vc.vc_ld.models.Credential import LDCredential + class V20IssueCredentialModuleResponseSchema(OpenAPISchema): """Response schema for v2.0 Issue Credential Module.""" @@ -152,51 +150,6 @@ class V20CredFilterIndySchema(OpenAPISchema): ) -class LDCredential(Schema): - context = fields.List( - fields.Str(), - data_key="@context", - required=True, - description="The JSON-LD context of the credential", - **CREDENTIAL_CONTEXT, - ) - id = fields.Str( - required=False, - desscription="The ID of the credential", - example="http://example.edu/credentials/1872", - validate=URI(), - ) - type = fields.List( - fields.Str(), - required=True, - description="The JSON-LD type of the credential", - **CREDENTIAL_TYPE, - ) - issuer = fields.Str( - required=False, description="The JSON-LD Verifiable Credential Issuer" - ) - issuance_date = fields.DateTime( - data_key="issuanceDate", - required=False, - description="The issuance date", - example="2010-01-01T19:73:24Z", - ) - expiration_date = fields.DateTime( - data_key="expirationDate", - required=False, - description="The expiration date", - example="2010-01-01T19:73:24Z", - ) - credential_subject = fields.Dict( - required=True, - keys=fields.Str(), - data_key="credentialSubject", - **CREDENTIAL_SUBJECT, - ) - # TODO: add typing - credential_schema = fields.Dict(required=False, data_key="credentialSchema") - - class LDCredentialOptions(Schema): proof_type = fields.List( fields.Str( diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py index 017c5ee208..bfeaf58360 100644 --- a/aries_cloudagent/vc/ld_proofs/ProofSet.py +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -1,32 +1,31 @@ """Class to represent a linked data proof set.""" -import asyncio -from typing import Union, List + +from typing import List from pyld.jsonld import JsonLdProcessor -from .suites import LinkedDataProof -from .purposes.ProofPurpose import ProofPurpose +from .constants import SECURITY_V2_URL from .document_loader import DocumentLoader -from .constants import SECURITY_CONTEXT_V2_URL +from .purposes.ProofPurpose import ProofPurpose +from .suites import LinkedDataProof class ProofSet: - def __init__(self, document: dict) -> None: - self._document = document.copy() - + @staticmethod async def add( - self, *, document: dict, - suite: List[LinkedDataProof], + suite: LinkedDataProof, purpose: ProofPurpose, document_loader: DocumentLoader ) -> dict: - input_ = document.copy() + document = document.copy() - if "proof" in input_: - del input_["proof"] + if "proof" in document: + del document["proof"] - proof = await suite.create_proof(input_, purpose, document_loader) + proof = await suite.create_proof( + document=document, purpose=purpose, document_loader=document_loader + ) if "@context" in proof: del proof["@context"] @@ -35,36 +34,41 @@ async def add( return document + @staticmethod async def verify( - self, *, - document: Union[dict, str], + document: dict, suites: List[LinkedDataProof], purpose: ProofPurpose, document_loader: DocumentLoader - ): + ) -> dict: try: - if isinstance(document, str): - document = await document_loader(document) + document = document.copy() - proofs = await ProofSet._get_proofs(document, document_loader) + proofs = await ProofSet._get_proofs(document=document) - results = ProofSet._verify( - document, suites, proofs["proof_set"], purpose, document_loader + results = await ProofSet._verify( + document=document, + suites=suites, + proof_set=proofs.get("proof_set"), + purpose=purpose, + document_loader=document_loader, ) - if results.len() == 0: + if len(results) == 0: raise Exception( "Could not verify any proofs; no proofs matched the required suite and purpose" ) - verified = any(x["verified"] for x in results) + verified = any(result.get("verified") for result in results) if not verified: - errors = [r["error"] for r in results if r["error"]] + errors = [ + result.get("error") for result in results if result.get("error") + ] result = {"verified": verified, "results": results} - if errors.len() > 0: + if len(errors) > 0: result["error"] = errors return result @@ -74,17 +78,16 @@ async def verify( return {"verified": verified, "error": e} @staticmethod - async def _get_proofs(document: dict, document_loader: DocumentLoader) -> dict: + async def _get_proofs(document: dict) -> dict: proof_set = JsonLdProcessor.get_values(document, "proof") - del document["proof"] + if "proof" in document: + del document["proof"] - if proof_set.len() == 0: + if len(proof_set) == 0: raise Exception("No matching proofs found in the given document") - proof_set = [ - {"@context": SECURITY_CONTEXT_V2_URL, **proof} for proof in proof_set - ] + proof_set = [{"@context": SECURITY_V2_URL, **proof} for proof in proof_set] return {"proof_set": proof_set, "document": document} @@ -95,7 +98,7 @@ async def _verify( proof_set: List[dict], purpose: ProofPurpose, document_loader: DocumentLoader, - ): + ) -> List[dict]: # Matches proof purposes proof set to passed purpose. # Only proofs with a `proofPurpose` that match the purpose are verified # e.g.: @@ -111,20 +114,13 @@ async def _verify( for proof in matches: for suite in suites: - if suite.match_proof(proof["type"]): - suite.verify_proof() - results.append() - - for m in matches: - - for s in suites: - if await s.match_proof(m["type"]): - out.append(s.verify_proof(m, document, purpose, document_loader)) - - results = await asyncio.gather( - *[x.verify_proof(x, document, purpose, document_loader) for x in out] - ) - - return [ - None if not r else {"proof": matches[i], **r} for r, i in enumerate(results) - ] + if suite.match_proof(proof.get("type")): + result = await suite.verify_proof( + proof=proof, + document=document, + purpose=purpose, + document_loader=document_loader, + ) + results.append({"proof": proof, **result}) + + return results diff --git a/aries_cloudagent/vc/ld_proofs/VerificationException.py b/aries_cloudagent/vc/ld_proofs/VerificationException.py index 5735da0399..9ab497aeba 100644 --- a/aries_cloudagent/vc/ld_proofs/VerificationException.py +++ b/aries_cloudagent/vc/ld_proofs/VerificationException.py @@ -4,6 +4,6 @@ class VerificationException(Exception): """Raised when verification verification fails.""" - def __init__(self, message: str, errors: Union[Exception, List[Exception]]): + def __init__(self, errors: Union[Exception, List[Exception]]): self.errors = errors if isinstance(errors, List) else [errors] - super().__init__(self.message) + super().__init__() diff --git a/aries_cloudagent/vc/ld_proofs/constants.py b/aries_cloudagent/vc/ld_proofs/constants.py index 8cf708b0e4..fc774a984b 100644 --- a/aries_cloudagent/vc/ld_proofs/constants.py +++ b/aries_cloudagent/vc/ld_proofs/constants.py @@ -1,3 +1,8 @@ -SECURITY_CONTEXT_V2_URL = "https://w3id.org/security/v2" -CREDENTIALS_CONTEXT_V1_URL = "https://www.w3.org/2018/credentials/v1" +SECURITY_V1_URL = "https://w3id.org/security/v1" +SECURITY_V2_URL = "https://w3id.org/security/v2" +DID_V1_URL = "https://w3id.org/did/v1" CREDENTIALS_ISSUER_URL = "https://www.w3.org/2018/credentials#issuer" +CREDENTIALS_V1_URL = "https://www.w3.org/2018/credentials/v1" +SECURITY_PROOF_URL = "https://w3id.org/security#proof" +SECURITY_SIGNATURE_URL = "https://w3id.org/security#signature" +SECURITY_BBS_URL = "https://w3id.org/security/bbs/v1" diff --git a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py index 3f160a37c9..8f55538177 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py @@ -7,5 +7,5 @@ async def sign(self, message: bytes) -> bytes: pass @abstractmethod - async def verify(self, message: bytes) -> bool: + async def verify(self, message: bytes, signature: bytes) -> bool: pass diff --git a/aries_cloudagent/vc/ld_proofs/ld_proofs.py b/aries_cloudagent/vc/ld_proofs/ld_proofs.py index c6babbf722..76db7b6fd3 100644 --- a/aries_cloudagent/vc/ld_proofs/ld_proofs.py +++ b/aries_cloudagent/vc/ld_proofs/ld_proofs.py @@ -1,22 +1,42 @@ -from typing import Union +"""Linked data proof signing and verification methods.""" + +from typing import List +from pyld.jsonld import JsonLdError -from .ProofSet import ProofSet -from .suites import LinkedDataSignature from .document_loader import DocumentLoader +from .ProofSet import ProofSet from .purposes import ProofPurpose -from pyld.jsonld import JsonLdError +from .suites import LinkedDataProof from .VerificationException import VerificationException async def sign( *, document: dict, - suite: LinkedDataSignature, + # TODO: support multiple signature suites + suite: LinkedDataProof, purpose: ProofPurpose, document_loader: DocumentLoader, -): +) -> dict: + """Cryptographically signs the provided document by adding a `proof` section. + + Proof is added based on the provided suite and proof purpose + + Args: + document (dict): The document to be signed. + suite (LinkedDataProof): The linked data signature cryptographic suite + with which to sign the document + purpose (ProofPurpose): A proof purpose instance that will match proofs to be + verified and ensure they were created according to the appropriate purpose. + document_loader (DocumentLoader): The document loader to use. + + Raises: + Exception: When a jsonld url cannot be resolved, OR signing fails. + Returns: + dict: Signed document. + """ try: - return await ProofSet().add( + return await ProofSet.add( document=document, suite=suite, purpose=purpose, @@ -28,34 +48,50 @@ async def sign( raise Exception( f'A URL "{e.details}" could not be fetched; you need to pass a DocumentLoader function that can resolve this URL, or resolve the URL before calling "sign".' ) - except Exception as e: raise e async def verify( *, - document: Union[dict, str], - suites: LinkedDataSignature, + document: dict, + suites: List[LinkedDataProof], purpose: ProofPurpose, document_loader: DocumentLoader, -): - result = await ProofSet().verify( +) -> dict: + """Verifies the linked data signature on the provided document. + + Args: + document (dict): The document with one or more proofs to be verified. + suites (List[LinkedDataProof]): Acceptable signature suite instances for + verifying the proof(s). + purpose (ProofPurpose): A proof purpose instance that will match proofs to be + verified and ensure they were created according to the appropriate purpose. + document_loader (DocumentLoader): The document loader to use. + + Returns: + dict: Dict with a `verified` boolean property that is `True` if at least one + proof matching the given purpose and suite verifies and `False` otherwise. + a `results` property with an array of detailed results. + if `False` an `error` property will be present, with `error.errors` + containing all of the errors that occurred during the verification process. + """ + + result = await ProofSet.verify( document=document, suites=suites, purpose=purpose, document_loader=document_loader, ) - if result["error"]: - if ( - hasattr(result["error"], "type") - and result["error"].type == "jsonld.InvalidUrl" - ): - url_err = Exception( - f'A URL "{result["error"].details}" could not be fetched; you need to pass a DocumentLoader function that can resolve this URL, or resolve the URL before calling "sign".' - ) - result["error"] = VerificationException(url_err) - else: - result["error"] = VerificationException(result["error"]) + if result.get("error"): + # TODO: is this necessary? Seems like it is vc-js specific + # TODO: error returns list, not object with type?? + # if result.get("error", {}).get("type") == "jsonld.InvalidUrl": + # url_err = Exception( + # f'A URL "{result.get("error").get("details")}" could not be fetched; you need to pass a DocumentLoader function that can resolve this URL, or resolve the URL before calling "sign".' + # ) + # result["error"] = VerificationException(url_err) + # else: + result["error"] = VerificationException(result.get("error")) return result diff --git a/aries_cloudagent/vc/ld_proofs/mock/test_documents.py b/aries_cloudagent/vc/ld_proofs/mock/test_documents.py deleted file mode 100644 index 08c6fa127e..0000000000 --- a/aries_cloudagent/vc/ld_proofs/mock/test_documents.py +++ /dev/null @@ -1,22 +0,0 @@ -from ..constants import SECURITY_CONTEXT_V2_URL - -non_security_context_test_doc = { - "@context": { - "schema": "http://schema.org/", - "name": "schema:name", - "homepage": "schema:url", - "image": "schema:image", - }, - "name": "Manu Sporny", - "homepage": "https://manu.sporny.org/", - "image": "https://manu.sporny.org/images/manu.png", -} - -security_context_test_doc = { - **non_security_context_test_doc, - "@context": [ - {"@version": 1.1}, - non_security_context_test_doc["@context"], - SECURITY_CONTEXT_V2_URL, - ], -} diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py index 945b3aff6a..4de41af8e0 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py @@ -1,10 +1,10 @@ from datetime import datetime, timedelta -from pyld import jsonld from pyld.jsonld import JsonLdProcessor -from ..constants import SECURITY_CONTEXT_V2_URL +from ..constants import SECURITY_V2_URL from ..suites import LinkedDataProof from ..document_loader import DocumentLoader +from ..util import frame_without_compact_to_relative from .ProofPurpose import ProofPurpose @@ -23,7 +23,13 @@ def validate( document_loader: DocumentLoader, ) -> dict: try: - result = super().validate(proof) + result = super().validate( + proof=proof, + document=document, + suite=suite, + verification_method=verification_method, + document_loader=document_loader, + ) if not result.get("valid"): raise result.get("error") @@ -38,14 +44,16 @@ def validate( else: raise Exception('"controller" must be a string or dict') - framed = jsonld.frame( - controller_id, - { - "@context": SECURITY_CONTEXT_V2_URL, + framed = frame_without_compact_to_relative( + input=controller_id, + frame={ + "@context": SECURITY_V2_URL, "id": controller_id, self.term: {"@embed": "@never", "id": verification_id}, }, - {"documentLoader": document_loader}, + options={ + "documentLoader": document_loader, + }, ) result["controller"] = framed diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py index c7f0668468..add8a1e455 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py @@ -31,7 +31,7 @@ def validate( ): raise Exception("The proof's created timestamp is out of range.") - return {"valid": True} + return {"valid": True} except Exception as err: return {"valid": False, "error": err} diff --git a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py index 5602cb1008..7d486892c0 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py @@ -3,11 +3,10 @@ from typing import Union import json -from ....wallet.util import bytes_to_b64, str_to_b64, b64_to_str +from ....wallet.util import b64_to_bytes, bytes_to_b64, str_to_b64, b64_to_str from ..crypto import KeyPair from ..document_loader import DocumentLoader from .LinkedDataSignature import LinkedDataSignature -from ..util import create_jws class JwsLinkedDataSignature(LinkedDataSignature): @@ -36,38 +35,37 @@ def __init__( async def sign(self, verify_data: bytes, proof: dict): header = {"alg": self.algorithm, "b64": False, "crit": ["b64"]} + encoded_header = self._encode_header(header) - encoded_header = str_to_b64(json.dumps(header), urlsafe=True, pad=False) - - data = create_jws(encoded_header, verify_data) + data = self._create_jws(encoded_header, verify_data) signature = await self.key_pair.sign(data) - encoded_signature = bytes_to_b64(signature, urlsafe=True, pad=False) + encoded_signature = bytes_to_b64( + signature, urlsafe=True, pad=False, encoding="utf-8" + ) - proof["jws"] = str(encoded_header) + ".." + encoded_signature + proof["jws"] = encoded_header + ".." + encoded_signature return proof async def verify_signature( self, verify_data: bytes, - proof: dict, verification_method: dict, + document: dict, + proof: dict, + document_loader: DocumentLoader, ): - - if not ( - ("jws" in proof) and isinstance(proof["jws"], str) and ("." in proof["jws"]) - ): + if not (isinstance(proof.get("jws"), str) and (".." in proof.get("jws"))): raise Exception('The proof does not contain a valid "jws" property.') - encoded_header, payload, encoded_signature = proof["jws"].split(".") + encoded_header, payload, encoded_signature = proof.get("jws").split(".") header = self._decode_header(encoded_header) - self._validate_header(header) - signature = b64_to_str(encoded_signature, urlsafe=True) - data = create_jws(encoded_header, verify_data) + signature = b64_to_bytes(encoded_signature, urlsafe=True) + data = self._create_jws(encoded_header, verify_data) return await self.key_pair.verify(data, signature) @@ -79,18 +77,25 @@ def _decode_header(self, encoded_header: str) -> dict: raise Exception("Could not parse JWS header.") return header + def _encode_header(self, header: dict) -> str: + return str_to_b64(json.dumps(header), urlsafe=True, pad=False) + + def _create_jws(self, encoded_header: str, verify_data: bytes) -> bytes: + """Compose JWS.""" + return (encoded_header + ".").encode("utf-8") + verify_data + def _validate_header(self, header: dict): """ Validates the JWS header, throws if not ok """ if not (header and isinstance(header, dict)): raise Exception("Invalid JWS header.") if not ( - header["alg"] == self.algorithm - and header["b64"] is False - and isinstance(header["crit"], list) - and header["crit"].len() == 1 - and header["crit"][0] == "b64" - and header.keys().len() == 3 + header.get("alg") == self.algorithm + and header.get("b64") is False + and isinstance(header.get("crit"), list) + and len(header.get("crit")) == 1 + and header.get("crit")[0] == "b64" + and len(header.keys()) == 3 ): raise Exception(f"Invalid JWS header params for {self.signature_type}") diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py index e3787f3f4f..6086c1842e 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py @@ -1,9 +1,14 @@ """Abstract base class for linked data proofs.""" -from ..purposes.ProofPurpose import ProofPurpose -from ..document_loader import DocumentLoader +from typing import TYPE_CHECKING from abc import ABCMeta, abstractmethod +from ..document_loader import DocumentLoader + +# ProofPurpose and LinkedDataProof depend on each other +if TYPE_CHECKING: + from ..purposes.ProofPurpose import ProofPurpose + class LinkedDataProof(metaclass=ABCMeta): def __init__(self, signature_type: str): @@ -11,8 +16,8 @@ def __init__(self, signature_type: str): @abstractmethod async def create_proof( - self, document: dict, purpose: ProofPurpose, document_loader: DocumentLoader - ): + self, document: dict, purpose: "ProofPurpose", document_loader: DocumentLoader + ) -> dict: pass @abstractmethod @@ -20,9 +25,9 @@ async def verify_proof( self, proof: dict, document: dict, - purpose: ProofPurpose, + purpose: "ProofPurpose", document_loader: DocumentLoader, - ): + ) -> dict: pass def match_proof(self, signature_type: str) -> bool: diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py index 23fada113b..4ac957f44b 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -1,3 +1,4 @@ +import traceback from pyld import jsonld from datetime import datetime from hashlib import sha256 @@ -6,7 +7,8 @@ from ..document_loader import DocumentLoader from ..purposes import ProofPurpose -from ..constants import SECURITY_CONTEXT_V2_URL +from ..constants import SECURITY_V2_URL +from ..util import frame_without_compact_to_relative from .LinkedDataProof import LinkedDataProof @@ -23,19 +25,24 @@ def __init__( self.proof = proof self.date = date - if isinstance(date, datetime): + if isinstance(date, str): # cast date to datetime if str - self.date = datetime.strptime(date) + self.date = datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ") # ABSTRACT METHODS @abstractmethod - def sign(self, verify_data: bytes, proof: dict): + async def sign(self, verify_data: bytes, proof: dict): pass @abstractmethod - def verify_signature( - self, verify_data: bytes, proof: dict, verification_method: dict + async def verify_signature( + self, + verify_data: bytes, + verification_method: dict, + document: dict, + proof: dict, + document_loader: DocumentLoader, ): pass @@ -50,12 +57,13 @@ async def create_proof( # TODO verify if the other optional params shown in jsonld-signatures are # required proof = jsonld.compact( - self.proof, SECURITY_CONTEXT_V2_URL, {"documentLoader": document_loader} + self.proof, SECURITY_V2_URL, {"documentLoader": document_loader} ) else: - proof = {"@context": SECURITY_CONTEXT_V2_URL} + proof = {"@context": SECURITY_V2_URL} proof["type"] = self.signature_type + proof["verificationMethod"] = self.verification_method if not self.date: self.date = datetime.now() @@ -63,21 +71,17 @@ async def create_proof( if not proof.get("created"): proof["created"] = self.date.isoformat() - if self.verification_method: - proof["verificationMethod"] = self.verification_method - - proof = await self.update_proof(proof=proof) - + proof = self.update_proof(proof=proof) proof = purpose.update(proof) - verify_data = await self._create_verify_data( + verify_data = self._create_verify_data( proof=proof, document=document, document_loader=document_loader ) proof = await self.sign(verify_data=verify_data, proof=proof) return proof - async def update_proof(self, proof: dict): + def update_proof(self, proof: dict): """ Extending classes may do more """ @@ -89,7 +93,7 @@ async def verify_proof( document: dict, purpose: ProofPurpose, document_loader: DocumentLoader, - ): + ) -> dict: try: verify_data = self._create_verify_data( proof=proof, document=document, document_loader=document_loader @@ -109,7 +113,7 @@ async def verify_proof( if not verified: raise Exception("Invalid signature") - purpose_result = await purpose.validate( + purpose_result = purpose.validate( proof=proof, document=document, suite=self, @@ -122,7 +126,12 @@ async def verify_proof( return {"verified": True, "purpose_result": purpose_result} except Exception as err: - return {"verified": False, "error": err} + return { + "verified": False, + "error": err, + # TODO: leave trace in error? + "trace": traceback.format_exc(), + } def _get_verification_method(self, proof: dict, document_loader: DocumentLoader): verification_method = proof.get("verificationMethod") @@ -130,10 +139,13 @@ def _get_verification_method(self, proof: dict, document_loader: DocumentLoader) if not verification_method: raise Exception('No "verificationMethod" found in proof') - framed = jsonld.frame( - verification_method, - { - "@context": SECURITY_CONTEXT_V2_URL, + if isinstance(verification_method, dict): + verification_method: str = verification_method.get("id") + + framed = frame_without_compact_to_relative( + input=verification_method, + frame={ + "@context": SECURITY_V2_URL, "@embed": "@always", "id": verification_method, }, @@ -150,15 +162,18 @@ def _get_verification_method(self, proof: dict, document_loader: DocumentLoader) def _create_verify_data( self, proof: dict, document: dict, document_loader: DocumentLoader - ) -> str: + ) -> bytes: c14n_proof_options = self._canonize_proof( proof=proof, document_loader=document_loader ) c14n_doc = self._canonize(input=document, document_loader=document_loader) - hash = sha256(c14n_proof_options.encode()) - hash.update(c14n_doc.encode()) - return hash.digest() + # TODO: detect any dropped properties using expand/contract step + + return ( + sha256(c14n_proof_options.encode("utf-8")).digest() + + sha256(c14n_doc.encode("utf-8")).digest() + ) def _canonize(self, input, document_loader: DocumentLoader = None) -> str: # application/n-quads format always returns str diff --git a/aries_cloudagent/vc/ld_proofs/mock/__init__.py b/aries_cloudagent/vc/ld_proofs/tests/__init__.py similarity index 100% rename from aries_cloudagent/vc/ld_proofs/mock/__init__.py rename to aries_cloudagent/vc/ld_proofs/tests/__init__.py diff --git a/aries_cloudagent/vc/ld_proofs/tests/test_doc.py b/aries_cloudagent/vc/ld_proofs/tests/test_doc.py new file mode 100644 index 0000000000..33d70179b8 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/tests/test_doc.py @@ -0,0 +1,80 @@ +DOC_TEMPLATE = { + "@context": { + "schema": "http://schema.org/", + "name": "schema:name", + "homepage": "schema:url", + "image": "schema:image", + }, + "name": "Manu Sporny", + "homepage": "https://manu.sporny.org/", + "image": "https://manu.sporny.org/images/manu.png", +} + +DOC_SIGNED = { + "@context": { + "schema": "http://schema.org/", + "name": "schema:name", + "homepage": "schema:url", + "image": "schema:image", + }, + "name": "Manu Sporny", + "homepage": "https://manu.sporny.org/", + "image": "https://manu.sporny.org/images/manu.png", + "proof": { + "proofPurpose": "assertionMethod", + "created": "2019-12-11T03:50:55", + "type": "Ed25519Signature2018", + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..Q6amIrxGiSbM7Ce6DxlfwLCjVcYyclas8fMxaecspXFUcFW9DAAxKzgHx93FWktnlZjM_biitkMgZdStgvivAQ", + }, +} + +DOC_VERIFIED = { + "verified": True, + "results": [ + { + "proof": { + "@context": "https://w3id.org/security/v2", + "proofPurpose": "assertionMethod", + "created": "2019-12-11T03:50:55", + "type": "Ed25519Signature2018", + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..Q6amIrxGiSbM7Ce6DxlfwLCjVcYyclas8fMxaecspXFUcFW9DAAxKzgHx93FWktnlZjM_biitkMgZdStgvivAQ", + }, + "verified": True, + "purpose_result": { + "valid": True, + "controller": { + "@context": "https://w3id.org/security/v2", + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "assertionMethod": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], + "authentication": [ + { + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "type": "Ed25519VerificationKey2018", + "controller": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "publicKeyBase58": "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx", + } + ], + "capabilityDelegation": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], + "capabilityInvocation": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], + "keyAgreement": [ + { + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6LSbkodSr6SU2trs8VUgnrnWtSm7BAPG245ggrBmSrxbv1R", + "type": "X25519KeyAgreementKey2019", + "controller": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "publicKeyBase58": "5dTvYHaNaB7mk7iA9LqCJEHG2dGZQsvoi8WGzDRtYEf", + } + ], + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + }, + }, + } + ], +} diff --git a/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py b/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py new file mode 100644 index 0000000000..3753f11e67 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py @@ -0,0 +1,62 @@ +from asynctest import TestCase + +from datetime import datetime + +from ....wallet.base import KeyInfo +from ....wallet.util import naked_to_did_key +from ....wallet.in_memory import InMemoryWallet +from ....core.in_memory import InMemoryProfile +from ...ld_proofs import ( + sign, + Ed25519Signature2018, + Ed25519WalletKeyPair, + AssertionProofPurpose, + verify, +) +from ...tests.document_loader import custom_document_loader +from .test_doc import DOC_TEMPLATE, DOC_SIGNED, DOC_VERIFIED + + +class TestLDProofs(TestCase): + test_seed = "testseed000000000000000000000001" + key_info: KeyInfo = None + + async def setUp(self): + self.profile = InMemoryProfile.test_profile() + self.wallet = InMemoryWallet(self.profile) + self.key_info = await self.wallet.create_signing_key(self.test_seed) + + self.key_pair = Ed25519WalletKeyPair( + wallet=self.wallet, public_key_base58=self.key_info.verkey + ) + self.verification_method = ( + naked_to_did_key(self.key_info.verkey) + "#" + self.key_pair.fingerprint() + ) + self.suite = Ed25519Signature2018( + # TODO: should we provide verification_method here? Or abstract? + verification_method=self.verification_method, + key_pair=Ed25519WalletKeyPair( + wallet=self.wallet, public_key_base58=self.key_info.verkey + ), + date=datetime.strptime("2019-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), + ) + + async def test_sign(self): + signed = await sign( + document=DOC_TEMPLATE, + suite=self.suite, + purpose=AssertionProofPurpose(), + document_loader=custom_document_loader, + ) + + assert DOC_SIGNED == signed + + async def test_verify(self): + verified = await verify( + document=DOC_SIGNED, + suites=[self.suite], + purpose=AssertionProofPurpose(), + document_loader=custom_document_loader, + ) + + assert DOC_VERIFIED == verified diff --git a/aries_cloudagent/vc/ld_proofs/util.py b/aries_cloudagent/vc/ld_proofs/util.py index 98931898ff..e647ea0318 100644 --- a/aries_cloudagent/vc/ld_proofs/util.py +++ b/aries_cloudagent/vc/ld_proofs/util.py @@ -1,6 +1,31 @@ +from pyld import jsonld from typing import Union -def create_jws(encoded_header: str, verify_data: bytes) -> bytes: - """Compose JWS.""" - return (encoded_header + ".").encode("utf-8") + verify_data \ No newline at end of file +def frame_without_compact_to_relative( + input: Union[dict, str], frame: dict, options: dict = None +): + """Frame document without compacting to relative. + + We need to expand first as otherwise the base (e.g. did: from did:key) is removed. + in jsonld.js this can be solved by setting `compactToRelative` to false + however this is not supported in pyld. + https://github.com/digitalbazaar/jsonld.js/blob/93a9d3f9abaffb7666f0fe0cb1adf59e0f816b5a/lib/jsonld.js#L111 + + Args: + input (Union[dict, str]): the JSON-LD input to frame. + frame (dict): the JSON-LD frame to use. + options (dict, optional): the options to use. Defaults to None. + + Returns: + the framed JSON-LD output. + """ + expanded = jsonld.expand(input, options=options) + + framed = jsonld.frame( + expanded, + frame, + options=options, + ) + + return framed diff --git a/aries_cloudagent/vc/tests/__init__.py b/aries_cloudagent/vc/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/vc/tests/contexts/__init__.py b/aries_cloudagent/vc/tests/contexts/__init__.py new file mode 100644 index 0000000000..357eebc6f9 --- /dev/null +++ b/aries_cloudagent/vc/tests/contexts/__init__.py @@ -0,0 +1,20 @@ +from .did_v1 import DID_V1 +from .security_v1 import SECURITY_V1 +from .security_v2 import SECURITY_V2 +from .bbs_v1 import BBS_V1 +from .credentials_v1 import CREDENTIALS_V1 +from .citizenship_v1 import CITIZENSHIP_V1 +from .examples_v1 import EXAMPLES_V1 +from .odrl import ODRL + + +__all__ = [ + DID_V1, + SECURITY_V1, + SECURITY_V2, + BBS_V1, + CREDENTIALS_V1, + CITIZENSHIP_V1, + EXAMPLES_V1, + ODRL, +] diff --git a/aries_cloudagent/vc/tests/contexts/bbs_v1.py b/aries_cloudagent/vc/tests/contexts/bbs_v1.py new file mode 100644 index 0000000000..6fd62b2881 --- /dev/null +++ b/aries_cloudagent/vc/tests/contexts/bbs_v1.py @@ -0,0 +1,91 @@ +BBS_V1 = { + "@context": { + "@version": 1.1, + "id": "@id", + "type": "@type", + "BbsBlsSignature2020": { + "@id": "https://w3id.org/security#BbsBlsSignature2020", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "challenge": "https://w3id.org/security#challenge", + "created": { + "@id": "http://purl.org/dc/terms/created", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + "domain": "https://w3id.org/security#domain", + "proofValue": "https://w3id.org/security#proofValue", + "nonce": "https://w3id.org/security#nonce", + "proofPurpose": { + "@id": "https://w3id.org/security#proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "assertionMethod": { + "@id": "https://w3id.org/security#assertionMethod", + "@type": "@id", + "@container": "@set", + }, + "authentication": { + "@id": "https://w3id.org/security#authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + "verificationMethod": { + "@id": "https://w3id.org/security#verificationMethod", + "@type": "@id", + }, + }, + }, + "BbsBlsSignatureProof2020": { + "@id": "https://w3id.org/security#BbsBlsSignatureProof2020", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "challenge": "https://w3id.org/security#challenge", + "created": { + "@id": "http://purl.org/dc/terms/created", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + "domain": "https://w3id.org/security#domain", + "nonce": "https://w3id.org/security#nonce", + "proofPurpose": { + "@id": "https://w3id.org/security#proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "sec": "https://w3id.org/security#", + "assertionMethod": { + "@id": "https://w3id.org/security#assertionMethod", + "@type": "@id", + "@container": "@set", + }, + "authentication": { + "@id": "https://w3id.org/security#authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + "proofValue": "https://w3id.org/security#proofValue", + "verificationMethod": { + "@id": "https://w3id.org/security#verificationMethod", + "@type": "@id", + }, + }, + }, + "Bls12381G2Key2020": "https://w3id.org/security#Bls12381G2Key2020", + } +} \ No newline at end of file diff --git a/aries_cloudagent/vc/tests/contexts/citizenship_v1.py b/aries_cloudagent/vc/tests/contexts/citizenship_v1.py new file mode 100644 index 0000000000..b6f1beb4f8 --- /dev/null +++ b/aries_cloudagent/vc/tests/contexts/citizenship_v1.py @@ -0,0 +1,45 @@ +CITIZENSHIP_V1 = { + "@context": { + "@version": 1.1, + "@protected": True, + "name": "http://schema.org/name", + "description": "http://schema.org/description", + "identifier": "http://schema.org/identifier", + "image": {"@id": "http://schema.org/image", "@type": "@id"}, + "PermanentResidentCard": { + "@id": "https://w3id.org/citizenship#PermanentResidentCard", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "description": "http://schema.org/description", + "name": "http://schema.org/name", + "identifier": "http://schema.org/identifier", + "image": {"@id": "http://schema.org/image", "@type": "@id"}, + }, + }, + "PermanentResident": { + "@id": "https://w3id.org/citizenship#PermanentResident", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "ctzn": "https://w3id.org/citizenship#", + "schema": "http://schema.org/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "birthCountry": "ctzn:birthCountry", + "birthDate": {"@id": "schema:birthDate", "@type": "xsd:dateTime"}, + "commuterClassification": "ctzn:commuterClassification", + "familyName": "schema:familyName", + "gender": "schema:gender", + "givenName": "schema:givenName", + "lprCategory": "ctzn:lprCategory", + "lprNumber": "ctzn:lprNumber", + "residentSince": {"@id": "ctzn:residentSince", "@type": "xsd:dateTime"}, + }, + }, + "Person": "http://schema.org/Person", + } +} diff --git a/aries_cloudagent/vc/tests/contexts/credentials_v1.py b/aries_cloudagent/vc/tests/contexts/credentials_v1.py new file mode 100644 index 0000000000..b1b6928f88 --- /dev/null +++ b/aries_cloudagent/vc/tests/contexts/credentials_v1.py @@ -0,0 +1,250 @@ +CREDENTIALS_V1 = { + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "VerifiableCredential": { + "@id": "https://www.w3.org/2018/credentials#VerifiableCredential", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "cred": "https://www.w3.org/2018/credentials#", + "sec": "https://w3id.org/security#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "credentialSchema": { + "@id": "cred:credentialSchema", + "@type": "@id", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "cred": "https://www.w3.org/2018/credentials#", + "JsonSchemaValidator2018": "cred:JsonSchemaValidator2018", + }, + }, + "credentialStatus": {"@id": "cred:credentialStatus", "@type": "@id"}, + "credentialSubject": {"@id": "cred:credentialSubject", "@type": "@id"}, + "evidence": {"@id": "cred:evidence", "@type": "@id"}, + "expirationDate": { + "@id": "cred:expirationDate", + "@type": "xsd:dateTime", + }, + "holder": {"@id": "cred:holder", "@type": "@id"}, + "issued": {"@id": "cred:issued", "@type": "xsd:dateTime"}, + "issuer": {"@id": "cred:issuer", "@type": "@id"}, + "issuanceDate": {"@id": "cred:issuanceDate", "@type": "xsd:dateTime"}, + "proof": {"@id": "sec:proof", "@type": "@id", "@container": "@graph"}, + "refreshService": { + "@id": "cred:refreshService", + "@type": "@id", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "cred": "https://www.w3.org/2018/credentials#", + "ManualRefreshService2018": "cred:ManualRefreshService2018", + }, + }, + "termsOfUse": {"@id": "cred:termsOfUse", "@type": "@id"}, + "validFrom": {"@id": "cred:validFrom", "@type": "xsd:dateTime"}, + "validUntil": {"@id": "cred:validUntil", "@type": "xsd:dateTime"}, + }, + }, + "VerifiablePresentation": { + "@id": "https://www.w3.org/2018/credentials#VerifiablePresentation", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "cred": "https://www.w3.org/2018/credentials#", + "sec": "https://w3id.org/security#", + "holder": {"@id": "cred:holder", "@type": "@id"}, + "proof": {"@id": "sec:proof", "@type": "@id", "@container": "@graph"}, + "verifiableCredential": { + "@id": "cred:verifiableCredential", + "@type": "@id", + "@container": "@graph", + }, + }, + }, + "EcdsaSecp256k1Signature2019": { + "@id": "https://w3id.org/security#EcdsaSecp256k1Signature2019", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "sec": "https://w3id.org/security#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "challenge": "sec:challenge", + "created": { + "@id": "http://purl.org/dc/terms/created", + "@type": "xsd:dateTime", + }, + "domain": "sec:domain", + "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, + "jws": "sec:jws", + "nonce": "sec:nonce", + "proofPurpose": { + "@id": "sec:proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "sec": "https://w3id.org/security#", + "assertionMethod": { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + "authentication": { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + "proofValue": "sec:proofValue", + "verificationMethod": {"@id": "sec:verificationMethod", "@type": "@id"}, + }, + }, + "EcdsaSecp256r1Signature2019": { + "@id": "https://w3id.org/security#EcdsaSecp256r1Signature2019", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "sec": "https://w3id.org/security#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "challenge": "sec:challenge", + "created": { + "@id": "http://purl.org/dc/terms/created", + "@type": "xsd:dateTime", + }, + "domain": "sec:domain", + "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, + "jws": "sec:jws", + "nonce": "sec:nonce", + "proofPurpose": { + "@id": "sec:proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "sec": "https://w3id.org/security#", + "assertionMethod": { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + "authentication": { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + "proofValue": "sec:proofValue", + "verificationMethod": {"@id": "sec:verificationMethod", "@type": "@id"}, + }, + }, + "Ed25519Signature2018": { + "@id": "https://w3id.org/security#Ed25519Signature2018", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "sec": "https://w3id.org/security#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "challenge": "sec:challenge", + "created": { + "@id": "http://purl.org/dc/terms/created", + "@type": "xsd:dateTime", + }, + "domain": "sec:domain", + "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, + "jws": "sec:jws", + "nonce": "sec:nonce", + "proofPurpose": { + "@id": "sec:proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "sec": "https://w3id.org/security#", + "assertionMethod": { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + "authentication": { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + "proofValue": "sec:proofValue", + "verificationMethod": {"@id": "sec:verificationMethod", "@type": "@id"}, + }, + }, + "RsaSignature2018": { + "@id": "https://w3id.org/security#RsaSignature2018", + "@context": { + "@version": 1.1, + "@protected": True, + "challenge": "sec:challenge", + "created": { + "@id": "http://purl.org/dc/terms/created", + "@type": "xsd:dateTime", + }, + "domain": "sec:domain", + "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, + "jws": "sec:jws", + "nonce": "sec:nonce", + "proofPurpose": { + "@id": "sec:proofPurpose", + "@type": "@vocab", + "@context": { + "@version": 1.1, + "@protected": True, + "id": "@id", + "type": "@type", + "sec": "https://w3id.org/security#", + "assertionMethod": { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + "authentication": { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + }, + }, + "proofValue": "sec:proofValue", + "verificationMethod": {"@id": "sec:verificationMethod", "@type": "@id"}, + }, + }, + "proof": { + "@id": "https://w3id.org/security#proof", + "@type": "@id", + "@container": "@graph", + }, + } +} \ No newline at end of file diff --git a/aries_cloudagent/vc/tests/contexts/did_v1.py b/aries_cloudagent/vc/tests/contexts/did_v1.py new file mode 100644 index 0000000000..9b105062ec --- /dev/null +++ b/aries_cloudagent/vc/tests/contexts/did_v1.py @@ -0,0 +1,64 @@ +DID_V1 = { + "@context": { + "@version": 1.1, + "id": "@id", + "type": "@type", + "dc": "http://purl.org/dc/terms/", + "schema": "http://schema.org/", + "sec": "https://w3id.org/security#", + "didns": "https://www.w3.org/ns/did#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "EcdsaSecp256k1VerificationKey2019": "sec:EcdsaSecp256k1VerificationKey2019", + "Ed25519Signature2018": "sec:Ed25519Signature2018", + "Ed25519VerificationKey2018": "sec:Ed25519VerificationKey2018", + "JsonWebKey2020": "sec:JsonWebKey2020", + "JsonWebSignature2020": "sec:JsonWebSignature2020", + "Bls12381G1Key2020": "sec:Bls12381G1Key2020", + "Bls12381G2Key2020": "sec:Bls12381G2Key2020", + "RsaVerificationKey2018": "sec:RsaVerificationKey2018", + "SchnorrSecp256k1VerificationKey2019": "sec:SchnorrSecp256k1VerificationKey2019", + "X25519KeyAgreementKey2019": "sec:X25519KeyAgreementKey2019", + "ServiceEndpointProxyService": "didns:ServiceEndpointProxyService", + "LinkedDomains": "https://identity.foundation/.well-known/resources/did-configuration/#LinkedDomains", + "alsoKnownAs": { + "@id": "https://www.w3.org/ns/activitystreams#alsoKnownAs", + "@type": "@id", + "@container": "@set", + }, + "assertionMethod": { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + "authentication": { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + "capabilityDelegation": { + "@id": "sec:capabilityDelegationMethod", + "@type": "@id", + "@container": "@set", + }, + "capabilityInvocation": { + "@id": "sec:capabilityInvocationMethod", + "@type": "@id", + "@container": "@set", + }, + "controller": {"@id": "sec:controller", "@type": "@id"}, + "created": {"@id": "dc:created", "@type": "xsd:dateTime"}, + "blockchainAccountId": "sec:blockchainAccountId", + "keyAgreement": { + "@id": "sec:keyAgreementMethod", + "@type": "@id", + "@container": "@set", + }, + "publicKey": {"@id": "sec:publicKey", "@type": "@id", "@container": "@set"}, + "publicKeyBase58": "sec:publicKeyBase58", + "publicKeyJwk": {"@id": "sec:publicKeyJwk", "@type": "@json"}, + "service": {"@id": "didns:service", "@type": "@id", "@container": "@set"}, + "serviceEndpoint": {"@id": "didns:serviceEndpoint", "@type": "@id"}, + "updated": {"@id": "dc:modified", "@type": "xsd:dateTime"}, + "verificationMethod": {"@id": "sec:verificationMethod", "@type": "@id"}, + } +} diff --git a/aries_cloudagent/vc/tests/contexts/examples_v1.py b/aries_cloudagent/vc/tests/contexts/examples_v1.py new file mode 100644 index 0000000000..089bda63d4 --- /dev/null +++ b/aries_cloudagent/vc/tests/contexts/examples_v1.py @@ -0,0 +1,46 @@ +EXAMPLES_V1 = { + "@context": [ + {"@version": 1.1}, + "https://www.w3.org/ns/odrl.jsonld", + { + "ex": "https://example.org/examples#", + "schema": "http://schema.org/", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "3rdPartyCorrelation": "ex:3rdPartyCorrelation", + "AllVerifiers": "ex:AllVerifiers", + "Archival": "ex:Archival", + "BachelorDegree": "ex:BachelorDegree", + "Child": "ex:Child", + "CLCredentialDefinition2019": "ex:CLCredentialDefinition2019", + "CLSignature2019": "ex:CLSignature2019", + "IssuerPolicy": "ex:IssuerPolicy", + "HolderPolicy": "ex:HolderPolicy", + "Mother": "ex:Mother", + "RelationshipCredential": "ex:RelationshipCredential", + "UniversityDegreeCredential": "ex:UniversityDegreeCredential", + "ZkpExampleSchema2018": "ex:ZkpExampleSchema2018", + "issuerData": "ex:issuerData", + "attributes": "ex:attributes", + "signature": "ex:signature", + "signatureCorrectnessProof": "ex:signatureCorrectnessProof", + "primaryProof": "ex:primaryProof", + "nonRevocationProof": "ex:nonRevocationProof", + "alumniOf": {"@id": "schema:alumniOf", "@type": "rdf:HTML"}, + "child": {"@id": "ex:child", "@type": "@id"}, + "degree": "ex:degree", + "degreeType": "ex:degreeType", + "degreeSchool": "ex:degreeSchool", + "college": "ex:college", + "name": {"@id": "schema:name", "@type": "rdf:HTML"}, + "givenName": "schema:givenName", + "familyName": "schema:familyName", + "parent": {"@id": "ex:parent", "@type": "@id"}, + "referenceId": "ex:referenceId", + "documentPresence": "ex:documentPresence", + "evidenceDocument": "ex:evidenceDocument", + "spouse": "schema:spouse", + "subjectPresence": "ex:subjectPresence", + "verifier": {"@id": "ex:verifier", "@type": "@id"}, + }, + ] +} \ No newline at end of file diff --git a/aries_cloudagent/vc/tests/contexts/odrl.py b/aries_cloudagent/vc/tests/contexts/odrl.py new file mode 100644 index 0000000000..c7ed5bfc3d --- /dev/null +++ b/aries_cloudagent/vc/tests/contexts/odrl.py @@ -0,0 +1,181 @@ +ODRL = { + "@context": { + "odrl": "http://www.w3.org/ns/odrl/2/", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "owl": "http://www.w3.org/2002/07/owl#", + "skos": "http://www.w3.org/2004/02/skos/core#", + "dct": "http://purl.org/dc/terms/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "vcard": "http://www.w3.org/2006/vcard/ns#", + "foaf": "http://xmlns.com/foaf/0.1/", + "schema": "http://schema.org/", + "cc": "http://creativecommons.org/ns#", + "uid": "@id", + "type": "@type", + "Policy": "odrl:Policy", + "Rule": "odrl:Rule", + "profile": {"@type": "@id", "@id": "odrl:profile"}, + "inheritFrom": {"@type": "@id", "@id": "odrl:inheritFrom"}, + "ConflictTerm": "odrl:ConflictTerm", + "conflict": {"@type": "@vocab", "@id": "odrl:conflict"}, + "perm": "odrl:perm", + "prohibit": "odrl:prohibit", + "invalid": "odrl:invalid", + "Agreement": "odrl:Agreement", + "Assertion": "odrl:Assertion", + "Offer": "odrl:Offer", + "Privacy": "odrl:Privacy", + "Request": "odrl:Request", + "Set": "odrl:Set", + "Ticket": "odrl:Ticket", + "Asset": "odrl:Asset", + "AssetCollection": "odrl:AssetCollection", + "relation": {"@type": "@id", "@id": "odrl:relation"}, + "hasPolicy": {"@type": "@id", "@id": "odrl:hasPolicy"}, + "target": {"@type": "@id", "@id": "odrl:target"}, + "output": {"@type": "@id", "@id": "odrl:output"}, + "partOf": {"@type": "@id", "@id": "odrl:partOf"}, + "source": {"@type": "@id", "@id": "odrl:source"}, + "Party": "odrl:Party", + "PartyCollection": "odrl:PartyCollection", + "function": {"@type": "@vocab", "@id": "odrl:function"}, + "PartyScope": "odrl:PartyScope", + "assignee": {"@type": "@id", "@id": "odrl:assignee"}, + "assigner": {"@type": "@id", "@id": "odrl:assigner"}, + "assigneeOf": {"@type": "@id", "@id": "odrl:assigneeOf"}, + "assignerOf": {"@type": "@id", "@id": "odrl:assignerOf"}, + "attributedParty": {"@type": "@id", "@id": "odrl:attributedParty"}, + "attributingParty": {"@type": "@id", "@id": "odrl:attributingParty"}, + "compensatedParty": {"@type": "@id", "@id": "odrl:compensatedParty"}, + "compensatingParty": {"@type": "@id", "@id": "odrl:compensatingParty"}, + "consentingParty": {"@type": "@id", "@id": "odrl:consentingParty"}, + "consentedParty": {"@type": "@id", "@id": "odrl:consentedParty"}, + "informedParty": {"@type": "@id", "@id": "odrl:informedParty"}, + "informingParty": {"@type": "@id", "@id": "odrl:informingParty"}, + "trackingParty": {"@type": "@id", "@id": "odrl:trackingParty"}, + "trackedParty": {"@type": "@id", "@id": "odrl:trackedParty"}, + "contractingParty": {"@type": "@id", "@id": "odrl:contractingParty"}, + "contractedParty": {"@type": "@id", "@id": "odrl:contractedParty"}, + "Action": "odrl:Action", + "action": {"@type": "@vocab", "@id": "odrl:action"}, + "includedIn": {"@type": "@id", "@id": "odrl:includedIn"}, + "implies": {"@type": "@id", "@id": "odrl:implies"}, + "Permission": "odrl:Permission", + "permission": {"@type": "@id", "@id": "odrl:permission"}, + "Prohibition": "odrl:Prohibition", + "prohibition": {"@type": "@id", "@id": "odrl:prohibition"}, + "obligation": {"@type": "@id", "@id": "odrl:obligation"}, + "use": "odrl:use", + "grantUse": "odrl:grantUse", + "aggregate": "odrl:aggregate", + "annotate": "odrl:annotate", + "anonymize": "odrl:anonymize", + "archive": "odrl:archive", + "concurrentUse": "odrl:concurrentUse", + "derive": "odrl:derive", + "digitize": "odrl:digitize", + "display": "odrl:display", + "distribute": "odrl:distribute", + "execute": "odrl:execute", + "extract": "odrl:extract", + "give": "odrl:give", + "index": "odrl:index", + "install": "odrl:install", + "modify": "odrl:modify", + "move": "odrl:move", + "play": "odrl:play", + "present": "odrl:present", + "print": "odrl:print", + "read": "odrl:read", + "reproduce": "odrl:reproduce", + "sell": "odrl:sell", + "stream": "odrl:stream", + "textToSpeech": "odrl:textToSpeech", + "transfer": "odrl:transfer", + "transform": "odrl:transform", + "translate": "odrl:translate", + "Duty": "odrl:Duty", + "duty": {"@type": "@id", "@id": "odrl:duty"}, + "consequence": {"@type": "@id", "@id": "odrl:consequence"}, + "remedy": {"@type": "@id", "@id": "odrl:remedy"}, + "acceptTracking": "odrl:acceptTracking", + "attribute": "odrl:attribute", + "compensate": "odrl:compensate", + "delete": "odrl:delete", + "ensureExclusivity": "odrl:ensureExclusivity", + "include": "odrl:include", + "inform": "odrl:inform", + "nextPolicy": "odrl:nextPolicy", + "obtainConsent": "odrl:obtainConsent", + "reviewPolicy": "odrl:reviewPolicy", + "uninstall": "odrl:uninstall", + "watermark": "odrl:watermark", + "Constraint": "odrl:Constraint", + "LogicalConstraint": "odrl:LogicalConstraint", + "constraint": {"@type": "@id", "@id": "odrl:constraint"}, + "refinement": {"@type": "@id", "@id": "odrl:refinement"}, + "Operator": "odrl:Operator", + "operator": {"@type": "@vocab", "@id": "odrl:operator"}, + "RightOperand": "odrl:RightOperand", + "rightOperand": "odrl:rightOperand", + "rightOperandReference": { + "@type": "xsd:anyURI", + "@id": "odrl:rightOperandReference", + }, + "LeftOperand": "odrl:LeftOperand", + "leftOperand": {"@type": "@vocab", "@id": "odrl:leftOperand"}, + "unit": "odrl:unit", + "dataType": {"@type": "xsd:anyType", "@id": "odrl:datatype"}, + "status": "odrl:status", + "absolutePosition": "odrl:absolutePosition", + "absoluteSpatialPosition": "odrl:absoluteSpatialPosition", + "absoluteTemporalPosition": "odrl:absoluteTemporalPosition", + "absoluteSize": "odrl:absoluteSize", + "count": "odrl:count", + "dateTime": "odrl:dateTime", + "delayPeriod": "odrl:delayPeriod", + "deliveryChannel": "odrl:deliveryChannel", + "elapsedTime": "odrl:elapsedTime", + "event": "odrl:event", + "fileFormat": "odrl:fileFormat", + "industry": "odrl:industry:", + "language": "odrl:language", + "media": "odrl:media", + "meteredTime": "odrl:meteredTime", + "payAmount": "odrl:payAmount", + "percentage": "odrl:percentage", + "product": "odrl:product", + "purpose": "odrl:purpose", + "recipient": "odrl:recipient", + "relativePosition": "odrl:relativePosition", + "relativeSpatialPosition": "odrl:relativeSpatialPosition", + "relativeTemporalPosition": "odrl:relativeTemporalPosition", + "relativeSize": "odrl:relativeSize", + "resolution": "odrl:resolution", + "spatial": "odrl:spatial", + "spatialCoordinates": "odrl:spatialCoordinates", + "systemDevice": "odrl:systemDevice", + "timeInterval": "odrl:timeInterval", + "unitOfCount": "odrl:unitOfCount", + "version": "odrl:version", + "virtualLocation": "odrl:virtualLocation", + "eq": "odrl:eq", + "gt": "odrl:gt", + "gteq": "odrl:gteq", + "lt": "odrl:lt", + "lteq": "odrl:lteq", + "neq": "odrl:neg", + "isA": "odrl:isA", + "hasPart": "odrl:hasPart", + "isPartOf": "odrl:isPartOf", + "isAllOf": "odrl:isAllOf", + "isAnyOf": "odrl:isAnyOf", + "isNoneOf": "odrl:isNoneOf", + "or": "odrl:or", + "xone": "odrl:xone", + "and": "odrl:and", + "andSequence": "odrl:andSequence", + "policyUsage": "odrl:policyUsage", + } +} \ No newline at end of file diff --git a/aries_cloudagent/vc/tests/contexts/security_v1.py b/aries_cloudagent/vc/tests/contexts/security_v1.py new file mode 100644 index 0000000000..ae16742868 --- /dev/null +++ b/aries_cloudagent/vc/tests/contexts/security_v1.py @@ -0,0 +1,50 @@ +SECURITY_V1 = { + "@context": { + "id": "@id", + "type": "@type", + + "dc": "http://purl.org/dc/terms/", + "sec": "https://w3id.org/security#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + + "EcdsaKoblitzSignature2016": "sec:EcdsaKoblitzSignature2016", + "Ed25519Signature2018": "sec:Ed25519Signature2018", + "EncryptedMessage": "sec:EncryptedMessage", + "GraphSignature2012": "sec:GraphSignature2012", + "LinkedDataSignature2015": "sec:LinkedDataSignature2015", + "LinkedDataSignature2016": "sec:LinkedDataSignature2016", + "CryptographicKey": "sec:Key", + + "authenticationTag": "sec:authenticationTag", + "canonicalizationAlgorithm": "sec:canonicalizationAlgorithm", + "cipherAlgorithm": "sec:cipherAlgorithm", + "cipherData": "sec:cipherData", + "cipherKey": "sec:cipherKey", + "created": { "@id": "dc:created", "@type": "xsd:dateTime" }, + "creator": { "@id": "dc:creator", "@type": "@id" }, + "digestAlgorithm": "sec:digestAlgorithm", + "digestValue": "sec:digestValue", + "domain": "sec:domain", + "encryptionKey": "sec:encryptionKey", + "expiration": { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + "expires": { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + "initializationVector": "sec:initializationVector", + "iterationCount": "sec:iterationCount", + "nonce": "sec:nonce", + "normalizationAlgorithm": "sec:normalizationAlgorithm", + "owner": { "@id": "sec:owner", "@type": "@id" }, + "password": "sec:password", + "privateKey": { "@id": "sec:privateKey", "@type": "@id" }, + "privateKeyPem": "sec:privateKeyPem", + "publicKey": { "@id": "sec:publicKey", "@type": "@id" }, + "publicKeyBase58": "sec:publicKeyBase58", + "publicKeyPem": "sec:publicKeyPem", + "publicKeyWif": "sec:publicKeyWif", + "publicKeyService": { "@id": "sec:publicKeyService", "@type": "@id" }, + "revoked": { "@id": "sec:revoked", "@type": "xsd:dateTime" }, + "salt": "sec:salt", + "signature": "sec:signature", + "signatureAlgorithm": "sec:signingAlgorithm", + "signatureValue": "sec:signatureValue" + } +} diff --git a/aries_cloudagent/vc/tests/contexts/security_v2.py b/aries_cloudagent/vc/tests/contexts/security_v2.py new file mode 100644 index 0000000000..e66faea8f4 --- /dev/null +++ b/aries_cloudagent/vc/tests/contexts/security_v2.py @@ -0,0 +1,93 @@ +SECURITY_V2 = { + "@context": [ + { + "@version": 1.1 + }, + "https://w3id.org/security/v1", + { + "AesKeyWrappingKey2019": "sec:AesKeyWrappingKey2019", + "DeleteKeyOperation": "sec:DeleteKeyOperation", + "DeriveSecretOperation": "sec:DeriveSecretOperation", + "EcdsaSecp256k1Signature2019": "sec:EcdsaSecp256k1Signature2019", + "EcdsaSecp256r1Signature2019": "sec:EcdsaSecp256r1Signature2019", + "EcdsaSecp256k1VerificationKey2019": "sec:EcdsaSecp256k1VerificationKey2019", + "EcdsaSecp256r1VerificationKey2019": "sec:EcdsaSecp256r1VerificationKey2019", + "Ed25519Signature2018": "sec:Ed25519Signature2018", + "Ed25519VerificationKey2018": "sec:Ed25519VerificationKey2018", + "EquihashProof2018": "sec:EquihashProof2018", + "ExportKeyOperation": "sec:ExportKeyOperation", + "GenerateKeyOperation": "sec:GenerateKeyOperation", + "KmsOperation": "sec:KmsOperation", + "RevokeKeyOperation": "sec:RevokeKeyOperation", + "RsaSignature2018": "sec:RsaSignature2018", + "RsaVerificationKey2018": "sec:RsaVerificationKey2018", + "Sha256HmacKey2019": "sec:Sha256HmacKey2019", + "SignOperation": "sec:SignOperation", + "UnwrapKeyOperation": "sec:UnwrapKeyOperation", + "VerifyOperation": "sec:VerifyOperation", + "WrapKeyOperation": "sec:WrapKeyOperation", + "X25519KeyAgreementKey2019": "sec:X25519KeyAgreementKey2019", + + "allowedAction": "sec:allowedAction", + "assertionMethod": { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set" + }, + "authentication": { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set" + }, + "capability": { "@id": "sec:capability", "@type": "@id" }, + "capabilityAction": "sec:capabilityAction", + "capabilityChain": { + "@id": "sec:capabilityChain", + "@type": "@id", + "@container": "@list" + }, + "capabilityDelegation": { + "@id": "sec:capabilityDelegationMethod", + "@type": "@id", + "@container": "@set" + }, + "capabilityInvocation": { + "@id": "sec:capabilityInvocationMethod", + "@type": "@id", + "@container": "@set" + }, + "caveat": { "@id": "sec:caveat", "@type": "@id", "@container": "@set" }, + "challenge": "sec:challenge", + "ciphertext": "sec:ciphertext", + "controller": { "@id": "sec:controller", "@type": "@id" }, + "delegator": { "@id": "sec:delegator", "@type": "@id" }, + "equihashParameterK": { + "@id": "sec:equihashParameterK", + "@type": "xsd:integer" + }, + "equihashParameterN": { + "@id": "sec:equihashParameterN", + "@type": "xsd:integer" + }, + "invocationTarget": { "@id": "sec:invocationTarget", "@type": "@id" }, + "invoker": { "@id": "sec:invoker", "@type": "@id" }, + "jws": "sec:jws", + "keyAgreement": { + "@id": "sec:keyAgreementMethod", + "@type": "@id", + "@container": "@set" + }, + "kmsModule": { "@id": "sec:kmsModule" }, + "parentCapability": { "@id": "sec:parentCapability", "@type": "@id" }, + "plaintext": "sec:plaintext", + "proof": { "@id": "sec:proof", "@type": "@id", "@container": "@graph" }, + "proofPurpose": { "@id": "sec:proofPurpose", "@type": "@vocab" }, + "proofValue": "sec:proofValue", + "referenceId": "sec:referenceId", + "unwrappedKey": "sec:unwrappedKey", + "verificationMethod": { "@id": "sec:verificationMethod", "@type": "@id" }, + "verifyData": "sec:verifyData", + "wrappedKey": "sec:wrappedKey" + } + ] +} diff --git a/aries_cloudagent/vc/tests/dids.py b/aries_cloudagent/vc/tests/dids.py new file mode 100644 index 0000000000..372f9acc39 --- /dev/null +++ b/aries_cloudagent/vc/tests/dids.py @@ -0,0 +1,32 @@ +DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL = { + "@context": "https://w3id.org/did/v1", + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "verificationMethod": [ + { + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "type": "Ed25519VerificationKey2018", + "controller": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "publicKeyBase58": "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx", + } + ], + "authentication": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], + "assertionMethod": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], + "capabilityDelegation": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], + "capabilityInvocation": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], + "keyAgreement": [ + { + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6LSbkodSr6SU2trs8VUgnrnWtSm7BAPG245ggrBmSrxbv1R", + "type": "X25519KeyAgreementKey2019", + "controller": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "publicKeyBase58": "5dTvYHaNaB7mk7iA9LqCJEHG2dGZQsvoi8WGzDRtYEf", + } + ], +} diff --git a/aries_cloudagent/vc/tests/document_loader.py b/aries_cloudagent/vc/tests/document_loader.py new file mode 100644 index 0000000000..38cbfadaad --- /dev/null +++ b/aries_cloudagent/vc/tests/document_loader.py @@ -0,0 +1,46 @@ +from .contexts import ( + DID_V1, + SECURITY_V1, + SECURITY_V2, + CREDENTIALS_V1, + EXAMPLES_V1, + BBS_V1, + CITIZENSHIP_V1, + ODRL, +) +from ..ld_proofs.constants import ( + SECURITY_V2_URL, + SECURITY_V1_URL, + DID_V1_URL, + SECURITY_BBS_URL, + CREDENTIALS_V1_URL, +) +from .dids import DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL + +DOCUMENTS = { + DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL.get( + "id" + ): DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL, + SECURITY_V1_URL: SECURITY_V1, + SECURITY_V2_URL: SECURITY_V2, + DID_V1_URL: DID_V1, + CREDENTIALS_V1_URL: CREDENTIALS_V1, + SECURITY_BBS_URL: BBS_V1, + "https://www.w3.org/2018/credentials/examples/v1": EXAMPLES_V1, + "https://w3id.org/citizenship/v1": CITIZENSHIP_V1, + "https://www.w3.org/ns/odrl.jsonld": ODRL, +} + + +def custom_document_loader(url: str, options: dict): + without_fragment = url.split("#")[0] + + if without_fragment in DOCUMENTS: + return { + "contentType": "application/ld+json", + "contextUrl": None, + "document": DOCUMENTS[without_fragment], + "documentUrl": url, + } + + raise Exception(f"No custom context support for {url}") diff --git a/aries_cloudagent/vc/vc_ld/__init__.py b/aries_cloudagent/vc/vc_ld/__init__.py index 65e0143c4c..4a9aa9629f 100644 --- a/aries_cloudagent/vc/vc_ld/__init__.py +++ b/aries_cloudagent/vc/vc_ld/__init__.py @@ -1,5 +1,11 @@ from .issue import issue -from .verify import verify, verify_credential +from .verify import verify_presentation, verify_credential from .prove import create_presentation, sign_presentation -__all__ = [issue, verify, verify_credential, create_presentation, sign_presentation] +__all__ = [ + issue, + verify_presentation, + verify_credential, + create_presentation, + sign_presentation, +] diff --git a/aries_cloudagent/vc/vc_ld/checker.py b/aries_cloudagent/vc/vc_ld/checker.py index d0b551b9d6..089d25e4c0 100644 --- a/aries_cloudagent/vc/vc_ld/checker.py +++ b/aries_cloudagent/vc/vc_ld/checker.py @@ -1,8 +1,8 @@ from pyld.jsonld import JsonLdProcessor import re -from ...messaging.valid import RFC3339DateTime -from .constants import CREDENTIALS_CONTEXT_V1_URL +# from ...messaging.valid import RFC3339DateTime +from ..ld_proofs.constants import CREDENTIALS_V1_URL def get_id(obj): @@ -16,12 +16,9 @@ def get_id(obj): def check_credential(credential: dict): - if not ( - credential["@context"] - and credential["@context"][0] == CREDENTIALS_CONTEXT_V1_URL - ): + if not (credential["@context"] and credential["@context"][0] == CREDENTIALS_V1_URL): raise Exception( - f"{CREDENTIALS_CONTEXT_V1_URL} needs to be first in the list of contexts" + f"{CREDENTIALS_V1_URL} needs to be first in the list of contexts" ) if not credential["type"]: @@ -42,10 +39,10 @@ def check_credential(credential: dict): if not credential["issuanceDate"]: raise Exception('"issuanceDate" property is required') - if not re.match(RFC3339DateTime.PATTERN, credential["issuanceDate"]): - raise Exception( - f'"issuanceDate" must be a valid date {credential["issuanceDate"]}' - ) + # if not re.match(RFC3339DateTime.PATTERN, credential["issuanceDate"]): + # raise Exception( + # f'"issuanceDate" must be a valid date {credential["issuanceDate"]}' + # ) if len(JsonLdProcessor.get_values(credential, "issuer")) > 1: raise Exception('"issuer" property can only have one value') @@ -74,9 +71,9 @@ def check_credential(credential: dict): if evidence_id and ":" not in evidence_id: raise Exception(f'"evidence" id must be a URL: {evidence}') - if "expirationDate" in credential and not re.match( - RFC3339DateTime.PATTERN, credential["issuanceDate"] - ): - raise Exception( - f'"expirationDate" must be a valid date {credential["expirationDate"]}' - ) + # if "expirationDate" in credential and not re.match( + # RFC3339DateTime.PATTERN, credential["issuanceDate"] + # ): + # raise Exception( + # f'"expirationDate" must be a valid date {credential["expirationDate"]}' + # ) diff --git a/aries_cloudagent/vc/vc_ld/constants.py b/aries_cloudagent/vc/vc_ld/constants.py deleted file mode 100644 index 91c9406f78..0000000000 --- a/aries_cloudagent/vc/vc_ld/constants.py +++ /dev/null @@ -1,5 +0,0 @@ -CREDENTIALS_CONTEXT_V1_URL = "https://www.w3.org/2018/credentials/v1" -SECURITY_CONTEXT_V2_URL = "https://w3id.org/security/v2" -SECURITY_PROOF_URL = "https://w3id.org/security#proof" -SECURITY_SIGNATURE_URL = "https://w3id.org/security#signature" -SECURITY_BBS_URL = "https://w3id.org/security/bbs/v1" diff --git a/aries_cloudagent/vc/vc_ld/issue.py b/aries_cloudagent/vc/vc_ld/issue.py index 3c2e6706fb..0ae96905e4 100644 --- a/aries_cloudagent/vc/vc_ld/issue.py +++ b/aries_cloudagent/vc/vc_ld/issue.py @@ -1,15 +1,20 @@ from ..ld_proofs import ( - LinkedDataSignature, + LinkedDataProof, ProofPurpose, sign, did_key_document_loader, CredentialIssuancePurpose, + DocumentLoader, ) -from .checker import check_credential + +# from .checker import check_credential async def issue( - credential: dict, suite: LinkedDataSignature, *, purpose: ProofPurpose = None + credential: dict, + suite: LinkedDataProof, + purpose: ProofPurpose = None, + document_loader: DocumentLoader = None, ) -> dict: # TODO: validate credential format @@ -20,6 +25,7 @@ async def issue( document=credential, suite=suite, purpose=purpose, - document_loader=did_key_document_loader, + document_loader=document_loader or did_key_document_loader, ) + return signed_credential diff --git a/aries_cloudagent/vc/vc_ld/models/Credential.py b/aries_cloudagent/vc/vc_ld/models/Credential.py new file mode 100644 index 0000000000..15c9ebdcb6 --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/models/Credential.py @@ -0,0 +1,64 @@ +from marshmallow import fields + +from ....messaging.models.base import Schema +from ....messaging.valid import ( + CREDENTIAL_CONTEXT, + CREDENTIAL_TYPE, + CREDENTIAL_SUBJECT, + URI, +) + + +class LDCredential(Schema): + # MTODO: Support union types + context = fields.List( + fields.Str(), + data_key="@context", + required=True, + description="The JSON-LD context of the credential", + **CREDENTIAL_CONTEXT, + ) + id = fields.Str( + required=False, + desscription="The ID of the credential", + example="http://example.edu/credentials/1872", + validate=URI(), + ) + type = fields.List( + fields.Str(), + required=True, + description="The JSON-LD type of the credential", + **CREDENTIAL_TYPE, + ) + issuer = fields.Str( + required=False, description="The JSON-LD Verifiable Credential Issuer" + ) + issuance_date = fields.DateTime( + data_key="issuanceDate", + required=False, + description="The issuance date", + example="2010-01-01T19:73:24Z", + ) + expiration_date = fields.DateTime( + data_key="expirationDate", + required=False, + description="The expiration date", + example="2010-01-01T19:73:24Z", + ) + credential_subject = fields.Dict( + required=True, + keys=fields.Str(), + data_key="credentialSubject", + **CREDENTIAL_SUBJECT, + ) + # TODO: add typing + credential_schema = fields.Dict(required=False, data_key="credentialSchema") + + +class LDVerifiableCredential(LDCredential): + # TODO: support union types, better dict key typing + # Add proof schema + proof = fields.Dict( + required=True, + keys=fields.Str(), + ) diff --git a/aries_cloudagent/vc/vc_ld/prove.py b/aries_cloudagent/vc/vc_ld/prove.py index 2e7b85efc2..5d90241dea 100644 --- a/aries_cloudagent/vc/vc_ld/prove.py +++ b/aries_cloudagent/vc/vc_ld/prove.py @@ -8,14 +8,14 @@ sign, LinkedDataProof, ) -from .constants import CREDENTIALS_CONTEXT_V1_URL +from ..ld_proofs.constants import CREDENTIALS_V1_URL async def create_presentation( verifiable_credential: Union[dict, List[dict]], id_: str = None ) -> dict: presentation = { - "@context": [CREDENTIALS_CONTEXT_V1_URL], + "@context": [CREDENTIALS_V1_URL], "type": ["VerifiablePresentation"], } diff --git a/aries_cloudagent/vc/vc_ld/tests/__init__.py b/aries_cloudagent/vc/vc_ld/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/vc/vc_ld/tests/test_credential.py b/aries_cloudagent/vc/vc_ld/tests/test_credential.py new file mode 100644 index 0000000000..17525c89ea --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/tests/test_credential.py @@ -0,0 +1,86 @@ +CREDENTIAL_TEMPLATE = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1", + ], + "id": "http://example.gov/credentials/3732", + "type": ["VerifiableCredential", "UniversityDegreeCredential"], + "issuer": {"id": "did:example:123"}, + "issuanceDate": "2020-03-10T04:24:12.164Z", + "credentialSubject": { + "id": "did:example:456", + "degree": {"type": "BachelorDegree", "name": "Bachelor of Science and Arts"}, + }, +} + +CREDENTIAL_ISSUED = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1", + ], + "id": "http://example.gov/credentials/3732", + "type": ["VerifiableCredential", "UniversityDegreeCredential"], + "issuer": {"id": "did:example:123"}, + "issuanceDate": "2020-03-10T04:24:12.164Z", + "credentialSubject": { + "id": "did:example:456", + "degree": {"type": "BachelorDegree", "name": "Bachelor of Science and Arts"}, + }, + "proof": { + "type": "Ed25519Signature2018", + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "created": "2019-12-11T03:50:55", + "proofPurpose": "assertionMethod", + "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..1MPs2dakcfqK7-tJ3MMYhO3r05iBGrE12KB5C5n-MGrbzk83J2Ct9jQbxZvpeLkBRgvyxFY-3dPs2-dtPkAiAQ", + }, +} + +CREDENTIAL_VERIFIED = { + "verified": True, + "results": [ + { + "proof": { + "@context": "https://w3id.org/security/v2", + "type": "Ed25519Signature2018", + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "created": "2019-12-11T03:50:55", + "proofPurpose": "assertionMethod", + "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..1MPs2dakcfqK7-tJ3MMYhO3r05iBGrE12KB5C5n-MGrbzk83J2Ct9jQbxZvpeLkBRgvyxFY-3dPs2-dtPkAiAQ", + }, + "verified": True, + "purpose_result": { + "valid": True, + "controller": { + "@context": "https://w3id.org/security/v2", + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "assertionMethod": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], + "authentication": [ + { + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "type": "Ed25519VerificationKey2018", + "controller": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "publicKeyBase58": "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx", + } + ], + "capabilityDelegation": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], + "capabilityInvocation": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], + "keyAgreement": [ + { + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6LSbkodSr6SU2trs8VUgnrnWtSm7BAPG245ggrBmSrxbv1R", + "type": "X25519KeyAgreementKey2019", + "controller": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "publicKeyBase58": "5dTvYHaNaB7mk7iA9LqCJEHG2dGZQsvoi8WGzDRtYEf", + } + ], + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + }, + }, + } + ], +} diff --git a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py new file mode 100644 index 0000000000..f528b21e11 --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py @@ -0,0 +1,62 @@ +from asynctest import TestCase + +from datetime import datetime + +from ....wallet.base import KeyInfo +from ....wallet.util import naked_to_did_key +from ....wallet.in_memory import InMemoryWallet +from ....core.in_memory import InMemoryProfile +from ...ld_proofs import ( + Ed25519Signature2018, + Ed25519WalletKeyPair, + CredentialIssuancePurpose, + AssertionProofPurpose, +) +from ...vc_ld import issue, verify_credential +from ...tests.document_loader import custom_document_loader +from .test_credential import CREDENTIAL_TEMPLATE, CREDENTIAL_ISSUED, CREDENTIAL_VERIFIED + + +class TestLinkedDataVerifiableCredential(TestCase): + test_seed = "testseed000000000000000000000001" + key_info: KeyInfo = None + + async def setUp(self): + self.profile = InMemoryProfile.test_profile() + self.wallet = InMemoryWallet(self.profile) + self.key_info = await self.wallet.create_signing_key(self.test_seed) + + self.key_pair = Ed25519WalletKeyPair( + wallet=self.wallet, public_key_base58=self.key_info.verkey + ) + self.verification_method = ( + naked_to_did_key(self.key_info.verkey) + "#" + self.key_pair.fingerprint() + ) + self.suite = Ed25519Signature2018( + # TODO: should we provide verification_method here? Or abstract? + verification_method=self.verification_method, + key_pair=Ed25519WalletKeyPair( + wallet=self.wallet, public_key_base58=self.key_info.verkey + ), + date=datetime.strptime("2019-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), + ) + + async def test_issue(self): + issued = await issue( + credential=CREDENTIAL_TEMPLATE, + suite=self.suite, + purpose=CredentialIssuancePurpose(), + document_loader=custom_document_loader, + ) + + assert issued == CREDENTIAL_ISSUED + + async def test_verify(self): + verified = await verify_credential( + credential=CREDENTIAL_ISSUED, + suite=self.suite, + document_loader=custom_document_loader, + purpose=AssertionProofPurpose(), + ) + + assert verified == CREDENTIAL_VERIFIED diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index d144595cb3..4354c66304 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -1,12 +1,11 @@ import asyncio -from typing import Callable, Mapping from pyld.jsonld import JsonLdProcessor +from typing import Callable, Mapping from ..ld_proofs import ( LinkedDataProof, CredentialIssuancePurpose, DocumentLoader, - LinkedDataSignature, ProofPurpose, AuthenticationProofPurpose, verify as ld_proofs_verify, @@ -15,14 +14,17 @@ async def _verify_credential( + *, credential: dict, document_loader: DocumentLoader, - suite: LinkedDataSignature, + suite: LinkedDataProof, purpose: ProofPurpose = None, + # TODO: add check_status method signature (like DocumentLoader) check_status: Callable = None, ) -> dict: # TODO: validate credential structure + # TODO: what if we don't want to check for credentialStatus? if "credentialStatus" in credential and not check_status: raise Exception( 'A "check_status function must be provided to verify credentials with "credentialStatus" set.' @@ -38,31 +40,37 @@ async def _verify_credential( document_loader=document_loader, ) - if not result["verified"]: + if not result.get("verified"): return result if "credentialStatus" in credential: # CHECK make sure this is how check_status should be called result["statusResult"] = await check_status(credential) - if not result["statusResult"]["verified"]: - result["verified"] = False + if not result.get("statusResult").get("verified"): + result["verified"] = False return result async def verify_credential( + *, credential: dict, + suite: LinkedDataProof, document_loader: DocumentLoader, - suite: LinkedDataSignature, purpose: ProofPurpose = None, check_status: Callable = None, ) -> dict: try: return await _verify_credential( - credential, document_loader, suite, purpose, check_status + credential=credential, + document_loader=document_loader, + suite=suite, + purpose=purpose, + check_status=check_status, ) except Exception as e: + # TODO: use class instance OR typed dict, as this is confusing return { "verified": False, "results": [{"credential": credential, "verified": False, "error": e}], @@ -77,7 +85,6 @@ async def _verify_presentation( unsigned_presentation: dict = None, suite_map: Mapping[str, LinkedDataProof] = None, suite: LinkedDataProof = None, - controller: dict = None, domain: str = None, document_loader: DocumentLoader = None, ): @@ -104,7 +111,8 @@ async def _verify_presentation( 'A "challenge" param is required for AuthenticationProofPurpose.' ) - suite = suite_map[presentation["proof"]["type"]]() + proof_type = presentation.get("proof").get("type") + suite = suite_map[proof_type]() presentation_result = await ld_proofs_verify( document=presentation, @@ -155,13 +163,14 @@ def d(cred: dict, index: int): } -async def verify( - challenge: str, +async def verify_presentation( + *, presentation: dict = None, - purpose: LinkedDataSignature = None, + challenge: str, + purpose: ProofPurpose = None, unsigned_presentation: dict = None, - suite_map: Mapping[str, LinkedDataSignature] = None, - suite: LinkedDataSignature = None, + suite_map: Mapping[str, LinkedDataProof] = None, + suite: LinkedDataProof = None, controller: dict = None, domain: str = None, document_loader: DocumentLoader = None, @@ -192,4 +201,4 @@ async def verify( } -__all__ = [verify, verify_credential] +__all__ = [verify_presentation, verify_credential] diff --git a/aries_cloudagent/wallet/util.py b/aries_cloudagent/wallet/util.py index 16d2dc4b3a..cd9cf7ceed 100644 --- a/aries_cloudagent/wallet/util.py +++ b/aries_cloudagent/wallet/util.py @@ -29,12 +29,12 @@ def b64_to_str(val: str, urlsafe=False, encoding=None) -> str: return b64_to_bytes(val, urlsafe).decode(encoding or "utf-8") -def bytes_to_b64(val: bytes, urlsafe=False, pad=True) -> str: +def bytes_to_b64(val: bytes, urlsafe=False, pad=True, encoding: str = "ascii") -> str: """Convert a byte string to base 64.""" b64 = ( - base64.urlsafe_b64encode(val).decode("ascii") + base64.urlsafe_b64encode(val).decode(encoding) if urlsafe - else base64.b64encode(val).decode("ascii") + else base64.b64encode(val).decode(encoding) ) return b64 if pad else unpad(b64) From 001ffd2f98c127d78bbbcdb7b3b3edd34fad34d3 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Thu, 18 Mar 2021 10:27:03 +0100 Subject: [PATCH 015/138] credential verification and storage working Signed-off-by: Timo Glastra --- .../issue_credential/v2_0/formats/ld_proof.py | 72 ++++++++---- .../storage/vc_holder/in_memory.py | 2 +- .../vc/ld_proofs/document_loader.py | 23 ++-- .../purposes/ControllerProofPurpose.py | 11 +- .../purposes/CredentialIssuancePurpose.py | 18 ++- .../ld_proofs/suites/LinkedDataSignature.py | 18 ++- aries_cloudagent/vc/ld_proofs/util.py | 31 ----- aries_cloudagent/vc/tests/contexts/did_v1.py | 107 +++++++++--------- aries_cloudagent/vc/vc_ld/issue.py | 1 + .../vc/vc_ld/tests/test_credential.py | 42 +------ aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py | 3 +- 11 files changed, 168 insertions(+), 160 deletions(-) delete mode 100644 aries_cloudagent/vc/ld_proofs/util.py diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py index 5cb05a1b25..00e60d6dad 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py @@ -2,7 +2,7 @@ import logging - +import json import uuid from typing import List, Mapping, Tuple @@ -18,6 +18,8 @@ from .....wallet.error import WalletNotFoundError from .....wallet.base import BaseWallet from .....wallet.util import did_key_to_naked, naked_to_did_key +from .....storage.vc_holder.base import VCHolder +from .....storage.vc_holder.vc_record import VCRecord from ..messages.cred_format import V20CredFormat from ..messages.cred_offer import V20CredOffer from ..messages.cred_proposal import V20CredProposal @@ -86,7 +88,9 @@ async def _get_suite_for_type(self, did: str, proof_type: str) -> LinkedDataProo return Ed25519Signature2018( verification_method=verification_method, - key_pair=Ed25519WalletKeyPair(verkey, wallet), + key_pair=Ed25519WalletKeyPair( + wallet=wallet, public_key_base58=verkey + ), ) else: raise V20CredFormatError(f"Unsupported proof type {proof_type}") @@ -120,7 +124,7 @@ async def create_offer( # - Other checks (credentialStatus, credentialSchema, etc...) # TODO: validate credential structure - filter = V20CredProposal.deserialize(cred_ex_record.cred_proposal).filter( + filter = V20CredProposal.deserialize(cred_ex_record.cred_proposal).attachment( self.format ) @@ -143,9 +147,9 @@ async def create_request( holder_did: str = None, ): if cred_ex_record.cred_offer: - cred_detail = V20CredOffer.deserialize(cred_ex_record.cred_offer).offer( - self.format - ) + cred_detail = V20CredOffer.deserialize( + cred_ex_record.cred_offer + ).attachment(self.format) if not cred_detail["credential"]["credentialSubject"]["id"] and holder_did: async with self.profile.session() as session: @@ -160,10 +164,9 @@ async def create_request( # TODO: start from request cred_detail = None - id = uuid.uuid4() return ( - V20CredFormat(attach_id=id, format_=self.format), - AttachDecorator.data_json(cred_detail, ident=id), + V20CredFormat(attach_id="ld_proof", format_=self.format), + AttachDecorator.data_json(cred_detail, ident="ld_proof"), ) async def receive_request( @@ -175,13 +178,13 @@ async def receive_request( async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = 5): if cred_ex_record.cred_offer: # TODO: match offer with request. Use request (because of credential subject id) - cred_detail = V20CredOffer.deserialize(cred_ex_record.cred_offer).offer( - self.format - ) + cred_detail = V20CredOffer.deserialize( + cred_ex_record.cred_offer + ).attachment(self.format) else: cred_detail = V20CredRequest.deserialize( cred_ex_record.cred_request - ).cred_request(self.format) + ).attachment(self.format) issuer_did = get_id(cred_detail["credential"]["issuer"]) proof_types = cred_detail["options"]["proofType"] @@ -194,7 +197,8 @@ async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = await self._assert_can_sign_with_did(issuer_did) suite = await self._get_suite_for_type(issuer_did, proof_types[0]) - vc = await issue(cred_detail["credential"], suite) + # TODO: proof options + vc = await issue(credential=cred_detail["credential"], suite=suite) return ( V20CredFormat(attach_id="ld_proof", format_=self.format), @@ -202,20 +206,50 @@ async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = ) async def store_credential(self, cred_ex_record: V20CredExRecord, cred_id: str): - vc = V20CredIssue.deserialize(cred_ex_record.cred_issue).cred(self.format) + # TODO: validate credential structure (prob in receive credential?) + credential: dict = V20CredIssue.deserialize( + cred_ex_record.cred_issue + ).attachment(self.format) async with self.profile.session() as session: wallet = session.inject(BaseWallet) - verification_method: str = vc["proof"]["verificationMethod"] + verification_method = credential.get("proof", {}).get("verificationMethod") + + if type(verification_method) is not str: + raise V20CredFormatError( + "Invalid verification method on received credential" + ) verkey = did_key_to_naked(verification_method.split("#")[0]) # TODO: API rework. suite = Ed25519Signature2018( verification_method=verification_method, - key_pair=Ed25519WalletKeyPair(verkey, wallet), + key_pair=Ed25519WalletKeyPair(wallet=wallet, public_key_base58=verkey), ) - valid = await verify_credential(vc, did_key_document_loader, suite) + result = await verify_credential( + credential=credential, + suite=suite, + document_loader=did_key_document_loader, + ) - print("is valid: ", valid) + if not result.get("verified"): + raise V20CredFormatError( + f"Received invalid credential: {result}", + ) + + vc_holder = session.inject(VCHolder) + + # TODO: tags + vc_record = VCRecord( + contexts=credential.get("@context"), + types=credential.get("type"), + issuer_id=get_id(credential.get("issuer")), + # TODO: subject may be array + subject_ids=[credential.get("credentialSubject").get("id")], + schema_ids=[], + value=json.dumps(credential), + given_id=credential.get("id"), + ) + await vc_holder.store_credential(vc_record) diff --git a/aries_cloudagent/storage/vc_holder/in_memory.py b/aries_cloudagent/storage/vc_holder/in_memory.py index 6f91bceecc..d76b881c3a 100644 --- a/aries_cloudagent/storage/vc_holder/in_memory.py +++ b/aries_cloudagent/storage/vc_holder/in_memory.py @@ -1,10 +1,10 @@ """Basic in-memory storage implementation of VC holder interface.""" -from aries_cloudagent.storage.record import StorageRecord from typing import Mapping, Sequence from ...core.in_memory import InMemoryProfile +from ..record import StorageRecord from ..in_memory import InMemoryStorage, InMemoryStorageSearch from .base import VCHolder, VCRecordSearch diff --git a/aries_cloudagent/vc/ld_proofs/document_loader.py b/aries_cloudagent/vc/ld_proofs/document_loader.py index 9217425078..f46ca0dcc3 100644 --- a/aries_cloudagent/vc/ld_proofs/document_loader.py +++ b/aries_cloudagent/vc/ld_proofs/document_loader.py @@ -5,22 +5,24 @@ def resolve_ed25519_did_key(did_key: str) -> dict: - pub_key_base58 = did_key_to_naked(did_key) - key_ref = f"#{did_key[8:]}" - did_key_with_key_ref = did_key + key_ref + # TODO: optimize + without_fragment = did_key.split("#")[0] + pub_key_base58 = did_key_to_naked(without_fragment) + key_ref = f"#{without_fragment[8:]}" + did_key_with_key_ref = without_fragment + key_ref return { "contentType": "application/ld+json", - "contextUrl": "https://w3id.org/did/v1", + "contextUrl": None, "documentUrl": did_key, "document": { "@context": "https://w3id.org/did/v1", - "id": did_key, + "id": without_fragment, "verificationMethod": [ { "id": did_key_with_key_ref, "type": "Ed25519VerificationKey2018", - "controller": did_key, + "controller": without_fragment, "publicKeyBase58": pub_key_base58, } ], @@ -28,7 +30,14 @@ def resolve_ed25519_did_key(did_key: str) -> dict: "assertionMethod": [did_key_with_key_ref], "capabilityDelegation": [did_key_with_key_ref], "capabilityInvocation": [did_key_with_key_ref], - "keyAgreement": [], + "keyAgreement": [ + { + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6LSbkodSr6SU2trs8VUgnrnWtSm7BAPG245ggrBmSrxbv1R", + "type": "X25519KeyAgreementKey2019", + "controller": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "publicKeyBase58": "5dTvYHaNaB7mk7iA9LqCJEHG2dGZQsvoi8WGzDRtYEf", + } + ], }, } diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py index 4de41af8e0..3bb5caca43 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py @@ -1,10 +1,10 @@ from datetime import datetime, timedelta from pyld.jsonld import JsonLdProcessor +from pyld import jsonld from ..constants import SECURITY_V2_URL from ..suites import LinkedDataProof from ..document_loader import DocumentLoader -from ..util import frame_without_compact_to_relative from .ProofPurpose import ProofPurpose @@ -44,8 +44,8 @@ def validate( else: raise Exception('"controller" must be a string or dict') - framed = frame_without_compact_to_relative( - input=controller_id, + framed = jsonld.frame( + controller_id, frame={ "@context": SECURITY_V2_URL, "id": controller_id, @@ -53,6 +53,11 @@ def validate( }, options={ "documentLoader": document_loader, + "expandContext": SECURITY_V2_URL, + # if we don't set base explicitly it will remove the base in returned + # document (e.g. use key:z... instead of did:key:z...) + # same as compactToRelative in jsonld.js + "base": None, }, ) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py index 2b3d675775..7371d3174d 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta from pyld.jsonld import JsonLdProcessor +from pyld import jsonld from ..suites import LinkedDataProof from ..document_loader import DocumentLoader @@ -16,7 +17,7 @@ def validate( proof: dict, document: dict, suite: LinkedDataProof, - verification_method: str, + verification_method: dict, document_loader: DocumentLoader, ): try: @@ -31,12 +32,23 @@ def validate( if not result.get("valid"): raise result.get("error") - issuer: list = JsonLdProcessor.get_values(document, CREDENTIALS_ISSUER_URL) + # TODO: move expansion to better place. But required for querying issuer + expanded = jsonld.expand( + document, + { + "documentLoader": document_loader, + }, + ) + # TODO: what if array has no values? + issuer: list = JsonLdProcessor.get_values( + expanded[0], CREDENTIALS_ISSUER_URL + ) if not issuer or len(issuer) == 0: raise Exception("Credential issuer is required.") - if result.get("controller", {}).get("id") != issuer[0].get("id"): + # TODO: we're mixing expanded and not-expanded here. Confusing + if result.get("controller", {}).get("id") != issuer[0].get("@id"): raise Exception( "Credential issuer must match the verification method controller." ) diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py index 4ac957f44b..52917fdd8c 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -8,7 +8,6 @@ from ..document_loader import DocumentLoader from ..purposes import ProofPurpose from ..constants import SECURITY_V2_URL -from ..util import frame_without_compact_to_relative from .LinkedDataProof import LinkedDataProof @@ -133,7 +132,9 @@ async def verify_proof( "trace": traceback.format_exc(), } - def _get_verification_method(self, proof: dict, document_loader: DocumentLoader): + def _get_verification_method( + self, proof: dict, document_loader: DocumentLoader + ) -> dict: verification_method = proof.get("verificationMethod") if not verification_method: @@ -142,14 +143,21 @@ def _get_verification_method(self, proof: dict, document_loader: DocumentLoader) if isinstance(verification_method, dict): verification_method: str = verification_method.get("id") - framed = frame_without_compact_to_relative( - input=verification_method, + framed = jsonld.frame( + verification_method, frame={ "@context": SECURITY_V2_URL, "@embed": "@always", "id": verification_method, }, - options={"documentLoader": document_loader}, + options={ + "documentLoader": document_loader, + "expandContext": SECURITY_V2_URL, + # if we don't set base explicitly it will remove the base in returned + # document (e.g. use key:z... instead of did:key:z...) + # same as compactToRelative in jsonld.js + "base": None, + }, ) if not framed: diff --git a/aries_cloudagent/vc/ld_proofs/util.py b/aries_cloudagent/vc/ld_proofs/util.py deleted file mode 100644 index e647ea0318..0000000000 --- a/aries_cloudagent/vc/ld_proofs/util.py +++ /dev/null @@ -1,31 +0,0 @@ -from pyld import jsonld -from typing import Union - - -def frame_without_compact_to_relative( - input: Union[dict, str], frame: dict, options: dict = None -): - """Frame document without compacting to relative. - - We need to expand first as otherwise the base (e.g. did: from did:key) is removed. - in jsonld.js this can be solved by setting `compactToRelative` to false - however this is not supported in pyld. - https://github.com/digitalbazaar/jsonld.js/blob/93a9d3f9abaffb7666f0fe0cb1adf59e0f816b5a/lib/jsonld.js#L111 - - Args: - input (Union[dict, str]): the JSON-LD input to frame. - frame (dict): the JSON-LD frame to use. - options (dict, optional): the options to use. Defaults to None. - - Returns: - the framed JSON-LD output. - """ - expanded = jsonld.expand(input, options=options) - - framed = jsonld.frame( - expanded, - frame, - options=options, - ) - - return framed diff --git a/aries_cloudagent/vc/tests/contexts/did_v1.py b/aries_cloudagent/vc/tests/contexts/did_v1.py index 9b105062ec..2f669b3521 100644 --- a/aries_cloudagent/vc/tests/contexts/did_v1.py +++ b/aries_cloudagent/vc/tests/contexts/did_v1.py @@ -4,61 +4,64 @@ "id": "@id", "type": "@type", "dc": "http://purl.org/dc/terms/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "schema": "http://schema.org/", "sec": "https://w3id.org/security#", - "didns": "https://www.w3.org/ns/did#", + "didv": "https://w3id.org/did#", "xsd": "http://www.w3.org/2001/XMLSchema#", - "EcdsaSecp256k1VerificationKey2019": "sec:EcdsaSecp256k1VerificationKey2019", - "Ed25519Signature2018": "sec:Ed25519Signature2018", - "Ed25519VerificationKey2018": "sec:Ed25519VerificationKey2018", - "JsonWebKey2020": "sec:JsonWebKey2020", - "JsonWebSignature2020": "sec:JsonWebSignature2020", - "Bls12381G1Key2020": "sec:Bls12381G1Key2020", - "Bls12381G2Key2020": "sec:Bls12381G2Key2020", - "RsaVerificationKey2018": "sec:RsaVerificationKey2018", - "SchnorrSecp256k1VerificationKey2019": "sec:SchnorrSecp256k1VerificationKey2019", - "X25519KeyAgreementKey2019": "sec:X25519KeyAgreementKey2019", - "ServiceEndpointProxyService": "didns:ServiceEndpointProxyService", - "LinkedDomains": "https://identity.foundation/.well-known/resources/did-configuration/#LinkedDomains", - "alsoKnownAs": { - "@id": "https://www.w3.org/ns/activitystreams#alsoKnownAs", - "@type": "@id", - "@container": "@set", - }, - "assertionMethod": { - "@id": "sec:assertionMethod", - "@type": "@id", - "@container": "@set", - }, - "authentication": { - "@id": "sec:authenticationMethod", - "@type": "@id", - "@container": "@set", - }, - "capabilityDelegation": { - "@id": "sec:capabilityDelegationMethod", - "@type": "@id", - "@container": "@set", - }, - "capabilityInvocation": { - "@id": "sec:capabilityInvocationMethod", - "@type": "@id", - "@container": "@set", - }, - "controller": {"@id": "sec:controller", "@type": "@id"}, + "AuthenticationSuite": "sec:AuthenticationSuite", + "CryptographicKey": "sec:Key", + "EquihashProof2017": "sec:EquihashProof2017", + "GraphSignature2012": "sec:GraphSignature2012", + "IssueCredential": "didv:IssueCredential", + "LinkedDataSignature2015": "sec:LinkedDataSignature2015", + "LinkedDataSignature2016": "sec:LinkedDataSignature2016", + "RsaCryptographicKey": "sec:RsaCryptographicKey", + "RsaSignatureAuthentication2018": "sec:RsaSignatureAuthentication2018", + "RsaSigningKey2018": "sec:RsaSigningKey", + "RsaSignature2015": "sec:RsaSignature2015", + "RsaSignature2017": "sec:RsaSignature2017", + "UpdateDidDescription": "didv:UpdateDidDescription", + "authentication": "sec:authenticationMethod", + "authenticationCredential": "sec:authenticationCredential", + "authorizationCapability": "sec:authorizationCapability", + "canonicalizationAlgorithm": "sec:canonicalizationAlgorithm", + "capability": "sec:capability", + "comment": "rdfs:comment", "created": {"@id": "dc:created", "@type": "xsd:dateTime"}, - "blockchainAccountId": "sec:blockchainAccountId", - "keyAgreement": { - "@id": "sec:keyAgreementMethod", - "@type": "@id", - "@container": "@set", - }, + "creator": {"@id": "dc:creator", "@type": "@id"}, + "description": "schema:description", + "digestAlgorithm": "sec:digestAlgorithm", + "digestValue": "sec:digestValue", + "domain": "sec:domain", + "entity": "sec:entity", + "equihashParameterAlgorithm": "sec:equihashParameterAlgorithm", + "equihashParameterK": {"@id": "sec:equihashParameterK", "@type": "xsd:integer"}, + "equihashParameterN": {"@id": "sec:equihashParameterN", "@type": "xsd:integer"}, + "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, + "field": {"@id": "didv:field", "@type": "@id"}, + "label": "rdfs:label", + "minimumProofsRequired": "sec:minimumProofsRequired", + "minimumSignaturesRequired": "sec:minimumSignaturesRequired", + "name": "schema:name", + "nonce": "sec:nonce", + "normalizationAlgorithm": "sec:normalizationAlgorithm", + "owner": {"@id": "sec:owner", "@type": "@id"}, + "permission": "sec:permission", + "permittedProofType": "sec:permittedProofType", + "privateKey": {"@id": "sec:privateKey", "@type": "@id"}, + "privateKeyPem": "sec:privateKeyPem", + "proof": "sec:proof", + "proofAlgorithm": "sec:proofAlgorithm", + "proofType": "sec:proofType", + "proofValue": "sec:proofValue", "publicKey": {"@id": "sec:publicKey", "@type": "@id", "@container": "@set"}, - "publicKeyBase58": "sec:publicKeyBase58", - "publicKeyJwk": {"@id": "sec:publicKeyJwk", "@type": "@json"}, - "service": {"@id": "didns:service", "@type": "@id", "@container": "@set"}, - "serviceEndpoint": {"@id": "didns:serviceEndpoint", "@type": "@id"}, - "updated": {"@id": "dc:modified", "@type": "xsd:dateTime"}, - "verificationMethod": {"@id": "sec:verificationMethod", "@type": "@id"}, + "publicKeyPem": "sec:publicKeyPem", + "requiredProof": "sec:requiredProof", + "revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"}, + "seeAlso": {"@id": "rdfs:seeAlso", "@type": "@id"}, + "signature": "sec:signature", + "signatureAlgorithm": "sec:signatureAlgorithm", + "signatureValue": "sec:signatureValue", } -} +} \ No newline at end of file diff --git a/aries_cloudagent/vc/vc_ld/issue.py b/aries_cloudagent/vc/vc_ld/issue.py index 0ae96905e4..f5240b4b49 100644 --- a/aries_cloudagent/vc/vc_ld/issue.py +++ b/aries_cloudagent/vc/vc_ld/issue.py @@ -11,6 +11,7 @@ async def issue( + *, credential: dict, suite: LinkedDataProof, purpose: ProofPurpose = None, diff --git a/aries_cloudagent/vc/vc_ld/tests/test_credential.py b/aries_cloudagent/vc/vc_ld/tests/test_credential.py index 17525c89ea..a73a8ceea4 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_credential.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_credential.py @@ -5,7 +5,7 @@ ], "id": "http://example.gov/credentials/3732", "type": ["VerifiableCredential", "UniversityDegreeCredential"], - "issuer": {"id": "did:example:123"}, + "issuer": {"id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL"}, "issuanceDate": "2020-03-10T04:24:12.164Z", "credentialSubject": { "id": "did:example:456", @@ -20,7 +20,7 @@ ], "id": "http://example.gov/credentials/3732", "type": ["VerifiableCredential", "UniversityDegreeCredential"], - "issuer": {"id": "did:example:123"}, + "issuer": {"id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL"}, "issuanceDate": "2020-03-10T04:24:12.164Z", "credentialSubject": { "id": "did:example:456", @@ -31,7 +31,7 @@ "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", "created": "2019-12-11T03:50:55", "proofPurpose": "assertionMethod", - "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..1MPs2dakcfqK7-tJ3MMYhO3r05iBGrE12KB5C5n-MGrbzk83J2Ct9jQbxZvpeLkBRgvyxFY-3dPs2-dtPkAiAQ", + "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..lKJU0Df_keblRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQKBhQDxvXNo7nvtUBb_Eq1Ch6YBKY7UBAjg6iBX5qBQ", }, } @@ -45,42 +45,10 @@ "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", "created": "2019-12-11T03:50:55", "proofPurpose": "assertionMethod", - "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..1MPs2dakcfqK7-tJ3MMYhO3r05iBGrE12KB5C5n-MGrbzk83J2Ct9jQbxZvpeLkBRgvyxFY-3dPs2-dtPkAiAQ", + "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..lKJU0Df_keblRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQKBhQDxvXNo7nvtUBb_Eq1Ch6YBKY7UBAjg6iBX5qBQ", }, "verified": True, - "purpose_result": { - "valid": True, - "controller": { - "@context": "https://w3id.org/security/v2", - "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", - "assertionMethod": [ - "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" - ], - "authentication": [ - { - "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", - "type": "Ed25519VerificationKey2018", - "controller": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", - "publicKeyBase58": "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx", - } - ], - "capabilityDelegation": [ - "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" - ], - "capabilityInvocation": [ - "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" - ], - "keyAgreement": [ - { - "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6LSbkodSr6SU2trs8VUgnrnWtSm7BAPG245ggrBmSrxbv1R", - "type": "X25519KeyAgreementKey2019", - "controller": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", - "publicKeyBase58": "5dTvYHaNaB7mk7iA9LqCJEHG2dGZQsvoi8WGzDRtYEf", - } - ], - "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", - }, - }, + "purpose_result": {"valid": True}, } ], } diff --git a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py index f528b21e11..7cb5824333 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py @@ -10,7 +10,6 @@ Ed25519Signature2018, Ed25519WalletKeyPair, CredentialIssuancePurpose, - AssertionProofPurpose, ) from ...vc_ld import issue, verify_credential from ...tests.document_loader import custom_document_loader @@ -56,7 +55,7 @@ async def test_verify(self): credential=CREDENTIAL_ISSUED, suite=self.suite, document_loader=custom_document_loader, - purpose=AssertionProofPurpose(), + purpose=CredentialIssuancePurpose(), ) assert verified == CREDENTIAL_VERIFIED From c057fc7d277c4ad74d519658b6b5e7211951ad9e Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Thu, 18 Mar 2021 17:37:06 +0100 Subject: [PATCH 016/138] fix merge, restructure Signed-off-by: Timo Glastra --- .../issue_credential/v2_0/formats/handler.py | 65 +++++--- .../{ => v2_0/formats}/indy/__init__.py | 0 .../v2_0/formats/{indy.py => indy/handler.py} | 149 +++++++++--------- .../formats/indy/models}/__init__.py | 0 .../formats/indy/models}/cred.py | 4 +- .../formats/indy/models}/cred_abstract.py | 4 +- .../formats/indy/models}/cred_request.py | 4 +- .../__init__.py => indy/models/detail.py} | 0 .../v2_0/formats/indy/tests/__init__.py | 0 .../formats}/indy/tests/test_cred.py | 6 +- .../tests/test_handler.py} | 15 +- .../v2_0/formats/ld_proof/__init__.py | 0 .../{ld_proof.py => ld_proof/handler.py} | 132 +++++++++------- .../issue_credential/v2_0/manager.py | 7 - .../v2_0/messages/cred_format.py | 13 +- .../v2_0/messages/cred_issue.py | 9 +- .../v2_0/messages/cred_offer.py | 10 +- .../v2_0/messages/cred_proposal.py | 8 +- .../v2_0/messages/cred_request.py | 9 +- 19 files changed, 227 insertions(+), 208 deletions(-) rename aries_cloudagent/protocols/issue_credential/{ => v2_0/formats}/indy/__init__.py (100%) rename aries_cloudagent/protocols/issue_credential/v2_0/formats/{indy.py => indy/handler.py} (82%) rename aries_cloudagent/protocols/issue_credential/{indy/tests => v2_0/formats/indy/models}/__init__.py (100%) rename aries_cloudagent/protocols/issue_credential/{indy => v2_0/formats/indy/models}/cred.py (96%) rename aries_cloudagent/protocols/issue_credential/{indy => v2_0/formats/indy/models}/cred_abstract.py (95%) rename aries_cloudagent/protocols/issue_credential/{indy => v2_0/formats/indy/models}/cred_request.py (92%) rename aries_cloudagent/protocols/issue_credential/v2_0/formats/{tests/__init__.py => indy/models/detail.py} (100%) create mode 100644 aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/__init__.py rename aries_cloudagent/protocols/issue_credential/{ => v2_0/formats}/indy/tests/test_cred.py (94%) rename aries_cloudagent/protocols/issue_credential/v2_0/formats/{tests/test_indy.py => indy/tests/test_handler.py} (90%) create mode 100644 aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/__init__.py rename aries_cloudagent/protocols/issue_credential/v2_0/formats/{ld_proof.py => ld_proof/handler.py} (70%) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py index 92668f850a..1e30a0a1d6 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py @@ -12,14 +12,18 @@ from ..message_types import ATTACHMENT_FORMAT from ..messages.cred_format import V20CredFormat +from ..messages.cred_proposal import V20CredProposal from ..messages.cred_offer import V20CredOffer from ..messages.cred_request import V20CredRequest +from ..messages.cred_issue import V20CredIssue from ..models.detail.indy import V20CredExRecordIndy from ..models.detail.ld_proof import V20CredExRecordLDProof from ..models.cred_ex_record import V20CredExRecord LOGGER = logging.getLogger(__name__) +CredFormatAttachment = Tuple[V20CredFormat, AttachDecorator] + class V20CredFormatError(BaseError): """Credential format error under issue-credential protocol v2.0.""" @@ -71,9 +75,7 @@ def get_format_identifier(self, message_type: str) -> str: """ return ATTACHMENT_FORMAT[message_type][self.format.api] - def get_format_data( - self, message_type: str, data: dict - ) -> Tuple[V20CredFormat, AttachDecorator]: + def get_format_data(self, message_type: str, data: dict) -> CredFormatAttachment: """Get credential format and attachment objects for use in cred ex messages. Returns a tuple of both credential format and attachment decorator for use @@ -86,8 +88,7 @@ def get_format_data( data (dict): The data to include in the attach decorator Returns: - Tuple[V20CredFormat, AttachDecorator]: Credential format and - attachment data objects + CredFormatAttachment: Credential format and attachment data objects """ return ( V20CredFormat( @@ -98,42 +99,60 @@ def get_format_data( ) @abstractclassmethod - def validate_filter(cls, data: Mapping): - pass + def validate_fields(cls, message_type: str, attachment_data: dict) -> None: + """Validate attachment data for specific message type and format""" @abstractmethod async def create_proposal( - self, cred_ex_record: V20CredExRecord, filter: Mapping = None - ) -> Tuple[V20CredFormat, AttachDecorator]: - pass + self, cred_ex_record: V20CredExRecord, proposal_data: Mapping + ) -> CredFormatAttachment: + """Format specific handler for creating credential proposal attachment format data""" + + @abstractmethod + async def receive_proposal( + self, cred_ex_record: V20CredExRecord, cred_proposal_message: V20CredProposal + ) -> None: + """Format specific handler for receiving credential proposal message""" @abstractmethod async def create_offer( - self, cred_ex_record: V20CredExRecord - ) -> Tuple[V20CredFormat, AttachDecorator]: - pass + self, cred_ex_record: V20CredExRecord, offer_data: Mapping = None + ) -> CredFormatAttachment: + """Format specific handler for creating credential offer attachment format data""" @abstractmethod async def receive_offer( self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer - ): - pass + ) -> None: + """Format specific handler for receiving credential offer message""" @abstractmethod async def create_request( - self, cred_ex_record: V20CredExRecord, holder_did: str = None - ): - pass + self, cred_ex_record: V20CredExRecord, request_data: Mapping = None + ) -> CredFormatAttachment: + """Format specific handler for creating credential request attachment format data""" + @abstractmethod async def receive_request( self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest - ): + ) -> None: """Format specific handler for receiving credential request message""" @abstractmethod - async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = 5): - pass + # TODO: add issue_data or is this never needed? + async def issue_credential( + self, cred_ex_record: V20CredExRecord, retries: int = 5 + ) -> CredFormatAttachment: + """Format specific handler for creating issue credential attachment format data""" + + @abstractmethod + async def receive_credential( + self, cred_ex_record: V20CredExRecord, cred_issue_message: V20CredIssue + ) -> None: + """Format specific handler for receiving issue credential message""" @abstractmethod - async def store_credential(self, cred_ex_record: V20CredExRecord, cred_id: str): - pass + async def store_credential( + self, cred_ex_record: V20CredExRecord, cred_id: str = None + ) -> None: + """Format specific handler for storing credential from issue credential message""" diff --git a/aries_cloudagent/protocols/issue_credential/indy/__init__.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/__init__.py similarity index 100% rename from aries_cloudagent/protocols/issue_credential/indy/__init__.py rename to aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/__init__.py diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py similarity index 82% rename from aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py rename to aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py index 9fe5c9b9b4..a278f11353 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py @@ -1,54 +1,69 @@ -"""V2.0 indy issue-credential cred format.""" +"""V2.0 issue-credential indy credential format handler.""" import logging -from marshmallow import ValidationError -import uuid +from marshmallow import RAISE import json from typing import Mapping, Tuple import asyncio -from .....cache.base import BaseCache -from .....indy.issuer import IndyIssuer, IndyIssuerRevocationRegistryFullError -from .....indy.holder import IndyHolder, IndyHolderError -from .....ledger.base import BaseLedger -from .....messaging.credential_definitions.util import ( +from ......cache.base import BaseCache +from ......indy.issuer import IndyIssuer, IndyIssuerRevocationRegistryFullError +from ......indy.holder import IndyHolder, IndyHolderError +from ......ledger.base import BaseLedger +from ......messaging.credential_definitions.util import ( CRED_DEF_SENT_RECORD_TYPE, - CRED_DEF_TAGS, + CredDefQueryStringSchema, ) -from .....messaging.decorators.attach_decorator import AttachDecorator -from .....revocation.models.issuer_rev_reg_record import IssuerRevRegRecord -from .....revocation.models.revocation_registry import RevocationRegistry -from .....revocation.indy import IndyRevocation -from .....storage.base import BaseStorage -from .....storage.error import StorageNotFoundError - -from ..message_types import ( +from ......messaging.decorators.attach_decorator import AttachDecorator +from ......revocation.models.issuer_rev_reg_record import IssuerRevRegRecord +from ......revocation.models.revocation_registry import RevocationRegistry +from ......revocation.indy import IndyRevocation +from ......storage.base import BaseStorage +from ......storage.error import StorageNotFoundError + + +from ...message_types import ( CRED_20_ISSUE, CRED_20_OFFER, CRED_20_PROPOSAL, CRED_20_REQUEST, ) -from ..messages.cred_format import V20CredFormat -from ..messages.cred_proposal import V20CredProposal -from ..messages.cred_offer import V20CredOffer -from ..messages.cred_request import V20CredRequest -from ..messages.cred_issue import V20CredIssue -from ..models.cred_ex_record import V20CredExRecord -from ..models.detail.indy import V20CredExRecordIndy -from ..formats.handler import V20CredFormatError, V20CredFormatHandler +from ...messages.cred_format import V20CredFormat +from ...messages.cred_proposal import V20CredProposal +from ...messages.cred_offer import V20CredOffer +from ...messages.cred_request import V20CredRequest +from ...messages.cred_issue import V20CredIssue +from ...models.cred_ex_record import V20CredExRecord +from ...models.detail.indy import V20CredExRecordIndy +from ..handler import CredFormatAttachment, V20CredFormatError, V20CredFormatHandler + +from .models.cred_request import IndyCredRequestSchema +from .models.cred_abstract import IndyCredAbstractSchema +from .models.cred import IndyCredentialSchema LOGGER = logging.getLogger(__name__) class IndyCredFormatHandler(V20CredFormatHandler): + """Indy credential format handler.""" format = V20CredFormat.Format.INDY @classmethod - def validate_filter(cls, data: Mapping): - if data.keys() - set(CRED_DEF_TAGS): - raise ValidationError(f"Bad indy credential filter: {data}") + def validate_fields(cls, message_type: str, attachment_data: Mapping): + mapping = { + CRED_20_PROPOSAL: CredDefQueryStringSchema, + CRED_20_OFFER: IndyCredAbstractSchema, + CRED_20_REQUEST: IndyCredRequestSchema, + CRED_20_ISSUE: IndyCredentialSchema, + } + + # Get schema class + Schema = mapping[message_type] + + # Validate, throw if not valid + Schema(unknown=RAISE).load(attachment_data) async def _match_sent_cred_def_id(self, tag_query: Mapping[str, str]) -> str: """Return most recent matching id of cred def that agent sent to ledger.""" @@ -70,44 +85,14 @@ async def create_proposal( return self.get_format_data(CRED_20_PROPOSAL, filter) - async def receive_offer( - self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer - ): - # TODO: Why move from offer to proposal? - offer = cred_offer_message.attachment(self.format) - schema_id = offer["schema_id"] - cred_def_id = offer["cred_def_id"] - - # TODO: this could overwrite proposal for other formats. We should append or something - # TODO: move schema_id and cred_def_id to indy record - cred_proposal_ser = V20CredProposal( - comment=cred_offer_message.comment, - credential_preview=cred_offer_message.credential_preview, - formats=[ - V20CredFormat( - attach_id=self.format.api, - format_=self.get_format_identifier(CRED_20_PROPOSAL), - ) - ], - filters_attach=[ - AttachDecorator.data_base64( - { - "schema_id": schema_id, - "cred_def_id": cred_def_id, - }, - ident=self.format.api, - ) - ], - ).serialize() # proposal houses filters, preview (possibly with MIME types) - - async with self.profile.session() as session: - # TODO: we should probably not modify cred_ex_record here - cred_ex_record.cred_proposal = cred_proposal_ser - await cred_ex_record.save(session, reason="receive v2.0 credential offer") + async def receive_proposal( + self, cred_ex_record: V20CredExRecord, cred_proposal_message: V20CredProposal + ) -> None: + pass async def create_offer( self, cred_ex_record: V20CredExRecord - ) -> Tuple[V20CredFormat, AttachDecorator]: + ) -> CredFormatAttachment: issuer = self.profile.inject(IndyIssuer) ledger = self.profile.inject(BaseLedger) cache = self.profile.inject(BaseCache, required=False) @@ -155,24 +140,30 @@ async def _create(): async def receive_offer( self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer - ): + ) -> None: # TODO: Why move from offer to proposal? - offer = cred_offer_message.offer(self.format) + offer = cred_offer_message.attachment(self.format) schema_id = offer["schema_id"] cred_def_id = offer["cred_def_id"] # TODO: this could overwrite proposal for other formats. We should append or something + # TODO: move schema_id and cred_def_id to indy record cred_proposal_ser = V20CredProposal( comment=cred_offer_message.comment, credential_preview=cred_offer_message.credential_preview, - formats=[V20CredFormat(attach_id="0", format_=self.format)], + formats=[ + V20CredFormat( + attach_id=self.format.api, + format_=self.get_format_identifier(CRED_20_PROPOSAL), + ) + ], filters_attach=[ AttachDecorator.data_base64( { "schema_id": schema_id, "cred_def_id": cred_def_id, }, - ident="0", + ident=self.format.api, ) ], ).serialize() # proposal houses filters, preview (possibly with MIME types) @@ -183,10 +174,10 @@ async def receive_offer( await cred_ex_record.save(session, reason="receive v2.0 credential offer") async def create_request( - self, - cred_ex_record: V20CredExRecord, - holder_did: str, - ): + self, cred_ex_record: V20CredExRecord, request_data: Mapping = None + ) -> CredFormatAttachment: + # TODO: request data may be None + holder_did = request_data.get("holder_did") if request_data else None cred_offer = V20CredOffer.deserialize(cred_ex_record.cred_offer).attachment( self.format ) @@ -237,10 +228,12 @@ async def _create(): async def receive_request( self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest - ): + ) -> None: assert cred_ex_record.cred_offer - async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = 5): + async def issue_credential( + self, cred_ex_record: V20CredExRecord, retries: int = 5 + ) -> CredFormatAttachment: cred_offer = V20CredOffer.deserialize(cred_ex_record.cred_offer).attachment( self.format ) @@ -388,7 +381,15 @@ async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = return self.get_format_data(CRED_20_ISSUE, json.loads(cred_json)) - async def store_credential(self, cred_ex_record: V20CredExRecord, cred_id: str): + async def receive_credential( + self, cred_ex_record: V20CredExRecord, cred_issue_message: V20CredIssue + ) -> None: + # TODO: validate + pass + + async def store_credential( + self, cred_ex_record: V20CredExRecord, cred_id: str = None + ) -> None: cred = V20CredIssue.deserialize(cred_ex_record.cred_issue).attachment( self.format ) diff --git a/aries_cloudagent/protocols/issue_credential/indy/tests/__init__.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/__init__.py similarity index 100% rename from aries_cloudagent/protocols/issue_credential/indy/tests/__init__.py rename to aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/__init__.py diff --git a/aries_cloudagent/protocols/issue_credential/indy/cred.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/cred.py similarity index 96% rename from aries_cloudagent/protocols/issue_credential/indy/cred.py rename to aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/cred.py index c955ff36fa..e0043868f0 100644 --- a/aries_cloudagent/protocols/issue_credential/indy/cred.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/cred.py @@ -4,8 +4,8 @@ from marshmallow import EXCLUDE, fields -from ....messaging.models.base import BaseModel, BaseModelSchema -from ....messaging.valid import ( +from .......messaging.models.base import BaseModel, BaseModelSchema +from .......messaging.valid import ( INDY_CRED_DEF_ID, INDY_REV_REG_ID, INDY_SCHEMA_ID, diff --git a/aries_cloudagent/protocols/issue_credential/indy/cred_abstract.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/cred_abstract.py similarity index 95% rename from aries_cloudagent/protocols/issue_credential/indy/cred_abstract.py rename to aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/cred_abstract.py index ca5649d7c9..1063eb012f 100644 --- a/aries_cloudagent/protocols/issue_credential/indy/cred_abstract.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/cred_abstract.py @@ -4,8 +4,8 @@ from marshmallow import EXCLUDE, fields -from ....messaging.models.base import BaseModel, BaseModelSchema -from ....messaging.valid import INDY_CRED_DEF_ID, INDY_SCHEMA_ID, NUM_STR_WHOLE +from .......messaging.models.base import BaseModel, BaseModelSchema +from .......messaging.valid import INDY_CRED_DEF_ID, INDY_SCHEMA_ID, NUM_STR_WHOLE class IndyKeyCorrectnessProof(BaseModel): diff --git a/aries_cloudagent/protocols/issue_credential/indy/cred_request.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/cred_request.py similarity index 92% rename from aries_cloudagent/protocols/issue_credential/indy/cred_request.py rename to aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/cred_request.py index 07dcef3453..bd09b17b32 100644 --- a/aries_cloudagent/protocols/issue_credential/indy/cred_request.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/cred_request.py @@ -4,8 +4,8 @@ from marshmallow import EXCLUDE, fields -from ....messaging.models.base import BaseModel, BaseModelSchema -from ....messaging.valid import INDY_CRED_DEF_ID, INDY_DID, NUM_STR_WHOLE +from .......messaging.models.base import BaseModel, BaseModelSchema +from .......messaging.valid import INDY_CRED_DEF_ID, INDY_DID, NUM_STR_WHOLE class IndyCredRequest(BaseModel): diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/__init__.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/detail.py similarity index 100% rename from aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/__init__.py rename to aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/detail.py diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/__init__.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/protocols/issue_credential/indy/tests/test_cred.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_cred.py similarity index 94% rename from aries_cloudagent/protocols/issue_credential/indy/tests/test_cred.py rename to aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_cred.py index ef6d106cc5..d978c6453a 100644 --- a/aries_cloudagent/protocols/issue_credential/indy/tests/test_cred.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_cred.py @@ -1,8 +1,8 @@ from unittest import TestCase -from ..cred import IndyAttrValue, IndyCredential -from ..cred_abstract import IndyCredAbstract, IndyKeyCorrectnessProof -from ..cred_request import IndyCredRequest +from ..models.cred import IndyAttrValue, IndyCredential +from ..models.cred_abstract import IndyCredAbstract, IndyKeyCorrectnessProof +from ..models.cred_request import IndyCredRequest KC_PROOF = { "c": "123467890", diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/test_indy.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_handler.py similarity index 90% rename from aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/test_indy.py rename to aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_handler.py index f068b6553e..4cf7d3f157 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/tests/test_indy.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_handler.py @@ -1,18 +1,11 @@ -import asyncio -import json - from asynctest import TestCase as AsyncTestCase from asynctest import mock as async_mock -from copy import deepcopy -from time import time -from ......core.in_memory import InMemoryProfile -from ......ledger.base import BaseLedger +from .......core.in_memory import InMemoryProfile +from .......ledger.base import BaseLedger -from .. import indy as test_module -from ..indy import IndyCredFormatHandler -from ...messages.cred_format import V20CredFormat -from ...models.detail.indy import V20CredExRecordIndy +from ..handler import IndyCredFormatHandler +from ....models.detail.indy import V20CredExRecordIndy TEST_DID = "LjgpST2rjsoxYegQDRm7EL" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/__init__.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py similarity index 70% rename from aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py rename to aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index 00e60d6dad..7ea6b2410f 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -1,32 +1,37 @@ -"""V2.0 linked data proof issue-credential cred format.""" +"""V2.0 issue-credential linked data proof credential format handler.""" import logging import json -import uuid -from typing import List, Mapping, Tuple +from typing import List, Mapping -from .....messaging.decorators.attach_decorator import AttachDecorator -from .....vc.vc_ld import issue, verify_credential -from .....vc.ld_proofs import ( +from ......vc.vc_ld import issue, verify_credential +from ......vc.ld_proofs import ( Ed25519Signature2018, Ed25519WalletKeyPair, did_key_document_loader, LinkedDataProof, ) -from .....wallet.error import WalletNotFoundError -from .....wallet.base import BaseWallet -from .....wallet.util import did_key_to_naked, naked_to_did_key -from .....storage.vc_holder.base import VCHolder -from .....storage.vc_holder.vc_record import VCRecord -from ..messages.cred_format import V20CredFormat -from ..messages.cred_offer import V20CredOffer -from ..messages.cred_proposal import V20CredProposal -from ..messages.cred_issue import V20CredIssue -from ..messages.cred_request import V20CredRequest -from ..models.cred_ex_record import V20CredExRecord -from ..formats.handler import V20CredFormatError, V20CredFormatHandler +from ......wallet.error import WalletNotFoundError +from ......wallet.base import BaseWallet +from ......wallet.util import did_key_to_naked, naked_to_did_key +from ......storage.vc_holder.base import VCHolder +from ......storage.vc_holder.vc_record import VCRecord + +from ...message_types import ( + CRED_20_ISSUE, + CRED_20_OFFER, + CRED_20_PROPOSAL, + CRED_20_REQUEST, +) +from ...messages.cred_format import V20CredFormat +from ...messages.cred_offer import V20CredOffer +from ...messages.cred_proposal import V20CredProposal +from ...messages.cred_issue import V20CredIssue +from ...messages.cred_request import V20CredRequest +from ...models.cred_ex_record import V20CredExRecord +from ..handler import CredFormatAttachment, V20CredFormatError, V20CredFormatHandler LOGGER = logging.getLogger(__name__) @@ -46,11 +51,12 @@ def get_id(obj) -> str: class LDProofCredFormatHandler(V20CredFormatHandler): + """Linked data proof credential format handler.""" format = V20CredFormat.Format.LD_PROOF @classmethod - def validate_filter(cls, data: Mapping): + def validate_fields(cls, message_type: str, attachment_data: dict) -> None: # TODO: validate LDProof credential filter pass @@ -101,51 +107,51 @@ def _get_verification_method(self, did: str): return verification_method async def create_proposal( - self, cred_ex_record: V20CredExRecord, filter: Mapping[str, str] - ) -> Tuple[V20CredFormat, AttachDecorator]: - # TODO: validate credential proposal structure - return ( - V20CredFormat(attach_id="ld_proof", format_=self.format), - AttachDecorator.data_base64(filter, ident="ld_proof"), - ) + self, cred_ex_record: V20CredExRecord, proposal_data: Mapping + ) -> CredFormatAttachment: + self.validate_fields(CRED_20_PROPOSAL, filter) - async def receive_offer( - self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer - ): + return self.get_format_data(CRED_20_PROPOSAL, filter) + + async def receive_proposal( + self, cred_ex_record: V20CredExRecord, cred_proposal_message: V20CredProposal + ) -> None: + # TODO: anything to validate here? pass - # TODO: add filter async def create_offer( - self, cred_ex_record: V20CredExRecord - ) -> Tuple[V20CredFormat, AttachDecorator]: + self, cred_ex_record: V20CredExRecord, offer_data: Mapping = None + ) -> CredFormatAttachment: # TODO: + # - Use offer data # - Check if all fields in credentialSubject are present in context # - Check if all required fields are present (according to RFC). Or is the API going to do this? # - Other checks (credentialStatus, credentialSchema, etc...) - # TODO: validate credential structure - filter = V20CredProposal.deserialize(cred_ex_record.cred_proposal).attachment( - self.format - ) + detail: dict = V20CredProposal.deserialize( + cred_ex_record.cred_proposal + ).attachment(self.format) - credential = filter["credential"] - options = filter["options"] + credential = detail["credential"] + options = detail["options"] await self._assert_can_sign_with_did(credential["issuer"]) await self._assert_can_sign_with_types(options["proofType"]) - id = uuid.uuid4() - return ( - V20CredFormat(attach_id=id, format_=self.format), - AttachDecorator.data_json(filter, ident=id), - ) + self.validate_fields(CRED_20_OFFER, detail) + return self.get_format_data(CRED_20_OFFER, detail) + + async def receive_offer( + self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer + ) -> None: + # TODO: anything to validate here? + pass async def create_request( - self, - cred_ex_record: V20CredExRecord, - # TODO subject id? - holder_did: str = None, - ): + self, cred_ex_record: V20CredExRecord, request_data: Mapping = None + ) -> CredFormatAttachment: + holder_did = request_data.get("holder_did") if request_data else None + if cred_ex_record.cred_offer: cred_detail = V20CredOffer.deserialize( cred_ex_record.cred_offer @@ -164,18 +170,19 @@ async def create_request( # TODO: start from request cred_detail = None - return ( - V20CredFormat(attach_id="ld_proof", format_=self.format), - AttachDecorator.data_json(cred_detail, ident="ld_proof"), - ) + self.validate_fields(CRED_20_REQUEST, cred_detail) + return self.get_format_data(CRED_20_REQUEST, cred_detail) async def receive_request( self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest - ): + ) -> None: # TODO: check if request matches offer. (If not send problem report?) + # TODO: validate pass - async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = 5): + async def issue_credential( + self, cred_ex_record: V20CredExRecord, retries: int = 5 + ) -> CredFormatAttachment: if cred_ex_record.cred_offer: # TODO: match offer with request. Use request (because of credential subject id) cred_detail = V20CredOffer.deserialize( @@ -200,12 +207,18 @@ async def issue_credential(self, cred_ex_record: V20CredExRecord, retries: int = # TODO: proof options vc = await issue(credential=cred_detail["credential"], suite=suite) - return ( - V20CredFormat(attach_id="ld_proof", format_=self.format), - AttachDecorator.data_json(vc, ident="ld_proof"), - ) + self.validate_fields(CRED_20_ISSUE, vc) + return self.get_format_data(CRED_20_ISSUE, vc) + + async def receive_credential( + self, cred_ex_record: V20CredExRecord, cred_issue_message: V20CredIssue + ) -> None: + # TODO: validate + pass - async def store_credential(self, cred_ex_record: V20CredExRecord, cred_id: str): + async def store_credential( + self, cred_ex_record: V20CredExRecord, cred_id: str = None + ) -> None: # TODO: validate credential structure (prob in receive credential?) credential: dict = V20CredIssue.deserialize( cred_ex_record.cred_issue @@ -251,5 +264,6 @@ async def store_credential(self, cred_ex_record: V20CredExRecord, cred_id: str): schema_ids=[], value=json.dumps(credential), given_id=credential.get("id"), + record_id=cred_id, ) await vc_holder.store_credential(vc_record) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py index 81e044b943..4b5de1f213 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py @@ -8,13 +8,6 @@ from ....core.profile import Profile from ....storage.error import StorageNotFoundError -from .message_types import ( - ATTACHMENT_FORMAT, - CRED_20_PROPOSAL, - CRED_20_OFFER, - CRED_20_REQUEST, - CRED_20_ISSUE, -) from .messages.cred_ack import V20CredAck from .messages.cred_format import V20CredFormat from .messages.cred_issue import V20CredIssue diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py index 4e3f395234..88baa4f9bd 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py @@ -13,7 +13,6 @@ from .....messaging.models.base import BaseModel, BaseModelSchema from .....messaging.valid import UUIDFour from .....messaging.decorators.attach_decorator import AttachDecorator -from ..message_types import PROTOCOL_PACKAGE from ..models.detail.indy import V20CredExRecordIndy from ..models.detail.ld_proof import V20CredExRecordLDProof from typing import TYPE_CHECKING @@ -39,12 +38,14 @@ class Format(Enum): INDY = FormatSpec( "hlindy/", V20CredExRecordIndy, - f"{PROTOCOL_PACKAGE}.formats.indy.IndyCredFormatHandler", + # TODO: use PROTOCOL_PACKAGE const + "aries_cloudagent.protocols.issue_credential.v2_0.formats.indy.IndyCredFormatHandler", ) LD_PROOF = FormatSpec( "aries/", V20CredExRecordLDProof, - f"{PROTOCOL_PACKAGE}.formats.ld_proof.LDProofCredFormatHandler", + # TODO: use PROTOCOL_PACKAGE const + "aries_cloudagent.protocols.issue_credential.v2_0.formats.ld_proof.LDProofCredFormatHandler", ) @classmethod @@ -80,9 +81,9 @@ def handler(self) -> Type["V20CredFormatHandler"]: # TODO: optimize / refactor return ClassLoader.load_class(self.value.handler) - def validate_filter(self, data: Mapping): - """Raise ValidationError for wrong filtration criteria.""" - self.handler.validate_filter(data) + def validate_fields(self, message_type: str, attachment_data: Mapping): + """Raise ValidationError for invalid attachment formats.""" + self.handler.validate_fields(message_type, attachment_data) def get_attachment_data( self, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_issue.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_issue.py index e3955f1048..7be0c2b526 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_issue.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_issue.py @@ -2,7 +2,7 @@ from typing import Sequence -from marshmallow import EXCLUDE, fields, RAISE, validates_schema, ValidationError +from marshmallow import EXCLUDE, fields, validates_schema, ValidationError from .....messaging.agent_message import AgentMessage, AgentMessageSchema from .....messaging.decorators.attach_decorator import ( @@ -11,8 +11,6 @@ ) from .....messaging.valid import UUIDFour -from ...indy.cred import IndyCredentialSchema - from ..message_types import CRED_20_ISSUE, PROTOCOL_PACKAGE from .cred_format import V20CredFormat, V20CredFormatSchema @@ -126,5 +124,6 @@ def get_attach_by_id(attach_id): for fmt in formats: atch = get_attach_by_id(fmt.attach_id) - if V20CredFormat.Format.get(fmt.format) is V20CredFormat.Format.INDY: - IndyCredentialSchema(unknown=RAISE).load(atch.content) + V20CredFormat.Format.get(fmt.format).validate_fields( + CRED_20_ISSUE, atch.content + ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_offer.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_offer.py index 9d24e6d63b..819c691a0c 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_offer.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_offer.py @@ -2,7 +2,7 @@ from typing import Sequence -from marshmallow import EXCLUDE, fields, RAISE, validates_schema, ValidationError +from marshmallow import EXCLUDE, fields, validates_schema, ValidationError from .....messaging.agent_message import AgentMessage, AgentMessageSchema from .....messaging.decorators.attach_decorator import ( @@ -11,8 +11,6 @@ ) from .....messaging.valid import UUIDFour -from ...indy.cred_abstract import IndyCredAbstractSchema - from ..message_types import CRED_20_OFFER, PROTOCOL_PACKAGE from .cred_format import V20CredFormat, V20CredFormatSchema @@ -133,5 +131,7 @@ def get_attach_by_id(attach_id): for fmt in formats: atch = get_attach_by_id(fmt.attach_id) - if V20CredFormat.Format.get(fmt.format) is V20CredFormat.Format.INDY: - IndyCredAbstractSchema(unknown=RAISE).load(atch.content) + + V20CredFormat.Format.get(fmt.format).validate_fields( + CRED_20_OFFER, atch.content + ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_proposal.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_proposal.py index d5b19ddb0a..8b85b419df 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_proposal.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_proposal.py @@ -2,10 +2,9 @@ from typing import Sequence -from marshmallow import EXCLUDE, fields, RAISE, validates_schema, ValidationError +from marshmallow import EXCLUDE, fields, validates_schema, ValidationError from .....messaging.agent_message import AgentMessage, AgentMessageSchema -from .....messaging.credential_definitions.util import CredDefQueryStringSchema from .....messaging.decorators.attach_decorator import ( AttachDecorator, AttachDecoratorSchema, @@ -129,5 +128,6 @@ def get_attach_by_id(attach_id): for fmt in formats: atch = get_attach_by_id(fmt.attach_id) - if V20CredFormat.Format.get(fmt.format) is V20CredFormat.Format.INDY: - CredDefQueryStringSchema(unknown=RAISE).load(atch.content) + V20CredFormat.Format.get(fmt.format).validate_fields( + CRED_20_PROPOSAL, atch.content + ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_request.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_request.py index f5d5307a52..9816c4ffee 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_request.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_request.py @@ -2,7 +2,7 @@ from typing import Sequence -from marshmallow import EXCLUDE, fields, RAISE, validates_schema, ValidationError +from marshmallow import EXCLUDE, fields, validates_schema, ValidationError from .....messaging.agent_message import AgentMessage, AgentMessageSchema from .....messaging.decorators.attach_decorator import ( @@ -10,8 +10,6 @@ AttachDecoratorSchema, ) -from ...indy.cred_request import IndyCredRequestSchema - from ..message_types import CRED_20_REQUEST, PROTOCOL_PACKAGE from .cred_format import V20CredFormat, V20CredFormatSchema @@ -119,5 +117,6 @@ def get_attach_by_id(attach_id): for fmt in formats: atch = get_attach_by_id(fmt.attach_id) - if V20CredFormat.Format.get(fmt.format) is V20CredFormat.Format.INDY: - IndyCredRequestSchema(unknown=RAISE).load(atch.content) + V20CredFormat.Format.get(fmt.format).validate_fields( + CRED_20_REQUEST, atch.content + ) From 311dfb14e71f4ebffef925c61196a7736b52a8ed Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Fri, 19 Mar 2021 19:51:32 +0100 Subject: [PATCH 017/138] issue ld proof vc api Signed-off-by: Timo Glastra --- aries_cloudagent/messaging/valid.py | 2 +- .../v2_0/formats/ld_proof/handler.py | 57 ++++++-- .../detail.py => ld_proof/models/__init__.py} | 0 .../formats/ld_proof/models/cred_detail.py | 77 ++++++++++ .../issue_credential/v2_0/manager.py | 5 +- .../v2_0/messages/cred_format.py | 8 +- .../protocols/issue_credential/v2_0/routes.py | 72 +++------- .../vc/vc_ld/models/Credential.py | 64 --------- .../vc/vc_ld/models/credential_schema.py | 132 ++++++++++++++++++ configs/agency.yaml | 46 ++++++ configs/external-mediator.yml | 25 ++++ configs/external.yaml | 51 +++++++ configs/holder.yaml | 51 +++++++ configs/mediator.yaml | 26 ++++ 14 files changed, 481 insertions(+), 135 deletions(-) rename aries_cloudagent/protocols/issue_credential/v2_0/formats/{indy/models/detail.py => ld_proof/models/__init__.py} (100%) create mode 100644 aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py delete mode 100644 aries_cloudagent/vc/vc_ld/models/Credential.py create mode 100644 aries_cloudagent/vc/vc_ld/models/credential_schema.py create mode 100644 configs/agency.yaml create mode 100644 configs/external-mediator.yml create mode 100644 configs/external.yaml create mode 100644 configs/holder.yaml create mode 100644 configs/mediator.yaml diff --git a/aries_cloudagent/messaging/valid.py b/aries_cloudagent/messaging/valid.py index 3907537151..1e1a6caf86 100644 --- a/aries_cloudagent/messaging/valid.py +++ b/aries_cloudagent/messaging/valid.py @@ -635,7 +635,7 @@ def __call__(self, value): CREDENTIAL_TYPE = {"validate": CredentialType(), "example": CredentialType.EXAMPLE} CREDENTIAL_CONTEXT = { "validate": CredentialContext(), - "example": CredentialType.EXAMPLE, + "example": CredentialContext.EXAMPLE, } CREDENTIAL_SUBJECT = { "validate": CredentialSubject(), diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index 7ea6b2410f..bae0cf1fce 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -5,7 +5,9 @@ import json from typing import List, Mapping +from marshmallow import RAISE +from ......vc.vc_ld.models.credential_schema import LDVerifiableCredentialSchema from ......vc.vc_ld import issue, verify_credential from ......vc.ld_proofs import ( Ed25519Signature2018, @@ -32,6 +34,8 @@ from ...messages.cred_request import V20CredRequest from ...models.cred_ex_record import V20CredExRecord from ..handler import CredFormatAttachment, V20CredFormatError, V20CredFormatHandler +from .models.cred_detail import LDProofVCDetail + LOGGER = logging.getLogger(__name__) @@ -56,9 +60,20 @@ class LDProofCredFormatHandler(V20CredFormatHandler): format = V20CredFormat.Format.LD_PROOF @classmethod - def validate_fields(cls, message_type: str, attachment_data: dict) -> None: - # TODO: validate LDProof credential filter - pass + def validate_fields(cls, message_type: str, attachment_data: Mapping) -> None: + mapping = { + CRED_20_PROPOSAL: LDProofVCDetail, + CRED_20_OFFER: LDProofVCDetail, + CRED_20_REQUEST: LDProofVCDetail, + CRED_20_ISSUE: LDVerifiableCredentialSchema, + } + + # Get schema class + Schema = mapping[message_type] + + # Validate, throw if not valid + # TODO: unknown should not raise + Schema(unknown=RAISE).load(attachment_data) async def _assert_can_sign_with_did(self, did: str): async with self.profile.session() as session: @@ -74,15 +89,13 @@ async def _assert_can_sign_with_did(self, did: str): f"Issuer did {did} not found. Unable to issue credential with this DID." ) - async def _assert_can_sign_with_types(self, proof_types: List[str]): - # Check if all proof types are supported - if not set(proof_types).issubset(SUPPORTED_PROOF_TYPES): - raise V20CredFormatError( - f"Unsupported proof type(s): {proof_types - SUPPORTED_PROOF_TYPES}." - ) + async def _assert_can_sign_with_type(self, proof_type: str): + # Check if proof types are supported + if not proof_type in SUPPORTED_PROOF_TYPES: + raise V20CredFormatError(f"Unsupported proof type: {proof_type}.") async def _get_suite_for_type(self, did: str, proof_type: str) -> LinkedDataProof: - await self._assert_can_sign_with_types([proof_type]) + await self._assert_can_sign_with_type(proof_type) async with self.profile.session() as session: # TODO: maybe keypair should start session and inject wallet (for shorter sessions) @@ -136,7 +149,7 @@ async def create_offer( options = detail["options"] await self._assert_can_sign_with_did(credential["issuer"]) - await self._assert_can_sign_with_types(options["proofType"]) + await self._assert_can_sign_with_type(options["proofType"]) self.validate_fields(CRED_20_OFFER, detail) return self.get_format_data(CRED_20_OFFER, detail) @@ -157,7 +170,10 @@ async def create_request( cred_ex_record.cred_offer ).attachment(self.format) - if not cred_detail["credential"]["credentialSubject"]["id"] and holder_did: + if ( + not cred_detail["credential"]["credentialSubject"].get("id") + and holder_did + ): async with self.profile.session() as session: wallet = session.inject(BaseWallet) @@ -167,8 +183,21 @@ async def create_request( cred_detail["credential"]["credentialSubject"]["id"] = did_key else: - # TODO: start from request - cred_detail = None + cred_detail = V20CredProposal.deserialize( + cred_ex_record.cred_proposal + ).attachment(self.format) + + if ( + not cred_detail["credential"]["credentialSubject"].get("id") + and holder_did + ): + async with self.profile.session() as session: + wallet = session.inject(BaseWallet) + + did_info = await wallet.get_local_did(holder_did) + did_key = naked_to_did_key(did_info.verkey) + + cred_detail["credential"]["credentialSubject"]["id"] = did_key self.validate_fields(CRED_20_REQUEST, cred_detail) return self.get_format_data(CRED_20_REQUEST, cred_detail) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/detail.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/__init__.py similarity index 100% rename from aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/models/detail.py rename to aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/__init__.py diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py new file mode 100644 index 0000000000..72a84ca799 --- /dev/null +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py @@ -0,0 +1,77 @@ +"""Linked data proof verifiable credential detail artifacts to attach to RFC 453 messages.""" + + +from marshmallow import fields, Schema + +from .......messaging.valid import INDY_ISO8601_DATETIME, UUIDFour +from .......vc.vc_ld.models.credential_schema import LDCredentialSchema + + +class CredentialStatusOptionsSchema(Schema): + """Linked data proof credential status options schema.""" + + type = fields.Str( + required=True, + description="Credential status method type to use for the credential. Should match status method registered in the Verifiable Credential Extension Registry", + example="CredentialStatusList2017", + ) + + +class LDProofVCDetailOptions(Schema): + """Linked data proof verifiable credential options schema.""" + + proof_type = fields.Str( + data_key="proofType", + required=True, + description="The proof type used for the proof. Should match suites registered in the Linked Data Cryptographic Suite Registry", + example="Ed25519Signature2018", + ) + + proof_purpose = fields.Str( + data_key="proofPurpose", + required=False, + default="assertionMethod", + description="The proof purpose used for the proof. Should match proof purposes registered in the Linked Data Proofs Specification", + example="assertionMethod", + ) + + created = fields.Str( + required=False, + description="The date and time of the proof (with a maximum accuracy in seconds). Defaults to current system time", + **INDY_ISO8601_DATETIME, + ) + + domain = fields.Str( + required=False, + description="The intended domain of validity for the proof", + example="example.com", + ) + + challenge = fields.Str( + required=False, + description="A challenge to include in the proof. SHOULD be provided by the requesting party of the credential (=holder)", + example=UUIDFour.EXAMPLE, + ) + + credential_status = fields.Nested( + CredentialStatusOptionsSchema, + data_key="credentialStatus", + required=False, + description="The credential status mechanism to use for the credential. Omitting the property indicates the issued credential will not include a credential status", + ) + + +class LDProofVCDetail(Schema): + """Linked data proof verifiable credential detail schema.""" + + credential = fields.Nested( + LDCredentialSchema, + required=True, + description="Detail of the JSON-LD Credential to be issued", + ) + + options = fields.Nested( + LDProofVCDetailOptions, + required=True, + description="Options for specifying how the linked data proof is created.", + ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py index 4b5de1f213..1da205dc93 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py @@ -321,14 +321,15 @@ async def create_request( # start with request (not allowed for indy -> checked in indy format handler) else: # TODO: where to get data from if starting from request. proposal? - cred_proposal = V20CredOffer.deserialize(cred_ex_record.cred_proposal) + + cred_proposal = V20CredProposal.deserialize(cred_ex_record.cred_proposal) formats = cred_proposal.formats # Format specific create_request handler request_formats = [ await V20CredFormat.Format.get(p.format).handler(self.profile) # TODO: retrieve holder did from create_request handler? - .create_request(cred_ex_record, holder_did) + .create_request(cred_ex_record, {"holder_did": holder_did}) for p in formats ] diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py index 88baa4f9bd..81b4650917 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py @@ -39,13 +39,13 @@ class Format(Enum): "hlindy/", V20CredExRecordIndy, # TODO: use PROTOCOL_PACKAGE const - "aries_cloudagent.protocols.issue_credential.v2_0.formats.indy.IndyCredFormatHandler", + "aries_cloudagent.protocols.issue_credential.v2_0.formats.indy.handler.IndyCredFormatHandler", ) LD_PROOF = FormatSpec( "aries/", V20CredExRecordLDProof, # TODO: use PROTOCOL_PACKAGE const - "aries_cloudagent.protocols.issue_credential.v2_0.formats.ld_proof.LDProofCredFormatHandler", + "aries_cloudagent.protocols.issue_credential.v2_0.formats.ld_proof.handler.LDProofCredFormatHandler", ) @classmethod @@ -140,6 +140,6 @@ class Meta: allow_none=False, description="Attachment format specifier", data_key="format", - validate=validate.Regexp("^(hlindy/.*@v2.0)|(dif/.*@v1.0)$"), - example="dif/credential-manifest@v1.0", + validate=validate.Regexp("^(hlindy/.*@v2.0)|(aries/.*@v1.0)$"), + example="aries/ld-proof-vc-detail@v1.0", ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py index 49c20bd638..308862fd2d 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py @@ -46,8 +46,7 @@ from .models.cred_ex_record import V20CredExRecord, V20CredExRecordSchema from .models.detail.ld_proof import V20CredExRecordLDProofSchema from .models.detail.indy import V20CredExRecordIndySchema - -from ....vc.vc_ld.models.Credential import LDCredential +from .formats.ld_proof.models.cred_detail import LDProofVCDetail class V20IssueCredentialModuleResponseSchema(OpenAPISchema): @@ -150,34 +149,6 @@ class V20CredFilterIndySchema(OpenAPISchema): ) -class LDCredentialOptions(Schema): - proof_type = fields.List( - fields.Str( - validate=validate.OneOf(["Ed25519Signature2018", "BbsBlsSignature2020"]) - ), - required=True, - data_key="proofType", - description="Proof types to use for the linked data proof. Entries should match suites registered in the Linked Data Cryptographic Suite Registry.", - example=[ - "Ed25519VerificationKey", - ], - ) - - # TODO: add typing - credential_status = fields.Dict(required=False, data_key="credentialStatus") - - -class V20CredFilterLDProofSchema(OpenAPISchema): - """Linked data proof credential filtration criteria.""" - - credential = fields.Nested( - LDCredential, - required=True, - description="JSON-LD credential data", - ) - options = fields.Nested(LDCredentialOptions) - - class V20CredFilterSchema(OpenAPISchema): """Credential filtration criteria.""" @@ -187,7 +158,7 @@ class V20CredFilterSchema(OpenAPISchema): description="Credential filter for indy", ) ld_proof = fields.Nested( - V20CredFilterLDProofSchema, + LDProofVCDetail, required=False, description="Credential filter for linked data proof", ) @@ -241,7 +212,8 @@ class V20IssueCredSchemaCore(AdminAPIMessageTracingSchema): class V20CredCreateSchema(V20IssueCredSchemaCore): """Request schema for creating a credential from attr values.""" - credential_preview = fields.Nested(V20CredPreviewSchema, required=True) + # TODO: validate that credential preview is present when indy format is present? + credential_preview = fields.Nested(V20CredPreviewSchema, required=False) class V20CredProposalRequestSchemaBase(V20IssueCredSchemaCore): @@ -260,12 +232,6 @@ class V20CredProposalRequestPreviewOptSchema(V20CredProposalRequestSchemaBase): credential_preview = fields.Nested(V20CredPreviewSchema, required=False) -class V20CredProposalRequestPreviewMandSchema(V20CredProposalRequestSchemaBase): - """Request schema for sending credential proposal on mandatory proposal preview.""" - - credential_preview = fields.Nested(V20CredPreviewSchema, required=True) - - class V20CredOfferRequestSchema(V20IssueCredSchemaCore): """Request schema for sending credential offer admin message.""" @@ -281,7 +247,8 @@ class V20CredOfferRequestSchema(V20IssueCredSchemaCore): ), required=False, ) - credential_preview = fields.Nested(V20CredPreviewSchema, required=True) + # TODO: validate that credential preview is present when indy format is present? + credential_preview = fields.Nested(V20CredPreviewSchema, required=False) class V20CredIssueRequestSchema(OpenAPISchema): @@ -314,6 +281,7 @@ class V20CredExIdMatchInfoSchema(OpenAPISchema): ) +# TODO: why store as format, we should pass directly to the format handler def _formats_filters(filt_spec: Mapping) -> Mapping: """Break out formats and filters for v2.0 cred proposal messages.""" @@ -344,7 +312,7 @@ async def _get_result_with_details( cred_ex_record.cred_ex_id ) - result[fmt.aka[0]] = detail_record.serialize() if detail_record else None + result[fmt.api] = detail_record.serialize() if detail_record else None return result @@ -540,8 +508,7 @@ async def credential_exchange_send(request: web.BaseRequest): if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") preview_spec = body.get("credential_preview") - # TODO: use generic format identifier - if "indy" in filt_spec and not preview_spec: + if V20CredFormat.Format.INDY.api in filt_spec and not preview_spec: raise web.HTTPBadRequest(reason="Missing credential_preview for indy filter") auto_remove = body.get("auto_remove") trace_msg = body.get("trace") @@ -558,6 +525,8 @@ async def credential_exchange_send(request: web.BaseRequest): if not conn_record.is_ready: raise web.HTTPForbidden(reason=f"Connection {connection_id} not ready") + # TODO: why do we create a proposal and then use that to create an offer. + # Seems easier to just pass the proposal data to the format specific handler cred_proposal = V20CredProposal( comment=comment, credential_preview=cred_preview, @@ -699,7 +668,7 @@ async def _create_free_offer( ): """Create a credential offer and related exchange record.""" - cred_preview = V20CredPreview.deserialize(preview_spec) + cred_preview = V20CredPreview.deserialize(preview_spec) if preview_spec else None cred_proposal = V20CredProposal( comment=comment, credential_preview=cred_preview, @@ -762,11 +731,11 @@ async def credential_exchange_create_free_offer(request: web.BaseRequest): auto_remove = body.get("auto_remove") comment = body.get("comment") preview_spec = body.get("credential_preview") - if not preview_spec: - raise web.HTTPBadRequest(reason=("Missing credential_preview")) filt_spec = body.get("filter") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") + if V20CredFormat.Format.INDY.api in filt_spec and not preview_spec: + raise web.HTTPBadRequest(reason="Missing credential_preview for indy filter") connection_id = body.get("connection_id") trace_msg = body.get("trace") @@ -865,8 +834,8 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): auto_remove = body.get("auto_remove") comment = body.get("comment") preview_spec = body.get("credential_preview") - if not preview_spec: - raise web.HTTPBadRequest(reason=("Missing credential_preview")) + if V20CredFormat.Format.INDY.api in filt_spec and not preview_spec: + raise web.HTTPBadRequest(reason="Missing credential_preview for indy filter") trace_msg = body.get("trace") cred_ex_record = None @@ -996,10 +965,12 @@ async def credential_exchange_send_bound_offer(request: web.BaseRequest): @docs( tags=["issue-credential v2.0"], - summary="Send issuer a credential proposal", + summary="Send issuer a credential request", ) @request_schema(V20CredProposalRequestPreviewOptSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") +# TODO: remove indy from OpenAPI example (to avoid confusion) +# TODO: remove credential preview from OpenAPI schema and example (to avoid confusion) async def credential_exchange_send_free_request(request: web.BaseRequest): """ Request handler for sending free credential request. @@ -1023,7 +994,8 @@ async def credential_exchange_send_free_request(request: web.BaseRequest): filt_spec = body.get("filter") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") - if "indy" in filt_spec: + # Indy cannot start from request + if V20CredFormat.Format.INDY.api in filt_spec: raise web.HTTPBadRequest( reason="Indy credential exchange cannot start with request" ) @@ -1045,7 +1017,7 @@ async def credential_exchange_send_free_request(request: web.BaseRequest): **_formats_filters(filt_spec), ) - cred_ex_record = await V20CredExRecord( + cred_ex_record = V20CredExRecord( connection_id=connection_id, auto_remove=auto_remove, cred_proposal=cred_proposal.serialize(), diff --git a/aries_cloudagent/vc/vc_ld/models/Credential.py b/aries_cloudagent/vc/vc_ld/models/Credential.py deleted file mode 100644 index 15c9ebdcb6..0000000000 --- a/aries_cloudagent/vc/vc_ld/models/Credential.py +++ /dev/null @@ -1,64 +0,0 @@ -from marshmallow import fields - -from ....messaging.models.base import Schema -from ....messaging.valid import ( - CREDENTIAL_CONTEXT, - CREDENTIAL_TYPE, - CREDENTIAL_SUBJECT, - URI, -) - - -class LDCredential(Schema): - # MTODO: Support union types - context = fields.List( - fields.Str(), - data_key="@context", - required=True, - description="The JSON-LD context of the credential", - **CREDENTIAL_CONTEXT, - ) - id = fields.Str( - required=False, - desscription="The ID of the credential", - example="http://example.edu/credentials/1872", - validate=URI(), - ) - type = fields.List( - fields.Str(), - required=True, - description="The JSON-LD type of the credential", - **CREDENTIAL_TYPE, - ) - issuer = fields.Str( - required=False, description="The JSON-LD Verifiable Credential Issuer" - ) - issuance_date = fields.DateTime( - data_key="issuanceDate", - required=False, - description="The issuance date", - example="2010-01-01T19:73:24Z", - ) - expiration_date = fields.DateTime( - data_key="expirationDate", - required=False, - description="The expiration date", - example="2010-01-01T19:73:24Z", - ) - credential_subject = fields.Dict( - required=True, - keys=fields.Str(), - data_key="credentialSubject", - **CREDENTIAL_SUBJECT, - ) - # TODO: add typing - credential_schema = fields.Dict(required=False, data_key="credentialSchema") - - -class LDVerifiableCredential(LDCredential): - # TODO: support union types, better dict key typing - # Add proof schema - proof = fields.Dict( - required=True, - keys=fields.Str(), - ) diff --git a/aries_cloudagent/vc/vc_ld/models/credential_schema.py b/aries_cloudagent/vc/vc_ld/models/credential_schema.py new file mode 100644 index 0000000000..998162935b --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/models/credential_schema.py @@ -0,0 +1,132 @@ +from marshmallow import fields + +from ....messaging.models.base import Schema +from ....messaging.valid import ( + CREDENTIAL_CONTEXT, + CREDENTIAL_TYPE, + CREDENTIAL_SUBJECT, + DIDKey, + DID_KEY, + INDY_ISO8601_DATETIME, + URI, + UUIDFour, +) + + +class LDSignatureSchema(Schema): + type = fields.Str( + required=True, + description="Identifies the digital signature suite that was used to create the signature", + example="Ed25519Signature2018", + ) + + proof_purpose = fields.Str( + data_key="proofPurpose", + required=True, + description="", + example="assertionMethod", + ) + + verification_method = fields.Str( + data_key="verificationMethod", + required=True, + description="Information used for proof verification", + example="did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + validate=URI(), + ) + + created = fields.Str( + required=True, + description="The string value of an ISO8601 combined date and time string generated by the Signature Algorithm", + **INDY_ISO8601_DATETIME, + ) + + domain = fields.Str( + required=False, + description="A string value specifying the restricted domain of the signature.", + example="example.com", + ) + + challenge = fields.Str( + required=False, + description="Associates a challenge with a proof, for use with a proofPurpose such as authentication", + example=UUIDFour.EXAMPLE, + ) + + jws = fields.Str( + required=False, + description="Associates a Detached Json Web Signature with a proof", + example="eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..lKJU0Df_keblRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQKBhQDxvXNo7nvtUBb_Eq1Ch6YBKY7UBAjg6iBX5qBQ", + ) + + proofValue = fields.Str( + required=False, + description="The proof value of a proof", + example="z76WGJzY2rXtSiZ8BDwU4VgcLqcMEm2dXdgVVS1QCZQUptZ5P8n5YCcnbuMUASYhVNihae7m8VeYvfViYf2KqTMVEH1BKNF6Xc5S2kPpBwsNos6egnrmDMxhtQppZjb47Mi2xG89jZm654uZUatDvfTCoDWuethfRHPSk81qn6od9zGxBxxAYyUPnY9Fs9QEQETm53AN9uk6erSAhJ2R3K8rosrBkSZbVhbzUJTPg22wpddVY8Xu3vhRVNpzyUvCEedg5EM6i7wE4G1CYsz7tbaApEF9aFRB92v4DoiY5GXGjwH5PhhGstJB9ySh9FyDfSYN8qRVVR7i5No2eBi3AjQ7cqaBiWkoSrCoQK7jJ4PyFsu3ZaAuUx8LAtkhaChmwfxH8E25LcTENJhFxqVnPd7f7Q3cUrFciYRqmg8eJsy1AahqbzJQ63n9RtekmwzqnMYrTwft6cLJJGeTSSxCCJ6HKnRtwE7jjDh6sB2ZeVj494VppdAVJBz2AAiZY9BBnCD8wUVgwqH3qchGRCuC2RugA4eQ9fUrR4Yuycac3caiaaay", + ) + + +class LDCredentialSchema(Schema): + context = fields.List( + # TODO: can be Str or Dict (wait for PE type) + fields.Str(), + data_key="@context", + required=True, + description="The JSON-LD context of the credential", + **CREDENTIAL_CONTEXT, + ) + id = fields.Str( + required=False, + desscription="The ID of the credential", + example="http://example.edu/credentials/1872", + validate=URI(), + ) + type = fields.List( + fields.Str(), + required=True, + description="The JSON-LD type of the credential", + **CREDENTIAL_TYPE, + ) + # TODO: can be Str or Dict (wait for PE type) + issuer = fields.Str( + required=False, + description="The JSON-LD Verifiable Credential Issuer", + example=DIDKey.EXAMPLE, + ) + # TODO: Check for RFC3339 format + issuance_date = fields.Str( + data_key="issuanceDate", + required=False, + description="The issuance date", + example="2010-01-01T19:73:24Z", + ) + # TODO: Check for RFC3339 format + expiration_date = fields.Str( + data_key="expirationDate", + required=False, + description="The expiration date", + example="2010-01-01T19:73:24Z", + ) + # TODO: Can be List or Dict + credential_subject = fields.Dict( + required=True, + keys=fields.Str(), + data_key="credentialSubject", + **CREDENTIAL_SUBJECT, + ) + # TODO: Add extra fields + + +class LDVerifiableCredentialSchema(LDCredentialSchema): + proof = fields.Nested( + LDSignatureSchema, + required=True, + description="The proof of the credential", + example={ + "type": "Ed25519Signature2018", + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "created": "2019-12-11T03:50:55", + "proofPurpose": "assertionMethod", + "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..lKJU0Df_keblRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQKBhQDxvXNo7nvtUBb_Eq1Ch6YBKY7UBAjg6iBX5qBQ", + }, + ) diff --git a/configs/agency.yaml b/configs/agency.yaml new file mode 100644 index 0000000000..8b6e4d3532 --- /dev/null +++ b/configs/agency.yaml @@ -0,0 +1,46 @@ +# General +label: Agency +endpoint: http://localhost:3004 +inbound-transport: + - [http, 0.0.0.0, 3004] +outbound-transport: http + +# Admin +admin: [0.0.0.0, 3005] +admin-insecure-mode: true + +# Connections +debug-connections: true +auto-accept-invites: true +auto-accept-requests: true +auto-ping-connection: true + +# Mediation +# mediator-invitation: http://localhost:3002?c_i=eyJAdHlwZSI6ICJkaWQ6c292OkJ6Q2JzTlloTXJqSGlxWkRUVUFTSGc7c3BlYy9jb25uZWN0aW9ucy8xLjAvaW52aXRhdGlvbiIsICJAaWQiOiAiOGRjY2NjOGUtYjJhZi00NjVmLTg1NzktOWIwMWRmY2EwYmMzIiwgInNlcnZpY2VFbmRwb2ludCI6ICJodHRwOi8vbG9jYWxob3N0OjMwMDIiLCAicmVjaXBpZW50S2V5cyI6IFsiQXMyd3JzWmI0Zmh0V3N0amE2UWpGQTk1Z0wzd0pqNm9XeGtDeVlaaGlBOG4iXSwgImxhYmVsIjogIk1lZGlhdG9yIn0= + +# Multi-tenancy +multitenant: true +multitenant-admin: true +jwt-secret: very secret secret + +# Webhooks +# webhook-url: http://localhost:1080/agency + +# Wallet +wallet-type: indy +wallet-name: IndyWallet2 +wallet-key: IndyWallet2 +auto-provision: true +recreate-wallet: true + +### Ledger ### + +# No Ledger +no-ledger: true +# BCovrin +# genesis-url: http://test.bcovrin.vonx.io/genesis +# Sovrin Builder Net +# genesis-url: https://raw.githubusercontent.com/sovrin-foundation/sovrin/master/sovrin/pool_transactions_builder_genesis + +# Debug +# log-level: DEBUG diff --git a/configs/external-mediator.yml b/configs/external-mediator.yml new file mode 100644 index 0000000000..9baef76796 --- /dev/null +++ b/configs/external-mediator.yml @@ -0,0 +1,25 @@ +# General +label: External Mediator +endpoint: http://localhost:3006 +inbound-transport: + - [http, 0.0.0.0, 3006] +outbound-transport: http +no-ledger: true + +# Admin +admin: [0.0.0.0, 3007] +admin-insecure-mode: true + +# Connections +debug-connections: true +auto-accept-invites: true +auto-accept-requests: true +auto-ping-connection: true + +# Mediation +open-mediation: true +auto-send-keylist-update-in-requests: true +auto-send-keylist-update-in-create-invitation: true + +# Webhooks +webhook-url: http://localhost:1080/external-mediator diff --git a/configs/external.yaml b/configs/external.yaml new file mode 100644 index 0000000000..fa41765956 --- /dev/null +++ b/configs/external.yaml @@ -0,0 +1,51 @@ +# General +label: Issuer +endpoint: http://localhost:3000 +inbound-transport: + - [http, 0.0.0.0, 3000] +outbound-transport: http + +# Admin +admin: [0.0.0.0, 3001] +admin-insecure-mode: true + +# Connections +debug-connections: true +debug-credentials: true +debug-presentations: true +auto-accept-invites: true +auto-accept-requests: true +# auto-accept-requests-peer: true +# auto-accept-requests-public: true +auto-ping-connection: true +auto-respond-messages: true +auto-respond-credential-proposal: true +auto-respond-credential-offer: true +auto-respond-credential-request: true +auto-respond-presentation-proposal: true +auto-respond-presentation-request: true +auto-store-credential: true +auto-verify-presentation: true + +# Webhooks +# webhook-url: http://localhost:1080/external + +# Wallet +wallet-type: indy +wallet-name: IndyWallet2 +wallet-key: IndyWallet2 +seed: SEED_000000000000000000000000001 +auto-provision: true +recreate-wallet: true + +### Ledger ### +# No Ledger +# no-ledger: true +# BCovrin +genesis-url: http://test.bcovrin.vonx.io/genesis +# Sovrin Builder Net +# genesis-url: https://raw.githubusercontent.com/sovrin-foundation/sovrin/master/sovrin/pool_transactions_builder_genesis + +# Debug +# log-level: DEBUG + diff --git a/configs/holder.yaml b/configs/holder.yaml new file mode 100644 index 0000000000..85b27a559e --- /dev/null +++ b/configs/holder.yaml @@ -0,0 +1,51 @@ +# General +label: Holder +endpoint: http://localhost:3002 +inbound-transport: + - [http, 0.0.0.0, 3002] +outbound-transport: http + +# Admin +admin: [0.0.0.0, 3003] +admin-insecure-mode: true + +# Connections +debug-connections: true +debug-credentials: true +debug-presentations: true +auto-accept-invites: true +auto-accept-requests: true +# auto-accept-requests-peer: true +# auto-accept-requests-public: true +auto-ping-connection: true +auto-respond-messages: true +auto-respond-credential-proposal: true +auto-respond-credential-offer: true +auto-respond-credential-request: true +auto-respond-presentation-proposal: true +auto-respond-presentation-request: true +auto-store-credential: true +auto-verify-presentation: true + +# Webhooks +# webhook-url: http://localhost:1080/external + +# Wallet +wallet-type: indy +wallet-name: IndyWallet3 +wallet-key: IndyWallet2 +seed: SEED_000000000000000000000000002 +auto-provision: true +recreate-wallet: true + +### Ledger ### +# No Ledger +# no-ledger: true +# BCovrin +genesis-url: http://test.bcovrin.vonx.io/genesis +# Sovrin Builder Net +# genesis-url: https://raw.githubusercontent.com/sovrin-foundation/sovrin/master/sovrin/pool_transactions_builder_genesis + +# Debug +# log-level: DEBUG + diff --git a/configs/mediator.yaml b/configs/mediator.yaml new file mode 100644 index 0000000000..232e3adc1d --- /dev/null +++ b/configs/mediator.yaml @@ -0,0 +1,26 @@ +# General +label: Mediator +endpoint: http://localhost:3002 +inbound-transport: + - [http, 0.0.0.0, 3002] +outbound-transport: http +no-ledger: true + +# Admin +admin: [0.0.0.0, 3003] +admin-insecure-mode: true + +# Connections +debug-connections: true +connections-invite: true +invite-multi-use: true +auto-accept-invites: true +auto-accept-requests: true +auto-ping-connection: true + +# Mediation +open-mediation: true +auto-send-keylist-update-in-requests: true +auto-send-keylist-update-in-create-invitation: true +# Webhooks +# webhook-url: http://localhost:1080/mediator From 27bb43493909be4d6f3c85a90466c6b34e242c0f Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sat, 20 Mar 2021 16:34:24 +0100 Subject: [PATCH 018/138] add did key parser class Signed-off-by: Timo Glastra --- aries_cloudagent/connections/base_manager.py | 6 +- aries_cloudagent/did/__init__.py | 0 aries_cloudagent/did/did_key.py | 108 ++++++++++++++++++ aries_cloudagent/did/tests/__init__.py | 0 aries_cloudagent/did/tests/test_did_key.py | 94 +++++++++++++++ .../connections/v1_0/tests/test_manager.py | 10 +- .../protocols/didexchange/v1_0/manager.py | 6 +- .../didexchange/v1_0/tests/test_manager.py | 11 +- .../v0_1/messages/tests/test_invitation.py | 10 +- .../introduction/v0_1/tests/test_service.py | 13 ++- .../v2_0/formats/ld_proof/handler.py | 32 +++--- .../protocols/out_of_band/v1_0/manager.py | 22 +++- .../v1_0/messages/tests/test_invitation.py | 17 +-- .../out_of_band/v1_0/tests/test_manager.py | 27 +++-- .../ld_proofs/crypto/Ed25519WalletKeyPair.py | 7 +- .../vc/ld_proofs/document_loader.py | 52 ++------- .../vc/ld_proofs/tests/test_ld_proofs.py | 9 +- aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py | 10 +- aries_cloudagent/wallet/crypto.py | 37 +++++- aries_cloudagent/wallet/tests/test_util.py | 18 +-- aries_cloudagent/wallet/util.py | 34 +----- 21 files changed, 360 insertions(+), 163 deletions(-) create mode 100644 aries_cloudagent/did/__init__.py create mode 100644 aries_cloudagent/did/did_key.py create mode 100644 aries_cloudagent/did/tests/__init__.py create mode 100644 aries_cloudagent/did/tests/test_did_key.py diff --git a/aries_cloudagent/connections/base_manager.py b/aries_cloudagent/connections/base_manager.py index 3ee4857006..a6c1cdc45c 100644 --- a/aries_cloudagent/connections/base_manager.py +++ b/aries_cloudagent/connections/base_manager.py @@ -21,7 +21,7 @@ from ..storage.error import StorageNotFoundError from ..storage.record import StorageRecord from ..wallet.base import BaseWallet, DIDInfo -from ..wallet.util import did_key_to_naked +from ..did.did_key import DIDKey from .models.conn_record import ConnRecord from .models.connection_target import ConnectionTarget @@ -267,11 +267,11 @@ async def fetch_connection_targets( else: endpoint = invitation.service_blocks[0].service_endpoint recipient_keys = [ - did_key_to_naked(k) + DIDKey.from_did(k).public_key_b58 for k in invitation.service_blocks[0].recipient_keys ] routing_keys = [ - did_key_to_naked(k) + DIDKey.from_did(k).public_key_b58 for k in invitation.service_blocks[0].routing_keys ] diff --git a/aries_cloudagent/did/__init__.py b/aries_cloudagent/did/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/did/did_key.py b/aries_cloudagent/did/did_key.py new file mode 100644 index 0000000000..f778fabc2b --- /dev/null +++ b/aries_cloudagent/did/did_key.py @@ -0,0 +1,108 @@ +from multicodec.multicodec import add_prefix, get_codec, remove_prefix +from varint import encode + +from ..wallet.crypto import KeyType, ed25519_pk_to_curve25519 +from ..wallet.util import b58_to_bytes, bytes_to_b58 + + +# TODO: use did resolver did doc class +def resolve_did_key_ed25519(did_key: "DIDKey") -> dict: + curve25519 = ed25519_pk_to_curve25519(did_key.public_key) + # TODO: reuse already existing fingerprint methods + # TODO: update once https://github.com/multiformats/py-multicodec/pull/14 is merged + curve25519_fingerprint = "z" + bytes_to_b58(b"".join([b"\xec\x01", curve25519])) + + return { + "@context": "https://w3id.org/did/v1", + "id": did_key.did, + "verificationMethod": [ + { + "id": did_key.key_id, + "type": "Ed25519VerificationKey2018", + "controller": did_key.did, + "publicKeyBase58": did_key.public_key_b58, + } + ], + "authentication": [did_key.key_id], + "assertionMethod": [did_key.key_id], + "capabilityDelegation": [did_key.key_id], + "capabilityInvocation": [did_key.key_id], + "keyAgreement": [ + { + "id": f"{did_key.did}#{curve25519_fingerprint}", + "type": "X25519KeyAgreementKey2019", + "controller": did_key.did, + "publicKeyBase58": bytes_to_b58(curve25519), + } + ], + } + + +DID_KEY_RESOLVERS = {KeyType.ED25519: resolve_did_key_ed25519} + + +class DIDKey: + _key_type: KeyType + _public_key: bytes + + def __init__(self, public_key: bytes, key_type: KeyType) -> None: + self._public_key = public_key + self._key_type = key_type + + @classmethod + def from_public_key(cls, public_key: bytes, key_type: str) -> "DIDKey": + return cls(public_key, key_type) + + @classmethod + def from_public_key_b58(cls, public_key: str, key_type: str) -> "DIDKey": + public_key_bytes = b58_to_bytes(public_key) + return cls.from_public_key(public_key_bytes, key_type) + + @classmethod + def from_fingerprint(cls, fingerprint: str) -> "DIDKey": + assert fingerprint[0] == "z" + key_bytes_with_prefix = b58_to_bytes(fingerprint[1:]) + public_key_bytes = remove_prefix(key_bytes_with_prefix) + + multicodec_name = get_codec(key_bytes_with_prefix) + key_type = KeyType.from_multicodec_name(multicodec_name) + + return cls(public_key_bytes, key_type) + + @classmethod + def from_did(cls, did: str) -> "DIDKey": + did_parts = did.split("#") + _, fingerprint = did_parts[0].split("did:key:") + + return cls.from_fingerprint(fingerprint) + + @property + def fingerprint(self) -> str: + prefixed_key_bytes = add_prefix(self.key_type.multicodec_name, self.public_key) + + return f"z{bytes_to_b58(prefixed_key_bytes)}" + + @property + def did(self) -> str: + return f"did:key:{self.fingerprint}" + + @property + def did_doc(self) -> dict: + resolver = DID_KEY_RESOLVERS[self.key_type] + return resolver(self) + + @property + def public_key(self) -> bytes: + return self._public_key + + @property + def public_key_b58(self) -> str: + return bytes_to_b58(self.public_key) + + @property + def key_type(self) -> KeyType: + return self._key_type + + @property + def key_id(self) -> str: + return f"{self.did}#{self.fingerprint}" diff --git a/aries_cloudagent/did/tests/__init__.py b/aries_cloudagent/did/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/did/tests/test_did_key.py b/aries_cloudagent/did/tests/test_did_key.py new file mode 100644 index 0000000000..d87563ae76 --- /dev/null +++ b/aries_cloudagent/did/tests/test_did_key.py @@ -0,0 +1,94 @@ +from unittest import TestCase + +from ...wallet.crypto import KeyType +from ...wallet.util import b58_to_bytes +from ..did_key import DIDKey, DID_KEY_RESOLVERS + +TEST_ED25519_BASE58_KEY = "8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K" +TEST_ED25519_FINGERPRINT = "z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" +TEST_ED25519_DID = f"did:key:{TEST_ED25519_FINGERPRINT}" +TEST_ED25519_KEY_ID = f"{TEST_ED25519_DID}#{TEST_ED25519_FINGERPRINT}" + + +class TestDIDKey(TestCase): + def test_ed25519_from_public_key(self): + key_bytes = b58_to_bytes(TEST_ED25519_BASE58_KEY) + did_key = DIDKey.from_public_key(key_bytes, KeyType.ED25519) + + assert did_key.did == TEST_ED25519_DID + + def test_ed25519_from_public_key_b58(self): + did_key = DIDKey.from_public_key_b58( + "8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K", KeyType.ED25519 + ) + + assert did_key.did == "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" + + def test_ed25519_from_fingerprint(self): + did_key = DIDKey.from_fingerprint(TEST_ED25519_FINGERPRINT) + + assert did_key.did == TEST_ED25519_DID + assert did_key.public_key_b58 == TEST_ED25519_BASE58_KEY + + def test_ed25519_from_did(self): + did_key = DIDKey.from_did(TEST_ED25519_DID) + + assert did_key.public_key_b58 == TEST_ED25519_BASE58_KEY + + def test_ed25519_properties(self): + did_key = DIDKey.from_did(TEST_ED25519_DID) + + assert did_key.fingerprint == TEST_ED25519_FINGERPRINT + assert did_key.did == TEST_ED25519_DID + assert did_key.public_key_b58 == TEST_ED25519_BASE58_KEY + assert did_key.public_key == b58_to_bytes(TEST_ED25519_BASE58_KEY) + assert did_key.key_type == KeyType.ED25519 + assert did_key.key_id == TEST_ED25519_KEY_ID + + def test_ed25519_diddoc(self): + did_key = DIDKey.from_did(TEST_ED25519_DID) + + resolver = DID_KEY_RESOLVERS[KeyType.ED25519] + + assert resolver(did_key) == did_key.did_doc + + def test_ed25519_resolver(self): + did_key = DIDKey.from_did(TEST_ED25519_DID) + resolver = DID_KEY_RESOLVERS[KeyType.ED25519] + did_doc = resolver(did_key) + + # resolved using uniresolver, updated to did v1 + expected = { + "@context": "https://w3id.org/did/v1", + "id": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", + "verificationMethod": [ + { + "id": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", + "type": "Ed25519VerificationKey2018", + "controller": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", + "publicKeyBase58": "8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K", + } + ], + "authentication": [ + "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" + ], + "assertionMethod": [ + "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" + ], + "capabilityDelegation": [ + "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" + ], + "capabilityInvocation": [ + "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" + ], + "keyAgreement": [ + { + "id": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6LShpNhGwSupbB7zjuivH156vhLJBDDzmQtA4BY9S94pe1K", + "type": "X25519KeyAgreementKey2019", + "controller": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", + "publicKeyBase58": "79CXkde3j8TNuMXxPdV7nLUrT2g7JAEjH5TreyVY7GEZ", + } + ], + } + + assert did_doc == expected diff --git a/aries_cloudagent/protocols/connections/v1_0/tests/test_manager.py b/aries_cloudagent/protocols/connections/v1_0/tests/test_manager.py index b45626b9d0..ee768efb62 100644 --- a/aries_cloudagent/protocols/connections/v1_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/connections/v1_0/tests/test_manager.py @@ -8,7 +8,6 @@ from .....connections.models.conn_record import ConnRecord from .....connections.models.connection_target import ConnectionTarget from .....connections.base_manager import ( - BaseConnectionManager, BaseConnectionManagerError, ) from .....connections.models.diddoc import DIDDoc, PublicKey, PublicKeyType, Service @@ -21,7 +20,8 @@ from .....wallet.base import DIDInfo, KeyInfo from .....wallet.error import WalletNotFoundError from .....wallet.in_memory import InMemoryWallet -from .....wallet.util import naked_to_did_key +from .....wallet.crypto import KeyType +from .....did.did_key import DIDKey from ....coordinate_mediation.v1_0.models.mediation_record import MediationRecord from ....coordinate_mediation.v1_0.manager import MediationManager from ....coordinate_mediation.v1_0.messages.keylist_update import KeylistUpdate @@ -2139,7 +2139,11 @@ async def test_fetch_connection_targets_oob_invitation_svc_block_ledger(self): service_blocks=[ async_mock.MagicMock( service_endpoint=self.test_endpoint, - recipient_keys=[naked_to_did_key(self.test_target_verkey)], + recipient_keys=[ + DIDKey.from_public_key_b58( + self.test_target_verkey, KeyType.ED25519 + ).did + ], routing_keys=[], ) ], diff --git a/aries_cloudagent/protocols/didexchange/v1_0/manager.py b/aries_cloudagent/protocols/didexchange/v1_0/manager.py index 4d3f707378..a88e2d1b99 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/manager.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/manager.py @@ -15,7 +15,7 @@ from ....transport.inbound.receipt import MessageReceipt from ....wallet.base import BaseWallet from ....wallet.did_posture import DIDPosture -from ....wallet.util import did_key_to_naked +from ....did.did_key import DIDKey from ....multitenant.manager import MultitenantManager from ...coordinate_mediation.v1_0.manager import MediationManager @@ -111,7 +111,9 @@ async def receive_invitation( conn_rec = ConnRecord( invitation_key=( # invitation.service_blocks[0].recipient_keys[0] - did_key_to_naked(invitation.service_blocks[0].recipient_keys[0]) + DIDKey.from_did( + invitation.service_blocks[0].recipient_keys[0] + ).public_key_b58 if invitation.service_blocks else None ), 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 00625ab4a4..ef6bbc56e5 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/tests/test_manager.py @@ -14,7 +14,6 @@ ) from .....core.in_memory import InMemoryProfile from .....ledger.base import BaseLedger -from .....ledger.error import LedgerError from .....messaging.responder import BaseResponder, MockResponder from .....messaging.decorators.attach_decorator import AttachDecorator from .....multitenant.manager import MultitenantManager @@ -23,13 +22,13 @@ from .....multitenant.manager import MultitenantManager from .....wallet.base import DIDInfo from .....wallet.in_memory import InMemoryWallet -from .....wallet.util import naked_to_did_key +from .....wallet.crypto import KeyType +from .....did.did_key import DIDKey + from .....connections.base_manager import ( - BaseConnectionManager, BaseConnectionManagerError, ) -from ....connections.v1_0.messages.connection_invitation import ConnectionInvitation from ....coordinate_mediation.v1_0.manager import MediationManager from ....coordinate_mediation.v1_0.messages.keylist_update import ( KeylistUpdate, @@ -111,7 +110,9 @@ async def setUp(self): assert self.manager.session self.oob_manager = OutOfBandManager(self.session) self.test_mediator_routing_keys = [ - naked_to_did_key("3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRR") + DIDKey.from_public_key_b58( + "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRR", KeyType.ED25519 + ).did ] self.test_mediator_conn_id = "mediator-conn-id" self.test_mediator_endpoint = "http://mediator.example.com" diff --git a/aries_cloudagent/protocols/introduction/v0_1/messages/tests/test_invitation.py b/aries_cloudagent/protocols/introduction/v0_1/messages/tests/test_invitation.py index cbddef8850..9bab4effdf 100644 --- a/aries_cloudagent/protocols/introduction/v0_1/messages/tests/test_invitation.py +++ b/aries_cloudagent/protocols/introduction/v0_1/messages/tests/test_invitation.py @@ -1,9 +1,9 @@ from asynctest import TestCase as AsyncTestCase -from unittest import mock, TestCase +from unittest import mock -from ......wallet.util import naked_to_did_key -from .....out_of_band.v1_0.message_types import INVITATION as OOB_INVITATION +from ......did.did_key import DIDKey +from ......wallet.crypto import KeyType from .....out_of_band.v1_0.messages.invitation import ( HSProto, InvitationMessage as OOBInvitationMessage, @@ -33,7 +33,9 @@ def setUp(self): _id="#inline", _type="did-communication", did=self.test_did, - recipient_keys=[naked_to_did_key(self.key)], + recipient_keys=[ + DIDKey.from_public_key_b58(self.key, KeyType.ED25519).did + ], service_endpoint=self.endpoint_url, ) ], diff --git a/aries_cloudagent/protocols/introduction/v0_1/tests/test_service.py b/aries_cloudagent/protocols/introduction/v0_1/tests/test_service.py index 12fbff108d..178ab81e14 100644 --- a/aries_cloudagent/protocols/introduction/v0_1/tests/test_service.py +++ b/aries_cloudagent/protocols/introduction/v0_1/tests/test_service.py @@ -4,7 +4,8 @@ from .....core.in_memory import InMemoryProfile from .....messaging.request_context import RequestContext from .....messaging.responder import MockResponder -from .....wallet.util import naked_to_did_key +from .....did.did_key import DIDKey +from .....wallet.crypto import KeyType from ....didcomm_prefix import DIDCommPrefix from ....out_of_band.v1_0.message_types import INVITATION as OOB_INVITATION @@ -34,8 +35,14 @@ def setUp(self): _id="#inline", _type="did-communication", did=TEST_DID, - recipient_keys=[naked_to_did_key(TEST_VERKEY)], - routing_keys=[naked_to_did_key(TEST_ROUTE_VERKEY)], + recipient_keys=[ + DIDKey.from_public_key_b58(TEST_VERKEY, KeyType.ED25519).did + ], + routing_keys=[ + DIDKey.from_public_key_b58( + TEST_ROUTE_VERKEY, KeyType.ED25519 + ).did + ], service_endpoint=TEST_ENDPOINT, ) ], diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index bae0cf1fce..d1d4597792 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -3,7 +3,7 @@ import logging import json -from typing import List, Mapping +from typing import Mapping from marshmallow import RAISE @@ -17,7 +17,8 @@ ) from ......wallet.error import WalletNotFoundError from ......wallet.base import BaseWallet -from ......wallet.util import did_key_to_naked, naked_to_did_key +from ......wallet.crypto import KeyType +from ......did.did_key import DIDKey from ......storage.vc_holder.base import VCHolder from ......storage.vc_holder.vc_record import VCRecord @@ -79,11 +80,10 @@ async def _assert_can_sign_with_did(self, did: str): async with self.profile.session() as session: try: wallet = session.inject(BaseWallet) + did_key = DIDKey.from_did(did) # Check if issuer is something we can issue with - assert did.startswith("did:key") - verkey = did_key_to_naked(did) - await wallet.get_local_did_for_verkey(verkey) + await wallet.get_local_did_for_verkey(did_key.public_key_b58) except WalletNotFoundError: raise V20CredFormatError( f"Issuer did {did} not found. Unable to issue credential with this DID." @@ -103,12 +103,12 @@ async def _get_suite_for_type(self, did: str, proof_type: str) -> LinkedDataProo if proof_type == "Ed25519Signature2018": verification_method = self._get_verification_method(did) - verkey = did_key_to_naked(did) + did_key = DIDKey.from_did(did) return Ed25519Signature2018( verification_method=verification_method, key_pair=Ed25519WalletKeyPair( - wallet=wallet, public_key_base58=verkey + wallet=wallet, public_key_base58=did_key.public_key_b58 ), ) else: @@ -178,9 +178,11 @@ async def create_request( wallet = session.inject(BaseWallet) did_info = await wallet.get_local_did(holder_did) - did_key = naked_to_did_key(did_info.verkey) + did_key = DIDKey.from_public_key_b58( + did_info.verkey, KeyType.ED25519 + ) - cred_detail["credential"]["credentialSubject"]["id"] = did_key + cred_detail["credential"]["credentialSubject"]["id"] = did_key.did else: cred_detail = V20CredProposal.deserialize( @@ -195,9 +197,11 @@ async def create_request( wallet = session.inject(BaseWallet) did_info = await wallet.get_local_did(holder_did) - did_key = naked_to_did_key(did_info.verkey) + did_key = DIDKey.from_public_key_b58( + did_info.verkey, KeyType.ED25519 + ) - cred_detail["credential"]["credentialSubject"]["id"] = did_key + cred_detail["credential"]["credentialSubject"]["id"] = did_key.did self.validate_fields(CRED_20_REQUEST, cred_detail) return self.get_format_data(CRED_20_REQUEST, cred_detail) @@ -262,12 +266,14 @@ async def store_credential( raise V20CredFormatError( "Invalid verification method on received credential" ) - verkey = did_key_to_naked(verification_method.split("#")[0]) + did_key = DIDKey.from_did(verification_method) # TODO: API rework. suite = Ed25519Signature2018( verification_method=verification_method, - key_pair=Ed25519WalletKeyPair(wallet=wallet, public_key_base58=verkey), + key_pair=Ed25519WalletKeyPair( + wallet=wallet, public_key_base58=did_key.public_key_b58 + ), ) result = await verify_credential( 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 b6e5fb3fdd..5acdae197a 100644 --- a/aries_cloudagent/protocols/out_of_band/v1_0/manager.py +++ b/aries_cloudagent/protocols/out_of_band/v1_0/manager.py @@ -24,7 +24,9 @@ from ....storage.error import StorageNotFoundError from ....transport.inbound.receipt import MessageReceipt from ....wallet.base import BaseWallet -from ....wallet.util import naked_to_did_key, b64_to_bytes, did_key_to_naked +from ....wallet.util import b64_to_bytes +from ....wallet.crypto import KeyType +from ....did.did_key import DIDKey from ...connections.v1_0.manager import ConnectionManager from ...connections.v1_0.messages.connection_invitation import ConnectionInvitation @@ -317,7 +319,9 @@ async def create_invitation( keylist_updates, connection_id=mediation_record.connection_id ) routing_keys = [ - key if len(key.split(":")) == 3 else naked_to_did_key(key) + key + if len(key.split(":")) == 3 + else DIDKey.from_public_key_b58(key, KeyType.ED25519).did for key in routing_keys ] # Create connection invitation message @@ -332,7 +336,11 @@ async def create_invitation( ServiceMessage( _id="#inline", _type="did-communication", - recipient_keys=[naked_to_did_key(connection_key.verkey)], + recipient_keys=[ + DIDKey.from_public_key_b58( + connection_key.verkey, KeyType.ED25519 + ).did + ], service_endpoint=my_endpoint, routing_keys=routing_keys, ) @@ -405,7 +413,7 @@ async def receive_invitation( service_did = invi_msg.service_dids[0] async with ledger: verkey = await ledger.get_key_for_did(service_did) - did_key = naked_to_did_key(verkey) + did_key = DIDKey.from_public_key_b58(verkey, KeyType.ED25519).did endpoint = await ledger.get_endpoint_for_did(service_did) public_did = service_did.split(":")[-1] service = ServiceMessage.deserialize( @@ -520,10 +528,12 @@ async def receive_invitation( ) elif proto is HSProto.RFC160: service.recipient_keys = [ - did_key_to_naked(key) for key in service.recipient_keys or [] + DIDKey.from_did(key).public_key_b58 + for key in service.recipient_keys or [] ] service.routing_keys = [ - did_key_to_naked(key) for key in service.routing_keys + DIDKey.from_did(key).public_key_b58 + for key in service.routing_keys ] or [] connection_invitation = ConnectionInvitation.deserialize( { 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 5f40f13d77..78e312bf3f 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 @@ -1,17 +1,16 @@ import pytest -from unittest import mock, TestCase - -from asynctest import TestCase as AsyncTestCase +from unittest import TestCase from ......messaging.models.base import BaseModelError -from ......wallet.util import naked_to_did_key +from ......did.did_key import DIDKey +from ......wallet.crypto import KeyType from .....connections.v1_0.message_types import ARIES_PROTOCOL as CONN_PROTO from .....didcomm_prefix import DIDCommPrefix from .....didexchange.v1_0.message_types import ARIES_PROTOCOL as DIDX_PROTO -from ...message_types import INVITATION, PROTOCOL_PACKAGE +from ...message_types import INVITATION from .. import invitation as test_module from ..invitation import HSProto, InvitationMessage, InvitationMessageSchema @@ -82,7 +81,9 @@ def test_wrap_serde(self): service = Service( _id="#inline", _type=DID_COMM, - recipient_keys=[naked_to_did_key(TEST_VERKEY)], + recipient_keys=[ + DIDKey.from_public_key_b58(TEST_VERKEY, KeyType.ED25519).did + ], service_endpoint="http://1.2.3.4:8080/service", ) data_deser = invi_schema.pre_load( @@ -114,7 +115,9 @@ def test_url_round_trip(self): service = Service( _id="#inline", _type=DID_COMM, - recipient_keys=[naked_to_did_key(TEST_VERKEY)], + recipient_keys=[ + DIDKey.from_public_key_b58(TEST_VERKEY, KeyType.ED25519).did + ], service_endpoint="http://1.2.3.4:8080/service", ) invi_msg = InvitationMessage( 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 1563e2bdf9..f3d30a435f 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 @@ -15,9 +15,8 @@ from .....ledger.base import BaseLedger from .....messaging.decorators.attach_decorator import AttachDecorator from .....messaging.responder import BaseResponder, MockResponder -from .....messaging.util import str_to_datetime, str_to_epoch +from .....messaging.util import str_to_epoch from .....multitenant.manager import MultitenantManager -from .....protocols.connections.v1_0.manager import ConnectionManager from .....protocols.coordinate_mediation.v1_0.models.mediation_record import ( MediationRecord, ) @@ -42,7 +41,6 @@ ) from .....protocols.present_proof.v1_0.messages.presentation_request import ( PresentationRequest, - PresentationRequestSchema, ) from .....protocols.present_proof.v1_0.models.presentation_exchange import ( V10PresentationExchange, @@ -56,12 +54,13 @@ from .....protocols.present_proof.v2_0.messages.pres import V20Pres from .....protocols.present_proof.v2_0.messages.pres_format import V20PresFormat from .....protocols.present_proof.v2_0.messages.pres_request import V20PresRequest -from .....storage.error import StorageError, StorageNotFoundError +from .....storage.error import StorageNotFoundError from .....multitenant.manager import MultitenantManager from .....transport.inbound.receipt import MessageReceipt from .....wallet.base import DIDInfo, KeyInfo from .....wallet.in_memory import InMemoryWallet -from .....wallet.util import did_key_to_naked, naked_to_did_key +from .....wallet.crypto import KeyType +from .....did.did_key import DIDKey from ....didcomm_prefix import DIDCommPrefix from ....issue_credential.v1_0.models.credential_exchange import V10CredentialExchange @@ -70,10 +69,9 @@ from ..manager import ( OutOfBandManager, OutOfBandManagerError, - OutOfBandManagerNotImplementedError, ) from ..message_types import INVITATION -from ..messages.invitation import HSProto, InvitationMessage, InvitationMessageSchema +from ..messages.invitation import HSProto, InvitationMessage from ..messages.reuse import HandshakeReuse from ..messages.reuse_accept import HandshakeReuseAccept from ..messages.problem_report import ProblemReport, ProblemReportReason @@ -660,9 +658,9 @@ async def test_create_invitation_peer_did(self): assert service["id"] == "#inline" assert service["type"] == "did-communication" assert len(service["recipientKeys"]) == 1 - assert service["routingKeys"][0] == naked_to_did_key( - self.test_mediator_routing_keys[0] - ) + assert service["routingKeys"][0] == DIDKey.from_public_key_b58( + self.test_mediator_routing_keys[0], KeyType.ED25519 + ).did assert service["serviceEndpoint"] == self.test_mediator_endpoint async def test_create_invitation_metadata_assigned(self): @@ -671,7 +669,7 @@ async def test_create_invitation_metadata_assigned(self): metadata={"hello": "world"}, ) service = invi_rec.invitation["service"][0] - invitation_key = did_key_to_naked(service["recipientKeys"][0]) + invitation_key = DIDKey.from_did(service["recipientKeys"][0]).public_key_b58 record = await ConnRecord.retrieve_by_invitation_key( self.session, invitation_key ) @@ -806,9 +804,10 @@ async def test_receive_invitation_connection_mock(self): service_blocks=[ async_mock.MagicMock( recipient_keys=[ - naked_to_did_key( - "9WCgWKUaAJj3VWxxtzvvMQN3AoFxoBtBDo9ntwJnVVCC" - ) + DIDKey.from_public_key_b58( + "9WCgWKUaAJj3VWxxtzvvMQN3AoFxoBtBDo9ntwJnVVCC", + KeyType.ED25519, + ).did ], routing_keys=[], service_endpoint="http://localhost", diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py index 2c9f6208d0..a506e59d6f 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py @@ -1,5 +1,6 @@ from ....wallet.base import BaseWallet -from ....wallet.util import public_key_base58_to_fingerprint +from ....did.did_key import DIDKey +from ....wallet.crypto import KeyType from .KeyPair import KeyPair @@ -9,7 +10,9 @@ def __init__(self, wallet: BaseWallet, public_key_base58: str): self.public_key_base58 = public_key_base58 def fingerprint(self) -> str: - return public_key_base58_to_fingerprint(self.public_key_base58) + return DIDKey.from_public_key_b58( + self.public_key_base58, KeyType.ED25519 + ).fingerprint async def sign(self, message: bytes) -> bytes: return await self.wallet.sign_message( diff --git a/aries_cloudagent/vc/ld_proofs/document_loader.py b/aries_cloudagent/vc/ld_proofs/document_loader.py index f46ca0dcc3..aace6bf01b 100644 --- a/aries_cloudagent/vc/ld_proofs/document_loader.py +++ b/aries_cloudagent/vc/ld_proofs/document_loader.py @@ -1,51 +1,21 @@ from pyld.documentloader import requests -from ...wallet.util import did_key_to_naked - -from typing import Callable +from ...did.did_key import DIDKey -def resolve_ed25519_did_key(did_key: str) -> dict: - # TODO: optimize - without_fragment = did_key.split("#")[0] - pub_key_base58 = did_key_to_naked(without_fragment) - key_ref = f"#{without_fragment[8:]}" - did_key_with_key_ref = without_fragment + key_ref - - return { - "contentType": "application/ld+json", - "contextUrl": None, - "documentUrl": did_key, - "document": { - "@context": "https://w3id.org/did/v1", - "id": without_fragment, - "verificationMethod": [ - { - "id": did_key_with_key_ref, - "type": "Ed25519VerificationKey2018", - "controller": without_fragment, - "publicKeyBase58": pub_key_base58, - } - ], - "authentication": [did_key_with_key_ref], - "assertionMethod": [did_key_with_key_ref], - "capabilityDelegation": [did_key_with_key_ref], - "capabilityInvocation": [did_key_with_key_ref], - "keyAgreement": [ - { - "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6LSbkodSr6SU2trs8VUgnrnWtSm7BAPG245ggrBmSrxbv1R", - "type": "X25519KeyAgreementKey2019", - "controller": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", - "publicKeyBase58": "5dTvYHaNaB7mk7iA9LqCJEHG2dGZQsvoi8WGzDRtYEf", - } - ], - }, - } +from typing import Callable def did_key_document_loader(url: str, options: dict): - # NOTE: this is a hacky approach for the time being. + # TODO: integrate with did resolver interface if url.startswith("did:key:"): - return resolve_ed25519_did_key(url) + did_key = DIDKey.from_did(url) + + return { + "contentType": "application/ld+json", + "contextUrl": None, + "documentUrl": url, + "document": did_key.did_doc, + } elif url.startswith("http://") or url.startswith("https://"): loader = requests.requests_document_loader() return loader(url, options) diff --git a/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py b/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py index 3753f11e67..8b982004f6 100644 --- a/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py +++ b/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py @@ -3,7 +3,8 @@ from datetime import datetime from ....wallet.base import KeyInfo -from ....wallet.util import naked_to_did_key +from ....wallet.crypto import KeyType +from ....did.did_key import DIDKey from ....wallet.in_memory import InMemoryWallet from ....core.in_memory import InMemoryProfile from ...ld_proofs import ( @@ -29,9 +30,9 @@ async def setUp(self): self.key_pair = Ed25519WalletKeyPair( wallet=self.wallet, public_key_base58=self.key_info.verkey ) - self.verification_method = ( - naked_to_did_key(self.key_info.verkey) + "#" + self.key_pair.fingerprint() - ) + self.verification_method = DIDKey.from_public_key_b58( + self.key_info.verkey, KeyType.ED25519 + ).key_id self.suite = Ed25519Signature2018( # TODO: should we provide verification_method here? Or abstract? verification_method=self.verification_method, diff --git a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py index 7cb5824333..fefc7eabb8 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py @@ -3,7 +3,8 @@ from datetime import datetime from ....wallet.base import KeyInfo -from ....wallet.util import naked_to_did_key +from ....wallet.crypto import KeyType +from ....did.did_key import DIDKey from ....wallet.in_memory import InMemoryWallet from ....core.in_memory import InMemoryProfile from ...ld_proofs import ( @@ -28,9 +29,10 @@ async def setUp(self): self.key_pair = Ed25519WalletKeyPair( wallet=self.wallet, public_key_base58=self.key_info.verkey ) - self.verification_method = ( - naked_to_did_key(self.key_info.verkey) + "#" + self.key_pair.fingerprint() - ) + + self.verification_method = DIDKey.from_public_key_b58( + self.key_info.verkey, KeyType.ED25519 + ).key_id self.suite = Ed25519Signature2018( # TODO: should we provide verification_method here? Or abstract? verification_method=self.verification_method, diff --git a/aries_cloudagent/wallet/crypto.py b/aries_cloudagent/wallet/crypto.py index bdf1a49271..509657748c 100644 --- a/aries_cloudagent/wallet/crypto.py +++ b/aries_cloudagent/wallet/crypto.py @@ -1,9 +1,10 @@ """Cryptography functions used by BasicWallet.""" +from enum import Enum import json from collections import OrderedDict -from typing import Callable, Optional, Sequence, Tuple +from typing import Callable, NamedTuple, Optional, Sequence, Tuple, Union import nacl.bindings import nacl.exceptions @@ -12,7 +13,35 @@ from marshmallow import fields, Schema, ValidationError from .error import WalletError -from .util import bytes_to_b58, bytes_to_b64, b64_to_bytes, b58_to_bytes +# Define keys +KeySpec = NamedTuple("KeySpec", [("name", str), ("multicodec_name", str)]) + + +class KeyTypeException(BaseException): + """Key type exception.""" + + +class KeyType(Enum): + ED25519 = KeySpec("ed25519", "ed25519-pub") + + @property + def name(self) -> str: + return self.value.name + + @property + def multicodec_name(self) -> str: + return self.value.multicodec_name + + @classmethod + def from_multicodec_name(cls, multicodec_name: str) -> "KeyType": + for key_type in KeyType: + if key_type.multicodec_name == multicodec_name: + return key_type + + raise KeyTypeException( + f"No key type found for multicoded name: '{multicodec_name}'" + ) + class PackMessageSchema(Schema): @@ -286,6 +315,10 @@ def prepare_pack_recipient_keys( # raise ValueError("No corresponding recipient key found in {}".format(not_found)) +def ed25519_pk_to_curve25519(public_key: bytes) -> bytes: + return nacl.bindings.crypto_sign_ed25519_pk_to_curve25519(public_key) + + def encrypt_plaintext( message: str, add_data: bytes, key: bytes ) -> Tuple[bytes, bytes, bytes]: diff --git a/aries_cloudagent/wallet/tests/test_util.py b/aries_cloudagent/wallet/tests/test_util.py index 98c38de6fe..b62eefcd91 100644 --- a/aries_cloudagent/wallet/tests/test_util.py +++ b/aries_cloudagent/wallet/tests/test_util.py @@ -1,9 +1,7 @@ -from datetime import datetime, timezone -from unittest import mock, TestCase +from unittest import TestCase from ..util import ( b58_to_bytes, - b64_to_bytes, b64_to_str, bytes_to_b58, bytes_to_b64, @@ -12,8 +10,6 @@ str_to_b64, set_urlsafe_b64, unpad, - naked_to_did_key, - did_key_to_naked, ) @@ -75,15 +71,3 @@ def test_full_verkey(self): assert full == full_vk assert full == full_verkey(f"did:sov:{did}", abbr_verkey) assert full_verkey(did, full_vk) == full_vk - - def test_naked_to_did_key(self): - assert ( - naked_to_did_key("8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K") - == "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" - ) - - def test_did_key_to_naked(self): - assert ( - did_key_to_naked("did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th") - == "8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K" - ) diff --git a/aries_cloudagent/wallet/util.py b/aries_cloudagent/wallet/util.py index cd9cf7ceed..ed13c9c8f9 100644 --- a/aries_cloudagent/wallet/util.py +++ b/aries_cloudagent/wallet/util.py @@ -3,8 +3,6 @@ import base58 import base64 -from multicodec import add_prefix, remove_prefix - def pad(val: str) -> str: """Pad base64 values if need be: JWT calls to omit trailing padding.""" @@ -67,34 +65,4 @@ def full_verkey(did: str, abbr_verkey: str) -> str: bytes_to_b58(b58_to_bytes(did.split(":")[-1]) + b58_to_bytes(abbr_verkey[1:])) if abbr_verkey.startswith("~") else abbr_verkey - ) - - -def public_key_base58_to_fingerprint(public_key_base58: str) -> str: - key_bytes = b58_to_bytes(public_key_base58) - prefixed_key_bytes = add_prefix("ed25519-pub", key_bytes) - fingerprint = f"z{bytes_to_b58(prefixed_key_bytes)}" - - return fingerprint - - -def fingerprint_to_public_key_base58(fingerprint: str): - assert fingerprint[0] == "z" - - # skip leading `z` that indicates base58 encoding - stripped_key_bytes = b58_to_bytes(fingerprint[1:]) - naked_key_bytes = remove_prefix(stripped_key_bytes) - return bytes_to_b58(naked_key_bytes) - - -def naked_to_did_key(key: str) -> str: - """Convert a naked ed25519 verkey to W3C did:key format.""" - fingerprint = public_key_base58_to_fingerprint(key) - did_key = f"did:key:{fingerprint}" - return did_key - - -def did_key_to_naked(did_key: str) -> str: - """Convert a W3C did:key to naked ed25519 verkey format.""" - fingerprint = did_key.split("did:key:").pop() - return fingerprint_to_public_key_base58(fingerprint) + ) \ No newline at end of file From cf6aec42cd2820f003dd2c0881dff256a1ec6862 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sat, 20 Mar 2021 19:28:14 +0100 Subject: [PATCH 019/138] add did:key support to API Signed-off-by: Timo Glastra --- aries_cloudagent/did/did_key.py | 1 - aries_cloudagent/messaging/valid.py | 19 +++++ aries_cloudagent/wallet/base.py | 15 +++- aries_cloudagent/wallet/crypto.py | 72 ++++++++++++++--- aries_cloudagent/wallet/in_memory.py | 30 ++++++- aries_cloudagent/wallet/indy.py | 69 ++++++++++++---- aries_cloudagent/wallet/routes.py | 117 +++++++++++++++++++++------ configs/agency.yaml | 46 ----------- configs/external-mediator.yml | 25 ------ configs/external.yaml | 51 ------------ configs/holder.yaml | 51 ------------ configs/mediator.yaml | 26 ------ 12 files changed, 269 insertions(+), 253 deletions(-) delete mode 100644 configs/agency.yaml delete mode 100644 configs/external-mediator.yml delete mode 100644 configs/external.yaml delete mode 100644 configs/holder.yaml delete mode 100644 configs/mediator.yaml diff --git a/aries_cloudagent/did/did_key.py b/aries_cloudagent/did/did_key.py index f778fabc2b..7f7b5a010e 100644 --- a/aries_cloudagent/did/did_key.py +++ b/aries_cloudagent/did/did_key.py @@ -1,5 +1,4 @@ from multicodec.multicodec import add_prefix, get_codec, remove_prefix -from varint import encode from ..wallet.crypto import KeyType, ed25519_pk_to_curve25519 from ..wallet.util import b58_to_bytes, bytes_to_b58 diff --git a/aries_cloudagent/messaging/valid.py b/aries_cloudagent/messaging/valid.py index 1e1a6caf86..e146e8dd44 100644 --- a/aries_cloudagent/messaging/valid.py +++ b/aries_cloudagent/messaging/valid.py @@ -3,6 +3,7 @@ import json from datetime import datetime +import re from base58 import alphabet from marshmallow.validate import OneOf, Range, Regexp, Validator @@ -590,6 +591,20 @@ def __call__(self, value): return value +class IndyOrKeyDID(Regexp): + """""" + + PATTERN = re.compile("|".join([DIDKey.PATTERN, IndyDID.PATTERN])) + EXAMPLE = IndyDID.EXAMPLE + + def __init__( + self, + ): + super().__init__( + IndyOrKeyDID, error="Value {input} is not in did:key or indy did format" + ) + + # Instances for marshmallow schema specification INT_EPOCH = {"validate": IntEpoch(), "example": IntEpoch.EXAMPLE} WHOLE_NUM = {"validate": WholeNumber(), "example": WholeNumber.EXAMPLE} @@ -641,3 +656,7 @@ def __call__(self, value): "validate": CredentialSubject(), "example": CredentialSubject.EXAMPLE, } +INDY_OR_KEY_DID = { + "validate": IndyOrKeyDID(), + "example": IndyOrKeyDID.EXAMPLE, +} diff --git a/aries_cloudagent/wallet/base.py b/aries_cloudagent/wallet/base.py index 4b08053b7e..13c66b32a8 100644 --- a/aries_cloudagent/wallet/base.py +++ b/aries_cloudagent/wallet/base.py @@ -2,8 +2,9 @@ from abc import ABC, abstractmethod from collections import namedtuple -from typing import Sequence +from typing import Sequence, Tuple +from .crypto import DIDMethod, KeyType from ..ledger.base import BaseLedger from ..ledger.endpoint_type import EndpointType @@ -88,7 +89,13 @@ async def rotate_did_keypair_apply(self, did: str) -> None: @abstractmethod async def create_local_did( - self, seed: str = None, did: str = None, metadata: dict = None + self, + seed: str = None, + did: str = None, + metadata: dict = None, + *, + method: DIDMethod = DIDMethod.SOV, + key_type: KeyType = KeyType.ED25519 ) -> DIDInfo: """ Create and store a new local DID. @@ -97,6 +104,8 @@ async def create_local_did( seed: Optional seed to use for DID did: The DID to use metadata: Metadata to store with DID + method: The method to use for the DID. Defaults to did:sov + key_type: The key type to use for the DID. defaults to ed25519. Returns: The created `DIDInfo` @@ -312,7 +321,7 @@ async def pack_message( """ @abstractmethod - async def unpack_message(self, enc_message: bytes) -> (str, str, str): + async def unpack_message(self, enc_message: bytes) -> Tuple[str, str, str]: """ Unpack a message. diff --git a/aries_cloudagent/wallet/crypto.py b/aries_cloudagent/wallet/crypto.py index 509657748c..80c50029d6 100644 --- a/aries_cloudagent/wallet/crypto.py +++ b/aries_cloudagent/wallet/crypto.py @@ -4,7 +4,7 @@ import json from collections import OrderedDict -from typing import Callable, NamedTuple, Optional, Sequence, Tuple, Union +from typing import Callable, Mapping, NamedTuple, Optional, Sequence, Tuple, Union, List import nacl.bindings import nacl.exceptions @@ -13,8 +13,15 @@ from marshmallow import fields, Schema, ValidationError from .error import WalletError +from .util import ( + bytes_to_b58, + bytes_to_b64, + b64_to_bytes, + b58_to_bytes, +) + # Define keys -KeySpec = NamedTuple("KeySpec", [("name", str), ("multicodec_name", str)]) +KeySpec = NamedTuple("KeySpec", [("key_type", str), ("multicodec_name", str)]) class KeyTypeException(BaseException): @@ -25,23 +32,70 @@ class KeyType(Enum): ED25519 = KeySpec("ed25519", "ed25519-pub") @property - def name(self) -> str: - return self.value.name + def key_type(self) -> str: + return self.value.key_type @property def multicodec_name(self) -> str: return self.value.multicodec_name @classmethod - def from_multicodec_name(cls, multicodec_name: str) -> "KeyType": + def from_multicodec_name(cls, multicodec_name: str) -> Optional["KeyType"]: for key_type in KeyType: if key_type.multicodec_name == multicodec_name: return key_type - raise KeyTypeException( - f"No key type found for multicoded name: '{multicodec_name}'" - ) + return None + + @classmethod + def from_key_type(cls, key_type: str) -> Optional["DIDMethod"]: + for _key_type in KeyType: + if _key_type.key_type == key_type: + return _key_type + + return None + + +DIDMethodSpec = NamedTuple( + "DIDMethodSpec", + [ + ("method_name", str), + ("supported_key_types", List[KeyType]), + ], +) + + +class DIDMethod(Enum): + SOV = DIDMethodSpec("sov", [KeyType.ED25519]) + KEY = DIDMethodSpec("key", [KeyType.ED25519]) + + @property + def method_name(self) -> str: + return self.value.method_name + + @property + def supported_key_types(self) -> List[KeyType]: + return self.value.supported_key_types + + def supports_key_type(self, key_type: KeyType) -> bool: + return key_type in self.supported_key_types + + def from_metadata(metadata: Mapping) -> "DIDMethod": + method = metadata.get("method") + + # extract from metadata object + if method: + for did_method in DIDMethod: + if method == did_method.method_name: + return did_method + + # return default SOV for backward compat + return DIDMethod.SOV + def from_method(method: str) -> Optional["DIDMethod"]: + for did_method in DIDMethod: + if method == did_method.method_name: + return did_method class PackMessageSchema(Schema): @@ -128,7 +182,7 @@ def sign_pk_from_sk(secret: bytes) -> bytes: return secret[seed_len:] -def validate_seed(seed: (str, bytes)) -> bytes: +def validate_seed(seed: Union[str, bytes]) -> bytes: """ Convert a seed parameter to standard format and check length. diff --git a/aries_cloudagent/wallet/in_memory.py b/aries_cloudagent/wallet/in_memory.py index fce6c46aef..d5791399fd 100644 --- a/aries_cloudagent/wallet/in_memory.py +++ b/aries_cloudagent/wallet/in_memory.py @@ -7,6 +7,8 @@ from .base import BaseWallet, KeyInfo, DIDInfo from .crypto import ( + DIDMethod, + KeyType, create_keypair, random_seed, validate_seed, @@ -15,6 +17,7 @@ encode_pack_message, decode_pack_message, ) +from ..did.did_key import DIDKey from .error import WalletError, WalletDuplicateError, WalletNotFoundError from .util import b58_to_bytes, bytes_to_b58 @@ -152,7 +155,13 @@ async def rotate_did_keypair_apply(self, did: str) -> None: return DIDInfo(did, verkey_enc, self.profile.local_dids[did]["metadata"].copy()) async def create_local_did( - self, seed: str = None, did: str = None, metadata: dict = None + self, + seed: str = None, + did: str = None, + metadata: dict = None, + *, + method: DIDMethod = DIDMethod.SOV, + key_type: KeyType = KeyType.ED25519, ) -> DIDInfo: """ Create and store a new local DID. @@ -161,6 +170,8 @@ async def create_local_did( seed: Optional seed to use for DID did: The DID to use metadata: Metadata to store with DID + method: The method to use for the DID. Defaults to did:sov + key_type: The key type to use for the DID. defaults to ed25519. Returns: A `DIDInfo` instance representing the created DID @@ -170,10 +181,25 @@ async def create_local_did( """ seed = validate_seed(seed) or random_seed() + + # validate key_type + if not method.supports_key_type(key_type): + raise WalletError( + f"Invalid key type {key_type.key_type} for did method f{method.method_name}" + ) + verkey, secret = create_keypair(seed) verkey_enc = bytes_to_b58(verkey) + if not did: - did = bytes_to_b58(verkey[:16]) + # TODO: each method should have it's own class (like DIDKey) + # that can handle public key + key type to did id + if method == DIDMethod.SOV: + did = bytes_to_b58(verkey[:16]) + elif method == DIDMethod.KEY: + did = DIDKey.from_public_key(verkey, key_type).did + else: + raise WalletError(f"Cannot create did for method: {method.method_name}") if ( did in self.profile.local_dids and self.profile.local_dids[did]["verkey"] != verkey_enc diff --git a/aries_cloudagent/wallet/indy.py b/aries_cloudagent/wallet/indy.py index b596638a62..ed9482ad15 100644 --- a/aries_cloudagent/wallet/indy.py +++ b/aries_cloudagent/wallet/indy.py @@ -17,10 +17,11 @@ from ..ledger.endpoint_type import EndpointType from ..ledger.error import LedgerConfigError +from ..did.did_key import DIDKey from .base import BaseWallet, KeyInfo, DIDInfo -from .crypto import validate_seed +from .crypto import DIDMethod, KeyType, validate_seed from .error import WalletError, WalletDuplicateError, WalletNotFoundError -from .util import bytes_to_b64 +from .util import bytes_to_b58, bytes_to_b64 class IndySdkWallet(BaseWallet): @@ -30,6 +31,20 @@ def __init__(self, opened: IndyOpenWallet): """Create a new IndySdkWallet instance.""" self.opened = opened + def __did_info_from_info(self, info): + metadata = json.loads(info["metadata"]) if info["metadata"] else {} + did = info["did"] + verkey = info["verkey"] + + if DIDMethod.from_metadata(metadata) == DIDMethod.KEY: + did = DIDKey.from_public_key_b58(info["verkey"], KeyType.ED25519).did + + return DIDInfo( + did=did, + verkey=verkey, + metadata=metadata, + ) + async def create_signing_key( self, seed: str = None, metadata: dict = None ) -> KeyInfo: @@ -111,6 +126,7 @@ async def replace_signing_key_metadata(self, verkey: str, metadata: dict): await self.get_signing_key(verkey) # throw exception if key is undefined await indy.crypto.set_key_metadata(self.opened.handle, verkey, meta_json) + # TODO: rotate not possible for did key async def rotate_did_keypair_start(self, did: str, next_seed: str = None) -> str: """ Begin key rotation for DID that wallet owns: generate new keypair. @@ -163,7 +179,13 @@ async def rotate_did_keypair_apply(self, did: str) -> DIDInfo: ) from x_indy async def create_local_did( - self, seed: str = None, did: str = None, metadata: dict = None + self, + seed: str = None, + did: str = None, + metadata: dict = None, + *, + method: DIDMethod = DIDMethod.SOV, + key_type: KeyType = KeyType.ED25519, ) -> DIDInfo: """ Create and store a new local DID. @@ -172,6 +194,8 @@ async def create_local_did( seed: Optional seed to use for DID did: The DID to use metadata: Metadata to store with DID + method: The method to use for the DID. Defaults to did:sov + key_type: The key type to use for the DID. defaults to ed25519. Returns: A `DIDInfo` instance representing the created DID @@ -181,6 +205,13 @@ async def create_local_did( WalletError: If there is a libindy error """ + + # validate key_type + if not method.supports_key_type(key_type): + raise WalletError( + f"Invalid key type {key_type.key_type} for did method f{method.method_name}" + ) + cfg = {} if seed: cfg["seed"] = bytes_to_b64(validate_seed(seed)) @@ -198,10 +229,21 @@ async def create_local_did( raise IndyErrorHandler.wrap_error( x_indy, "Wallet {} error".format(self.opened.name), WalletError ) from x_indy + + # Store that we're using did:key for this did + if method == DIDMethod.KEY: + if metadata: + metadata["method"] = method.method_name + else: + metadata = {"method": method.method_name} if metadata: await self.replace_local_did_metadata(did, metadata) else: metadata = {} + + if method == DIDMethod.KEY: + # Transform the did to a did key + did = DIDKey.from_public_key_b58(verkey, key_type).did return DIDInfo(did, verkey, metadata) async def get_local_dids(self) -> Sequence[DIDInfo]: @@ -216,13 +258,7 @@ async def get_local_dids(self) -> Sequence[DIDInfo]: info = json.loads(info_json) ret = [] for did in info: - ret.append( - DIDInfo( - did=did["did"], - verkey=did["verkey"], - metadata=json.loads(did["metadata"]) if did["metadata"] else {}, - ) - ) + ret.append(self.__did_info_from_info(did)) return ret async def get_local_did(self, did: str) -> DIDInfo: @@ -241,6 +277,13 @@ async def get_local_did(self, did: str) -> DIDInfo: """ + # Resolve + if did.startswith("did:key"): + did_key = DIDKey.from_did(did) + # Ed25519 did:keys are masked indy dids + if did_key.key_type == KeyType.ED25519: + did = bytes_to_b58(did_key.public_key[:16]) + try: info_json = await indy.did.get_my_did_with_meta(self.opened.handle, did) except IndyError as x_indy: @@ -250,11 +293,7 @@ async def get_local_did(self, did: str) -> DIDInfo: x_indy, "Wallet {} error".format(self.opened.name), WalletError ) from x_indy info = json.loads(info_json) - return DIDInfo( - did=info["did"], - verkey=info["verkey"], - metadata=json.loads(info["metadata"]) if info["metadata"] else {}, - ) + return self.__did_info_from_info(info) async def get_local_did_for_verkey(self, verkey: str) -> DIDInfo: """ diff --git a/aries_cloudagent/wallet/routes.py b/aries_cloudagent/wallet/routes.py index 28e9e91a1a..a142e1bbee 100644 --- a/aries_cloudagent/wallet/routes.py +++ b/aries_cloudagent/wallet/routes.py @@ -1,16 +1,14 @@ """Wallet admin routes.""" -from aries_cloudagent.wallet.util import naked_to_did_key from aiohttp import web from aiohttp_apispec import ( docs, - # match_info_schema, querystring_schema, request_schema, response_schema, ) -from marshmallow import fields +from marshmallow import fields, validate, ValidationError from ..admin.request_context import AdminRequestContext from ..ledger.base import BaseLedger @@ -19,14 +17,14 @@ from ..messaging.models.openapi import OpenAPISchema from ..messaging.valid import ( DID_POSTURE, + INDY_OR_KEY_DID, + INDY_DID, ENDPOINT, ENDPOINT_TYPE, - INDY_CRED_DEF_ID, - INDY_DID, INDY_RAW_PUBLIC_KEY, ) from ..multitenant.manager import MultitenantManager - +from .crypto import DIDMethod, KeyType from .base import DIDInfo, BaseWallet from .did_posture import DIDPosture from .error import WalletError, WalletNotFoundError @@ -39,7 +37,7 @@ class WalletModuleResponseSchema(OpenAPISchema): class DIDSchema(OpenAPISchema): """Result schema for a DID.""" - did = fields.Str(description="DID of interest", **INDY_DID) + did = fields.Str(description="DID of interest", **INDY_OR_KEY_DID) verkey = fields.Str(description="Public verification key", **INDY_RAW_PUBLIC_KEY) posture = fields.Str( description=( @@ -92,7 +90,7 @@ class DIDEndpointSchema(OpenAPISchema): class DIDListQueryStringSchema(OpenAPISchema): """Parameters and validators for DID list request query string.""" - did = fields.Str(description="DID of interest", required=False, **INDY_DID) + did = fields.Str(description="DID of interest", required=False, **INDY_OR_KEY_DID) verkey = fields.Str( description="Verification key of interest", required=False, @@ -107,6 +105,12 @@ class DIDListQueryStringSchema(OpenAPISchema): required=False, **DID_POSTURE, ) + method = fields.Str( + required=False, + example=DIDMethod.KEY.method_name, + validate=validate.OneOf([DIDMethod.KEY.method_name, DIDMethod.SOV.method_name]), + description="DID method to query for. e.g. sov to only fetch indy/sov DIDs", + ) class DIDQueryStringSchema(OpenAPISchema): @@ -115,11 +119,30 @@ class DIDQueryStringSchema(OpenAPISchema): did = fields.Str(description="DID of interest", required=True, **INDY_DID) -class CredDefIdMatchInfoSchema(OpenAPISchema): - """Path parameters and validators for request taking credential definition id.""" +class DIDCreateOptionsSchema(OpenAPISchema): + """Parameters and validators for create DID options.""" - cred_def_id = fields.Str( - description="Credential identifier", required=True, **INDY_CRED_DEF_ID + key_type = fields.Str( + required=True, + example=KeyType.ED25519.key_type, + validate=validate.OneOf([key_type.key_type for key_type in KeyType]), + ) + + +class DIDCreateSchema(OpenAPISchema): + """Parameters and validators for create DID endpoint.""" + + method = fields.Str( + required=False, + default=DIDMethod.SOV.method_name, + example=DIDMethod.KEY.method_name, + validate=validate.OneOf([DIDMethod.KEY.method_name, DIDMethod.SOV.method_name]), + ) + + options = fields.Nested( + DIDCreateOptionsSchema, + required=False, + description="To define a key type for a did:key", ) @@ -130,8 +153,6 @@ def format_did_info(info: DIDInfo): "did": info.did, "verkey": info.verkey, "posture": DIDPosture.get(info.metadata).moniker, - # TODO: if did is public use did:sov - "full_did": naked_to_did_key(info.verkey), } @@ -156,6 +177,7 @@ async def wallet_did_list(request: web.BaseRequest): raise web.HTTPForbidden(reason="No wallet available") filter_did = request.query.get("did") filter_verkey = request.query.get("verkey") + filter_method = DIDMethod.from_method(request.query.get("method")) or DIDMethod.SOV filter_posture = DIDPosture.get(request.query.get("posture")) results = [] public_did_info = await wallet.get_public_did() @@ -166,13 +188,24 @@ async def wallet_did_list(request: web.BaseRequest): public_did_info and (not filter_verkey or public_did_info.verkey == filter_verkey) and (not filter_did or public_did_info.did == filter_did) + # filter by did method + and ( + not filter_method + or DIDMethod.from_metadata(public_did_info.metadata) == filter_method + ) ): results.append(format_did_info(public_did_info)) elif filter_posture is DIDPosture.POSTED: results = [] for info in posted_did_infos: - if (not filter_verkey or info.verkey == filter_verkey) and ( - not filter_did or info.did == filter_did + if ( + (not filter_verkey or info.verkey == filter_verkey) + and (not filter_did or info.did == filter_did) + # filter by did method + and ( + not filter_method + or DIDMethod.from_metadata(info.metadata) == filter_method + ) ): results.append(format_did_info(info)) elif filter_did: @@ -191,6 +224,11 @@ async def wallet_did_list(request: web.BaseRequest): and not info.metadata.get("posted") ) ) + # filter by did method + and ( + not filter_method + or DIDMethod.from_metadata(info.metadata) == filter_method + ) ): results.append(format_did_info(info)) elif filter_verkey: @@ -198,11 +236,19 @@ async def wallet_did_list(request: web.BaseRequest): info = await wallet.get_local_did_for_verkey(filter_verkey) except WalletError: info = None - if info and ( - filter_posture is None - or ( - filter_posture is DID_POSTURE.WALLET_ONLY - and not info.metadata.get("posted") + if ( + info + and ( + filter_posture is None + or ( + filter_posture is DID_POSTURE.WALLET_ONLY + and not info.metadata.get("posted") + ) + ) + # filter by did method + and ( + not filter_method + or DIDMethod.from_metadata(info.metadata) == filter_method ) ): results.append(format_did_info(info)) @@ -211,8 +257,15 @@ async def wallet_did_list(request: web.BaseRequest): results = [ format_did_info(info) for info in dids - if filter_posture is None - or DIDPosture.get(info.metadata) is DIDPosture.WALLET_ONLY + if ( + filter_posture is None + or DIDPosture.get(info.metadata) is DIDPosture.WALLET_ONLY + ) + # filter by did method + and ( + not filter_method + or DIDMethod.from_metadata(info.metadata) == filter_method + ) ] results.sort( @@ -222,6 +275,7 @@ async def wallet_did_list(request: web.BaseRequest): @docs(tags=["wallet"], summary="Create a local DID") +@request_schema(DIDCreateSchema()) @response_schema(DIDResultSchema, 200, description="") async def wallet_create_did(request: web.BaseRequest): """ @@ -235,12 +289,27 @@ async def wallet_create_did(request: web.BaseRequest): """ context: AdminRequestContext = request["context"] + + try: + body = await request.json() + except: + body = {} + + key_type = ( + KeyType.from_key_type(body.get("options", {}).get("key_type")) + or KeyType.ED25519 + ) + method = DIDMethod.from_method(body.get("method")) or DIDMethod.SOV + + if not method.supports_key_type(key_type): + raise ValidationError(f"method {method} does not support key type {key_type}") + session = await context.session() wallet = session.inject(BaseWallet, required=False) if not wallet: raise web.HTTPForbidden(reason="No wallet available") try: - info = await wallet.create_local_did() + info = await wallet.create_local_did(method=method, key_type=key_type) except WalletError as err: raise web.HTTPBadRequest(reason=err.roll_up) from err diff --git a/configs/agency.yaml b/configs/agency.yaml deleted file mode 100644 index 8b6e4d3532..0000000000 --- a/configs/agency.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# General -label: Agency -endpoint: http://localhost:3004 -inbound-transport: - - [http, 0.0.0.0, 3004] -outbound-transport: http - -# Admin -admin: [0.0.0.0, 3005] -admin-insecure-mode: true - -# Connections -debug-connections: true -auto-accept-invites: true -auto-accept-requests: true -auto-ping-connection: true - -# Mediation -# mediator-invitation: http://localhost:3002?c_i=eyJAdHlwZSI6ICJkaWQ6c292OkJ6Q2JzTlloTXJqSGlxWkRUVUFTSGc7c3BlYy9jb25uZWN0aW9ucy8xLjAvaW52aXRhdGlvbiIsICJAaWQiOiAiOGRjY2NjOGUtYjJhZi00NjVmLTg1NzktOWIwMWRmY2EwYmMzIiwgInNlcnZpY2VFbmRwb2ludCI6ICJodHRwOi8vbG9jYWxob3N0OjMwMDIiLCAicmVjaXBpZW50S2V5cyI6IFsiQXMyd3JzWmI0Zmh0V3N0amE2UWpGQTk1Z0wzd0pqNm9XeGtDeVlaaGlBOG4iXSwgImxhYmVsIjogIk1lZGlhdG9yIn0= - -# Multi-tenancy -multitenant: true -multitenant-admin: true -jwt-secret: very secret secret - -# Webhooks -# webhook-url: http://localhost:1080/agency - -# Wallet -wallet-type: indy -wallet-name: IndyWallet2 -wallet-key: IndyWallet2 -auto-provision: true -recreate-wallet: true - -### Ledger ### - -# No Ledger -no-ledger: true -# BCovrin -# genesis-url: http://test.bcovrin.vonx.io/genesis -# Sovrin Builder Net -# genesis-url: https://raw.githubusercontent.com/sovrin-foundation/sovrin/master/sovrin/pool_transactions_builder_genesis - -# Debug -# log-level: DEBUG diff --git a/configs/external-mediator.yml b/configs/external-mediator.yml deleted file mode 100644 index 9baef76796..0000000000 --- a/configs/external-mediator.yml +++ /dev/null @@ -1,25 +0,0 @@ -# General -label: External Mediator -endpoint: http://localhost:3006 -inbound-transport: - - [http, 0.0.0.0, 3006] -outbound-transport: http -no-ledger: true - -# Admin -admin: [0.0.0.0, 3007] -admin-insecure-mode: true - -# Connections -debug-connections: true -auto-accept-invites: true -auto-accept-requests: true -auto-ping-connection: true - -# Mediation -open-mediation: true -auto-send-keylist-update-in-requests: true -auto-send-keylist-update-in-create-invitation: true - -# Webhooks -webhook-url: http://localhost:1080/external-mediator diff --git a/configs/external.yaml b/configs/external.yaml deleted file mode 100644 index fa41765956..0000000000 --- a/configs/external.yaml +++ /dev/null @@ -1,51 +0,0 @@ -# General -label: Issuer -endpoint: http://localhost:3000 -inbound-transport: - - [http, 0.0.0.0, 3000] -outbound-transport: http - -# Admin -admin: [0.0.0.0, 3001] -admin-insecure-mode: true - -# Connections -debug-connections: true -debug-credentials: true -debug-presentations: true -auto-accept-invites: true -auto-accept-requests: true -# auto-accept-requests-peer: true -# auto-accept-requests-public: true -auto-ping-connection: true -auto-respond-messages: true -auto-respond-credential-proposal: true -auto-respond-credential-offer: true -auto-respond-credential-request: true -auto-respond-presentation-proposal: true -auto-respond-presentation-request: true -auto-store-credential: true -auto-verify-presentation: true - -# Webhooks -# webhook-url: http://localhost:1080/external - -# Wallet -wallet-type: indy -wallet-name: IndyWallet2 -wallet-key: IndyWallet2 -seed: SEED_000000000000000000000000001 -auto-provision: true -recreate-wallet: true - -### Ledger ### -# No Ledger -# no-ledger: true -# BCovrin -genesis-url: http://test.bcovrin.vonx.io/genesis -# Sovrin Builder Net -# genesis-url: https://raw.githubusercontent.com/sovrin-foundation/sovrin/master/sovrin/pool_transactions_builder_genesis - -# Debug -# log-level: DEBUG - diff --git a/configs/holder.yaml b/configs/holder.yaml deleted file mode 100644 index 85b27a559e..0000000000 --- a/configs/holder.yaml +++ /dev/null @@ -1,51 +0,0 @@ -# General -label: Holder -endpoint: http://localhost:3002 -inbound-transport: - - [http, 0.0.0.0, 3002] -outbound-transport: http - -# Admin -admin: [0.0.0.0, 3003] -admin-insecure-mode: true - -# Connections -debug-connections: true -debug-credentials: true -debug-presentations: true -auto-accept-invites: true -auto-accept-requests: true -# auto-accept-requests-peer: true -# auto-accept-requests-public: true -auto-ping-connection: true -auto-respond-messages: true -auto-respond-credential-proposal: true -auto-respond-credential-offer: true -auto-respond-credential-request: true -auto-respond-presentation-proposal: true -auto-respond-presentation-request: true -auto-store-credential: true -auto-verify-presentation: true - -# Webhooks -# webhook-url: http://localhost:1080/external - -# Wallet -wallet-type: indy -wallet-name: IndyWallet3 -wallet-key: IndyWallet2 -seed: SEED_000000000000000000000000002 -auto-provision: true -recreate-wallet: true - -### Ledger ### -# No Ledger -# no-ledger: true -# BCovrin -genesis-url: http://test.bcovrin.vonx.io/genesis -# Sovrin Builder Net -# genesis-url: https://raw.githubusercontent.com/sovrin-foundation/sovrin/master/sovrin/pool_transactions_builder_genesis - -# Debug -# log-level: DEBUG - diff --git a/configs/mediator.yaml b/configs/mediator.yaml deleted file mode 100644 index 232e3adc1d..0000000000 --- a/configs/mediator.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# General -label: Mediator -endpoint: http://localhost:3002 -inbound-transport: - - [http, 0.0.0.0, 3002] -outbound-transport: http -no-ledger: true - -# Admin -admin: [0.0.0.0, 3003] -admin-insecure-mode: true - -# Connections -debug-connections: true -connections-invite: true -invite-multi-use: true -auto-accept-invites: true -auto-accept-requests: true -auto-ping-connection: true - -# Mediation -open-mediation: true -auto-send-keylist-update-in-requests: true -auto-send-keylist-update-in-create-invitation: true -# Webhooks -# webhook-url: http://localhost:1080/mediator From 77bab4bbd3236e2e9f66fb79fc5f71c75392078b Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sat, 20 Mar 2021 19:35:08 +0100 Subject: [PATCH 020/138] fix indy or key did pattern Signed-off-by: Timo Glastra --- aries_cloudagent/messaging/valid.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aries_cloudagent/messaging/valid.py b/aries_cloudagent/messaging/valid.py index e146e8dd44..ccf9687dae 100644 --- a/aries_cloudagent/messaging/valid.py +++ b/aries_cloudagent/messaging/valid.py @@ -601,7 +601,8 @@ def __init__( self, ): super().__init__( - IndyOrKeyDID, error="Value {input} is not in did:key or indy did format" + IndyOrKeyDID.PATTERN, + error="Value {input} is not in did:key or indy did format", ) From 3bf693f52a67388c6b69e5b7344e4fc41a6058f9 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sun, 21 Mar 2021 16:14:11 +0100 Subject: [PATCH 021/138] major cleanup and consistency overhaul Signed-off-by: Timo Glastra --- aries_cloudagent/did/did_key.py | 105 ++++-- aries_cloudagent/messaging/valid.py | 85 ++++- .../v2_0/formats/indy/handler.py | 1 - .../v2_0/formats/ld_proof/handler.py | 318 ++++++++++-------- .../formats/ld_proof/models/cred_detail.py | 183 +++++----- .../ld_proof/models/cred_detail_schema.py | 116 +++++++ .../v2_0/handlers/cred_request_handler.py | 14 +- .../issue_credential/v2_0/manager.py | 3 + .../protocols/issue_credential/v2_0/routes.py | 69 +++- aries_cloudagent/vc/ld_proofs/ProofSet.py | 72 ++-- .../vc/ld_proofs/VerificationException.py | 9 - aries_cloudagent/vc/ld_proofs/__init__.py | 11 +- aries_cloudagent/vc/ld_proofs/constants.py | 3 + .../ld_proofs/crypto/Ed25519WalletKeyPair.py | 53 ++- .../vc/ld_proofs/crypto/KeyPair.py | 22 +- .../vc/ld_proofs/crypto/WalletKeyPair.py | 12 + .../vc/ld_proofs/document_loader.py | 47 +-- aries_cloudagent/vc/ld_proofs/error.py | 5 + aries_cloudagent/vc/ld_proofs/ld_proofs.py | 25 +- .../purposes/AssertionProofPurpose.py | 7 +- .../purposes/AuthenticationProofPurpose.py | 24 +- .../purposes/ControllerProofPurpose.py | 39 ++- .../purposes/CredentialIssuancePurpose.py | 43 ++- .../vc/ld_proofs/purposes/ProofPurpose.py | 22 +- .../ld_proofs/suites/Ed25519Signature2018.py | 7 +- .../suites/JwsLinkedDataSignature.py | 74 +++- .../vc/ld_proofs/suites/LinkedDataProof.py | 40 ++- .../ld_proofs/suites/LinkedDataSignature.py | 99 +++--- .../vc/ld_proofs/tests/test_doc.py | 35 +- .../vc/ld_proofs/tests/test_ld_proofs.py | 31 +- .../vc/ld_proofs/validation_result.py | 116 +++++++ aries_cloudagent/vc/vc_ld/checker.py | 79 ----- aries_cloudagent/vc/vc_ld/issue.py | 9 +- .../vc/vc_ld/models/credential.py | 297 ++++++++++++++++ .../vc/vc_ld/models/credential_schema.py | 107 ++++-- aries_cloudagent/vc/vc_ld/verify.py | 87 ++--- 36 files changed, 1581 insertions(+), 688 deletions(-) create mode 100644 aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail_schema.py delete mode 100644 aries_cloudagent/vc/ld_proofs/VerificationException.py create mode 100644 aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py create mode 100644 aries_cloudagent/vc/ld_proofs/error.py create mode 100644 aries_cloudagent/vc/ld_proofs/validation_result.py delete mode 100644 aries_cloudagent/vc/vc_ld/checker.py create mode 100644 aries_cloudagent/vc/vc_ld/models/credential.py diff --git a/aries_cloudagent/did/did_key.py b/aries_cloudagent/did/did_key.py index 7f7b5a010e..c5ee23e762 100644 --- a/aries_cloudagent/did/did_key.py +++ b/aries_cloudagent/did/did_key.py @@ -4,65 +4,43 @@ from ..wallet.util import b58_to_bytes, bytes_to_b58 -# TODO: use did resolver did doc class -def resolve_did_key_ed25519(did_key: "DIDKey") -> dict: - curve25519 = ed25519_pk_to_curve25519(did_key.public_key) - # TODO: reuse already existing fingerprint methods - # TODO: update once https://github.com/multiformats/py-multicodec/pull/14 is merged - curve25519_fingerprint = "z" + bytes_to_b58(b"".join([b"\xec\x01", curve25519])) - - return { - "@context": "https://w3id.org/did/v1", - "id": did_key.did, - "verificationMethod": [ - { - "id": did_key.key_id, - "type": "Ed25519VerificationKey2018", - "controller": did_key.did, - "publicKeyBase58": did_key.public_key_b58, - } - ], - "authentication": [did_key.key_id], - "assertionMethod": [did_key.key_id], - "capabilityDelegation": [did_key.key_id], - "capabilityInvocation": [did_key.key_id], - "keyAgreement": [ - { - "id": f"{did_key.did}#{curve25519_fingerprint}", - "type": "X25519KeyAgreementKey2019", - "controller": did_key.did, - "publicKeyBase58": bytes_to_b58(curve25519), - } - ], - } - - -DID_KEY_RESOLVERS = {KeyType.ED25519: resolve_did_key_ed25519} - - class DIDKey: + """DID Key parser and resolver.""" + _key_type: KeyType _public_key: bytes def __init__(self, public_key: bytes, key_type: KeyType) -> None: + """Initialize new DIDKey instance.""" self._public_key = public_key self._key_type = key_type @classmethod - def from_public_key(cls, public_key: bytes, key_type: str) -> "DIDKey": + def from_public_key(cls, public_key: bytes, key_type: KeyType) -> "DIDKey": + """Initialize new DIDKey instance from public key and key type.""" + return cls(public_key, key_type) @classmethod def from_public_key_b58(cls, public_key: str, key_type: str) -> "DIDKey": + """Initialize new DIDKey instance from base58 encoded public key and key type.""" public_key_bytes = b58_to_bytes(public_key) return cls.from_public_key(public_key_bytes, key_type) @classmethod def from_fingerprint(cls, fingerprint: str) -> "DIDKey": + """Initialize new DIDKey instance from multibase encoded fingerprint. + + The fingerprint contains both the public key and key type. + """ + # Assert fingerprint is in multibase format assert fingerprint[0] == "z" + + # Get key bytes, remove multicoded prefix key_bytes_with_prefix = b58_to_bytes(fingerprint[1:]) public_key_bytes = remove_prefix(key_bytes_with_prefix) + # Detect multicodec name from prefix, get associated key type multicodec_name = get_codec(key_bytes_with_prefix) key_type = KeyType.from_multicodec_name(multicodec_name) @@ -70,6 +48,10 @@ def from_fingerprint(cls, fingerprint: str) -> "DIDKey": @classmethod def from_did(cls, did: str) -> "DIDKey": + """Initialize a new DIDKey instance from a fully qualified did:key string + + Extracts the fingerprint from the did:key and uses that to constrcut the did:key. + """ did_parts = did.split("#") _, fingerprint = did_parts[0].split("did:key:") @@ -77,31 +59,80 @@ def from_did(cls, did: str) -> "DIDKey": @property def fingerprint(self) -> str: + """Getter for did key fingerprint""" prefixed_key_bytes = add_prefix(self.key_type.multicodec_name, self.public_key) return f"z{bytes_to_b58(prefixed_key_bytes)}" @property def did(self) -> str: + """Getter for full did:key string""" return f"did:key:{self.fingerprint}" @property def did_doc(self) -> dict: + """Getter for did document associated with did:key""" resolver = DID_KEY_RESOLVERS[self.key_type] return resolver(self) @property def public_key(self) -> bytes: + """Getter for public key""" return self._public_key @property def public_key_b58(self) -> str: + """Getter for base58 encoded public key""" return bytes_to_b58(self.public_key) @property def key_type(self) -> KeyType: + """Getter for key type""" return self._key_type @property def key_id(self) -> str: + """Getter for key id""" return f"{self.did}#{self.fingerprint}" + + +def construct_did_key_ed25519(did_key: "DIDKey") -> dict: + """Construct Ed25519 did:key + + Args: + did_key (DIDKey): did key instance to parse ed25519 did:key document from + + Returns: + dict: The ed25519 did:key did document + """ + curve25519 = ed25519_pk_to_curve25519(did_key.public_key) + # TODO: update once https://github.com/multiformats/py-multicodec/pull/14 is merged + curve25519_fingerprint = "z" + bytes_to_b58(b"".join([b"\xec\x01", curve25519])) + + return { + "@context": "https://w3id.org/did/v1", + "id": did_key.did, + "verificationMethod": [ + { + "id": did_key.key_id, + "type": "Ed25519VerificationKey2018", + "controller": did_key.did, + "publicKeyBase58": did_key.public_key_b58, + } + ], + "authentication": [did_key.key_id], + "assertionMethod": [did_key.key_id], + "capabilityDelegation": [did_key.key_id], + "capabilityInvocation": [did_key.key_id], + "keyAgreement": [ + { + "id": f"{did_key.did}#{curve25519_fingerprint}", + "type": "X25519KeyAgreementKey2019", + "controller": did_key.did, + "publicKeyBase58": bytes_to_b58(curve25519), + } + ], + } + + +DID_KEY_RESOLVERS = {KeyType.ED25519: construct_did_key_ed25519} \ No newline at end of file diff --git a/aries_cloudagent/messaging/valid.py b/aries_cloudagent/messaging/valid.py index ccf9687dae..f323b56b05 100644 --- a/aries_cloudagent/messaging/valid.py +++ b/aries_cloudagent/messaging/valid.py @@ -8,6 +8,7 @@ from base58 import alphabet from marshmallow.validate import OneOf, Range, Regexp, Validator from marshmallow.exceptions import ValidationError +from marshmallow.fields import Field from .util import epoch_to_str @@ -18,6 +19,51 @@ B58 = alphabet if isinstance(alphabet, str) else alphabet.decode("ascii") +class StrOrDictField(Field): + """URI or Dict field for Marshmallow.""" + + def _serialize(self, value, attr, obj, **kwargs): + return value + + def _deserialize(self, value, attr, data, **kwargs): + if isinstance(value, (str, dict)): + return value + else: + raise ValidationError("Field should be str or dict") + + +class DictOrDictListField(Field): + """Dict or Dict List field for Marshmallow.""" + + def _serialize(self, value, attr, obj, **kwargs): + return value + + def _deserialize(self, value, attr, data, **kwargs): + # dict + if isinstance(value, dict): + return value + # list of dicts + elif isinstance(value, list) and all(isinstance(item, dict) for item in value): + return value + else: + raise ValidationError("Field should be dict or list of dicts") + + +class UriOrDictField(StrOrDictField): + """URI or Dict field for Marshmallow.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Insert validation into self.validators so that multiple errors can be stored. + self.validators.insert(0, self._uri_validator) + + def _uri_validator(self, value): + # Check if URI when + if isinstance(value, str): + return Uri()(value) + + class IntEpoch(Range): """Validate value against (integer) epoch format.""" @@ -339,6 +385,24 @@ def __init__(self): ) +class RFC3339DateTime(Regexp): + """Validate value against RFC3339 datetime format.""" + + EXAMPLE = "2010-01-01T19:73:24Z" + PATTERN = ( + r"^([0-9]{4})-([0-9]{2})-([0-9]{2})([Tt]([0-9]{2}):([0-9]{2}):" + r"([0-9]{2})(\\.[0-9]+)?)?(([Zz]|([+-])([0-9]{2}):([0-9]{2})))?$" + ) + + def __init__(self): + """Initializer.""" + + super().__init__( + RFC3339DateTime.PATTERN, + error="Value {input} is not a date in valid format", + ) + + class IndyWQL(Regexp): # using Regexp brings in nice visual validator cue """Validate value as potential WQL query.""" @@ -491,14 +555,14 @@ def __init__(self): ) -class URI(Regexp): +class Uri(Regexp): """Validate value against URI on any scheme.""" EXAMPLE = "https://www.w3.org/2018/credentials/v1" PATTERN = r"^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?" def __init__(self): - super().__init__(URI.PATTERN, error="Value {input} is not URI") + super().__init__(Uri.PATTERN, error="Value {input} is not URI") class Endpoint(Regexp): # using Regexp brings in nice visual validator cue @@ -583,10 +647,17 @@ def __init__(self) -> None: super().__init__() def __call__(self, value): - if "id" in value: - uri_validator = URI() - if not uri_validator(value["id"]): - raise ValidationError(f"credential subject id {value[0]} must be URI") + subjects = value if isinstance(value, list) else [value] + + for subject in subjects: + if "id" in subject: + uri_validator = Uri() + try: + uri_validator(value["id"]) + except ValidationError: + raise ValidationError( + f"credential subject id {value[0]} must be URI" + ) return value @@ -635,6 +706,7 @@ def __init__( "validate": IndyISO8601DateTime(), "example": IndyISO8601DateTime.EXAMPLE, } +RFC3339_DATETIME = {"validate": RFC3339DateTime(), "example": RFC3339DateTime.EXAMPLE} INDY_WQL = {"validate": IndyWQL(), "example": IndyWQL.EXAMPLE} INDY_EXTRA_WQL = {"validate": IndyExtraWQL(), "example": IndyExtraWQL.EXAMPLE} BASE64 = {"validate": Base64(), "example": Base64.EXAMPLE} @@ -653,6 +725,7 @@ def __init__( "validate": CredentialContext(), "example": CredentialContext.EXAMPLE, } +URI = {"validate": Uri(), "example": Uri.EXAMPLE} CREDENTIAL_SUBJECT = { "validate": CredentialSubject(), "example": CredentialSubject.EXAMPLE, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py index a278f11353..686694f2c4 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py @@ -176,7 +176,6 @@ async def receive_offer( async def create_request( self, cred_ex_record: V20CredExRecord, request_data: Mapping = None ) -> CredFormatAttachment: - # TODO: request data may be None holder_did = request_data.get("holder_did") if request_data else None cred_offer = V20CredOffer.deserialize(cred_ex_record.cred_offer).attachment( self.format diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index d1d4597792..f923d7b544 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -7,17 +7,19 @@ from marshmallow import RAISE -from ......vc.vc_ld.models.credential_schema import LDVerifiableCredentialSchema +from ......vc.vc_ld.models.credential_schema import VerifiableCredentialSchema +from ......vc.vc_ld.models.credential import LDProof, VerifiableCredential from ......vc.vc_ld import issue, verify_credential from ......vc.ld_proofs import ( Ed25519Signature2018, Ed25519WalletKeyPair, - did_key_document_loader, + default_document_loader, LinkedDataProof, + CredentialIssuancePurpose, + ProofPurpose, ) from ......wallet.error import WalletNotFoundError from ......wallet.base import BaseWallet -from ......wallet.crypto import KeyType from ......did.did_key import DIDKey from ......storage.vc_holder.base import VCHolder from ......storage.vc_holder.vc_record import VCRecord @@ -35,22 +37,11 @@ from ...messages.cred_request import V20CredRequest from ...models.cred_ex_record import V20CredExRecord from ..handler import CredFormatAttachment, V20CredFormatError, V20CredFormatHandler +from .models.cred_detail_schema import LDProofVCDetailSchema from .models.cred_detail import LDProofVCDetail - LOGGER = logging.getLogger(__name__) -# TODO: move to vc util -def get_id(obj) -> str: - if type(obj) is str: - return obj - - if "id" not in obj: - return - - return obj["id"] - - # TODO: move to vc/proof library SUPPORTED_PROOF_TYPES = {"Ed25519Signature2018"} @@ -63,10 +54,10 @@ class LDProofCredFormatHandler(V20CredFormatHandler): @classmethod def validate_fields(cls, message_type: str, attachment_data: Mapping) -> None: mapping = { - CRED_20_PROPOSAL: LDProofVCDetail, - CRED_20_OFFER: LDProofVCDetail, - CRED_20_REQUEST: LDProofVCDetail, - CRED_20_ISSUE: LDVerifiableCredentialSchema, + CRED_20_PROPOSAL: LDProofVCDetailSchema, + CRED_20_OFFER: LDProofVCDetailSchema, + CRED_20_REQUEST: LDProofVCDetailSchema, + CRED_20_ISSUE: VerifiableCredentialSchema, } # Get schema class @@ -76,83 +67,135 @@ def validate_fields(cls, message_type: str, attachment_data: Mapping) -> None: # TODO: unknown should not raise Schema(unknown=RAISE).load(attachment_data) - async def _assert_can_sign_with_did(self, did: str): - async with self.profile.session() as session: - try: - wallet = session.inject(BaseWallet) - did_key = DIDKey.from_did(did) - - # Check if issuer is something we can issue with - await wallet.get_local_did_for_verkey(did_key.public_key_b58) - except WalletNotFoundError: + async def _assert_can_issue_with_did_and_proof_type( + self, did: str, proof_type: str + ): + try: + # We only support ed signatures at the moment + if proof_type != "Ed25519Signature2018": raise V20CredFormatError( - f"Issuer did {did} not found. Unable to issue credential with this DID." + f"Unable to sign credential with proof type {proof_type}" ) + await self._did_info_for_did(did) + except WalletNotFoundError: + raise V20CredFormatError( + f"Issuer did {did} not found. Unable to issue credential with this DID." + ) + + async def _did_info_for_did(self, did: str): + async with self.profile.session() as session: + wallet = session.inject(BaseWallet) + + # If the did starts with did:sov we need to query without + if did.startswith("did:sov:"): + return await wallet.get_local_did(did.replace("did:sov:", "")) + # All other methods we can just query + else: + return await wallet.get_local_did(did) + + async def _get_suite_for_detail(self, detail: LDProofVCDetail) -> LinkedDataProof: + did = detail.credential.issuer_id + proof_type = detail.options.proof_type + + await self._assert_can_issue_with_did_and_proof_type( + did, detail.options.proof_type + ) - async def _assert_can_sign_with_type(self, proof_type: str): - # Check if proof types are supported - if not proof_type in SUPPORTED_PROOF_TYPES: - raise V20CredFormatError(f"Unsupported proof type: {proof_type}.") + proof = LDProof( + created=detail.options.created, + domain=detail.options.domain, + challenge=detail.options.challenge, + ) - async def _get_suite_for_type(self, did: str, proof_type: str) -> LinkedDataProof: - await self._assert_can_sign_with_type(proof_type) + did_info = await self._did_info_for_did(did) + verification_method = self._get_verification_method(did) async with self.profile.session() as session: # TODO: maybe keypair should start session and inject wallet (for shorter sessions) wallet = session.inject(BaseWallet) + # TODO: make enum or something? + # TODO: how to abstract keypair from this step? if proof_type == "Ed25519Signature2018": - verification_method = self._get_verification_method(did) - did_key = DIDKey.from_did(did) - return Ed25519Signature2018( verification_method=verification_method, + proof=proof.serialize(), key_pair=Ed25519WalletKeyPair( - wallet=wallet, public_key_base58=did_key.public_key_b58 + wallet=wallet, public_key_base58=did_info.verkey ), ) else: raise V20CredFormatError(f"Unsupported proof type {proof_type}") + # TODO: move to better place + # TODO: integrate with did resolver classes (did) def _get_verification_method(self, did: str): - verification_method = did + "#" + did.replace("did:key:", "") + if did.startswith("did:sov:"): + # TODO: is this correct? uniresolver uses #key-1, SICPA uses #1 + return did + "#1" + elif did.startswith("did:key:"): + return DIDKey.from_did(did).key_id + else: + raise V20CredFormatError( + f"Unable to get retrieve verification method for did {did}" + ) + + # TODO: move to better place + # TODO: probably needs more input parameters + def _get_proof_purpose(self, proof_purpose: str = None) -> ProofPurpose: + PROOF_PURPOSE_MAP = { + "assertionMethod": CredentialIssuancePurpose, + # TODO: authentication + # "authentication": AuthenticationProofPurpose, + } + + # assertionMethod is default + if not proof_purpose: + proof_purpose = "assertionMethod" + + if proof_purpose not in PROOF_PURPOSE_MAP: + raise V20CredFormatError(f"Unsupported proof purpose {proof_purpose}") - return verification_method + # TODO: constructor parameters + return PROOF_PURPOSE_MAP[proof_purpose]() async def create_proposal( self, cred_ex_record: V20CredExRecord, proposal_data: Mapping ) -> CredFormatAttachment: - self.validate_fields(CRED_20_PROPOSAL, filter) - - return self.get_format_data(CRED_20_PROPOSAL, filter) + return self.get_format_data(CRED_20_PROPOSAL, proposal_data) async def receive_proposal( self, cred_ex_record: V20CredExRecord, cred_proposal_message: V20CredProposal ) -> None: - # TODO: anything to validate here? + """Receive linked data proof credential proposal""" + # Structure validation is already done when message is received + # no additional checking is required here pass async def create_offer( self, cred_ex_record: V20CredExRecord, offer_data: Mapping = None ) -> CredFormatAttachment: # TODO: - # - Use offer data - # - Check if all fields in credentialSubject are present in context - # - Check if all required fields are present (according to RFC). Or is the API going to do this? - # - Other checks (credentialStatus, credentialSchema, etc...) + # - Check if all fields in credentialSubject are present in context (dropped attributes) + # - offer data is not passed at the moment - detail: dict = V20CredProposal.deserialize( - cred_ex_record.cred_proposal - ).attachment(self.format) + # use proposal data otherwise + if not offer_data and cred_ex_record.cred_proposal: + offer_data = V20CredProposal.deserialize( + cred_ex_record.cred_proposal + ).attachment(self.format) + else: + raise V20CredFormatError( + "Cannot create linked data proof offer without proposal or input data" + ) - credential = detail["credential"] - options = detail["options"] + detail = LDProofVCDetail.deserialize(offer_data) - await self._assert_can_sign_with_did(credential["issuer"]) - await self._assert_can_sign_with_type(options["proofType"]) + await self._assert_can_issue_with_did_and_proof_type( + detail.credential.issuer_id, detail.options.proof_type + ) - self.validate_fields(CRED_20_OFFER, detail) - return self.get_format_data(CRED_20_OFFER, detail) + return self.get_format_data(CRED_20_OFFER, detail.serialize()) async def receive_offer( self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer @@ -163,82 +206,88 @@ async def receive_offer( async def create_request( self, cred_ex_record: V20CredExRecord, request_data: Mapping = None ) -> CredFormatAttachment: - holder_did = request_data.get("holder_did") if request_data else None - + # holder_did = request_data.get("holder_did") if request_data else None + # TODO: add build_detail method that takes the record + # and looks for the best detail to build (dependant on messages and role) + + # TODO: request data now contains holder did. This should + # contain the data from the API (if starting from request) + # if request_data: + # detail = LDProofVCDetail.deserialize(request_data) + # Otherwise use offer if possible if cred_ex_record.cred_offer: - cred_detail = V20CredOffer.deserialize( + request_data = V20CredOffer.deserialize( cred_ex_record.cred_offer ).attachment(self.format) - - if ( - not cred_detail["credential"]["credentialSubject"].get("id") - and holder_did - ): - async with self.profile.session() as session: - wallet = session.inject(BaseWallet) - - did_info = await wallet.get_local_did(holder_did) - did_key = DIDKey.from_public_key_b58( - did_info.verkey, KeyType.ED25519 - ) - - cred_detail["credential"]["credentialSubject"]["id"] = did_key.did - - else: - cred_detail = V20CredProposal.deserialize( + # API data is stored in proposal (when starting from request) + # It is a bit of a strage flow IMO. + elif cred_ex_record.cred_proposal: + request_data = V20CredProposal.deserialize( cred_ex_record.cred_proposal ).attachment(self.format) - if ( - not cred_detail["credential"]["credentialSubject"].get("id") - and holder_did - ): - async with self.profile.session() as session: - wallet = session.inject(BaseWallet) + # TODO: do we want to set the credential subject id? + # if ( + # not cred_detail["credential"]["credentialSubject"].get("id") + # and holder_did + # ): + # async with self.profile.session() as session: + # wallet = session.inject(BaseWallet) - did_info = await wallet.get_local_did(holder_did) - did_key = DIDKey.from_public_key_b58( - did_info.verkey, KeyType.ED25519 - ) + # did_info = await wallet.get_local_did(holder_did) + # did_key = DIDKey.from_public_key_b58( + # did_info.verkey, KeyType.ED25519 + # ) + + # cred_detail["credential"]["credentialSubject"]["id"] = did_key.did + else: + raise V20CredFormatError( + "Cannot create linked data proof request without offer or input data" + ) - cred_detail["credential"]["credentialSubject"]["id"] = did_key.did + detail = LDProofVCDetail.deserialize(request_data) - self.validate_fields(CRED_20_REQUEST, cred_detail) - return self.get_format_data(CRED_20_REQUEST, cred_detail) + return self.get_format_data(CRED_20_REQUEST, detail.serialize()) async def receive_request( self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest ) -> None: - # TODO: check if request matches offer. (If not send problem report?) - # TODO: validate - pass + # If we sent an offer, check if request matches this + if cred_ex_record.cred_offer: + cred_request_detail = LDProofVCDetail.deserialize( + cred_request_message.attachment(self.format) + ) + + cred_offer_detail = LDProofVCDetail.deserialize( + V20CredOffer.deserialize(cred_ex_record.cred_offer).attachment( + self.format + ) + ) + + # TODO: probably some fields can be different + # so maybe do partial check? + # e.g. options.challenge may be filled in request + # OR credentialSubject.id + # TODO: Send problem report if no match?s + assert cred_offer_detail == cred_request_detail async def issue_credential( self, cred_ex_record: V20CredExRecord, retries: int = 5 ) -> CredFormatAttachment: - if cred_ex_record.cred_offer: - # TODO: match offer with request. Use request (because of credential subject id) - cred_detail = V20CredOffer.deserialize( - cred_ex_record.cred_offer - ).attachment(self.format) - else: - cred_detail = V20CredRequest.deserialize( - cred_ex_record.cred_request - ).attachment(self.format) + # TODO: we need to be sure the request is matched against the offer + # and only fields that are allowed to change can change + detail_dict = V20CredRequest.deserialize( + cred_ex_record.cred_request + ).attachment(self.format) - issuer_did = get_id(cred_detail["credential"]["issuer"]) - proof_types = cred_detail["options"]["proofType"] + detail = LDProofVCDetail.deserialize(detail_dict) - if len(proof_types) > 1: - raise V20CredFormatError( - "Issuing credential with multiple proof types not supported." - ) - - await self._assert_can_sign_with_did(issuer_did) - suite = await self._get_suite_for_type(issuer_did, proof_types[0]) + suite = await self._get_suite_for_detail(detail) + proof_purpose = self._get_proof_purpose(detail.options.proof_purpose) - # TODO: proof options - vc = await issue(credential=cred_detail["credential"], suite=suite) + vc = await issue( + credential=detail.credential.serialize(), suite=suite, purpose=proof_purpose + ) self.validate_fields(CRED_20_ISSUE, vc) return self.get_format_data(CRED_20_ISSUE, vc) @@ -246,40 +295,32 @@ async def issue_credential( async def receive_credential( self, cred_ex_record: V20CredExRecord, cred_issue_message: V20CredIssue ) -> None: - # TODO: validate + # TODO: validate? I think structure is already validated on a higher lever + # And crypto stuff is better handled in store_credential pass async def store_credential( self, cred_ex_record: V20CredExRecord, cred_id: str = None ) -> None: - # TODO: validate credential structure (prob in receive credential?) - credential: dict = V20CredIssue.deserialize( + cred_dict: dict = V20CredIssue.deserialize( cred_ex_record.cred_issue ).attachment(self.format) + credential = VerifiableCredential.deserialize(cred_dict) + async with self.profile.session() as session: wallet = session.inject(BaseWallet) - verification_method = credential.get("proof", {}).get("verificationMethod") - - if type(verification_method) is not str: - raise V20CredFormatError( - "Invalid verification method on received credential" - ) - did_key = DIDKey.from_did(verification_method) - - # TODO: API rework. + # TODO: better way to get suite + # (possibly combine with creating issuer suite) suite = Ed25519Signature2018( - verification_method=verification_method, - key_pair=Ed25519WalletKeyPair( - wallet=wallet, public_key_base58=did_key.public_key_b58 - ), + key_pair=Ed25519WalletKeyPair(wallet=wallet), ) result = await verify_credential( credential=credential, suite=suite, - document_loader=did_key_document_loader, + document_loader=default_document_loader, ) if not result.get("verified"): @@ -291,14 +332,13 @@ async def store_credential( # TODO: tags vc_record = VCRecord( - contexts=credential.get("@context"), - types=credential.get("type"), - issuer_id=get_id(credential.get("issuer")), - # TODO: subject may be array - subject_ids=[credential.get("credentialSubject").get("id")], - schema_ids=[], - value=json.dumps(credential), - given_id=credential.get("id"), + contexts=credential.context_urls, + types=credential.type, + issuer_id=credential.issuer_id, + subject_ids=[credential.credential_subject_ids], + schema_ids=[], # Schemas not supported yet + value=json.dumps(credential.serialize()), + given_id=credential.id, record_id=cred_id, ) await vc_holder.store_credential(vc_record) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py index 72a84ca799..a72cb33dea 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py @@ -1,77 +1,106 @@ -"""Linked data proof verifiable credential detail artifacts to attach to RFC 453 messages.""" - - -from marshmallow import fields, Schema - -from .......messaging.valid import INDY_ISO8601_DATETIME, UUIDFour -from .......vc.vc_ld.models.credential_schema import LDCredentialSchema - - -class CredentialStatusOptionsSchema(Schema): - """Linked data proof credential status options schema.""" - - type = fields.Str( - required=True, - description="Credential status method type to use for the credential. Should match status method registered in the Verifiable Credential Extension Registry", - example="CredentialStatusList2017", - ) - - -class LDProofVCDetailOptions(Schema): - """Linked data proof verifiable credential options schema.""" - - proof_type = fields.Str( - data_key="proofType", - required=True, - description="The proof type used for the proof. Should match suites registered in the Linked Data Cryptographic Suite Registry", - example="Ed25519Signature2018", - ) - - proof_purpose = fields.Str( - data_key="proofPurpose", - required=False, - default="assertionMethod", - description="The proof purpose used for the proof. Should match proof purposes registered in the Linked Data Proofs Specification", - example="assertionMethod", - ) - - created = fields.Str( - required=False, - description="The date and time of the proof (with a maximum accuracy in seconds). Defaults to current system time", - **INDY_ISO8601_DATETIME, - ) - - domain = fields.Str( - required=False, - description="The intended domain of validity for the proof", - example="example.com", - ) - - challenge = fields.Str( - required=False, - description="A challenge to include in the proof. SHOULD be provided by the requesting party of the credential (=holder)", - example=UUIDFour.EXAMPLE, - ) - - credential_status = fields.Nested( - CredentialStatusOptionsSchema, - data_key="credentialStatus", - required=False, - description="The credential status mechanism to use for the credential. Omitting the property indicates the issued credential will not include a credential status", - ) - - -class LDProofVCDetail(Schema): - """Linked data proof verifiable credential detail schema.""" - - credential = fields.Nested( - LDCredentialSchema, - required=True, - description="Detail of the JSON-LD Credential to be issued", - ) - - options = fields.Nested( - LDProofVCDetailOptions, - required=True, - description="Options for specifying how the linked data proof is created.", - ) +"""Linked data proof verifiable options detail artifacts to attach to RFC 453 messages.""" + + +import copy +import json +from typing import Optional, Union + + +from .......vc.vc_ld.models.credential import ( + VerifiableCredential, +) +from .cred_detail_schema import LDProofVCDetailOptionsSchema, LDProofVCDetailSchema + + +class LDProofVCDetailOptions: + """Linked Data Proof verifiable credential options model""" + + def __init__( + self, + proof_type: Optional[str] = None, + proof_purpose: Optional[str] = None, + created: Optional[str] = None, + domain: Optional[str] = None, + challenge: Optional[str] = None, + credential_status: Optional[dict] = None, + **kwargs, + ) -> None: + """Initialize the LDProofVCDetailOptions instance.""" + + self.proof_type = proof_type + self.proof_purpose = proof_purpose + self.created = created + self.domain = domain + self.challenge = challenge + self.credential_status = credential_status + self.extra = kwargs + + @classmethod + def deserialize(cls, detail_options: Union[dict, str]) -> "LDProofVCDetailOptions": + """Deserialize a dict into a LDProofVCDetailOptions object. + + Args: + detail_options: detail_options + + Returns: + LDProofVCDetailOptions: The deserialized LDProofVCDetailOptions object + """ + if isinstance(detail_options, str): + detail_options = json.loads(detail_options) + schema = LDProofVCDetailOptionsSchema() + detail_options = schema.load(detail_options) + return detail_options + + def serialize(self) -> dict: + """Serialize the LDProofVCDetailOptions object into dict. + + Returns: + dict: The LDProofVCDetailOptions serialized as dict. + """ + schema = LDProofVCDetailOptionsSchema() + detail_options: dict = schema.dump(copy.deepcopy(self)) + detail_options.update(self.extra) + return detail_options + + +class LDProofVCDetail: + """Linked data proof verifiable credential detail.""" + + def __init__( + self, + credential: Optional[Union[dict, VerifiableCredential]], + options: Optional[Union[dict, LDProofVCDetailOptions]], + ) -> None: + if isinstance(credential, dict): + credential = VerifiableCredential.deserialize(credential) + self.credential = credential + + if isinstance(options, dict): + options = LDProofVCDetailOptions.deserialize(options) + self.options = options + + @classmethod + def deserialize(cls, detail: Union[dict, str]) -> "LDProofVCDetail": + """Deserialize a dict into a LDProofVCDetail object. + + Args: + detail: detail + + Returns: + LDProofVCDetail: The deserialized LDProofVCDetail object + """ + if isinstance(detail, str): + detail = json.loads(detail) + schema = LDProofVCDetailSchema() + detail = schema.load(detail) + return detail + + def serialize(self) -> dict: + """Serialize the LDProofVCDetail object into dict. + + Returns: + dict: The LDProofVCDetail serialized as dict. + """ + schema = LDProofVCDetailSchema() + detail: dict = schema.dump(copy.deepcopy(self)) + return detail diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail_schema.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail_schema.py new file mode 100644 index 0000000000..9151a469a0 --- /dev/null +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail_schema.py @@ -0,0 +1,116 @@ +"""Linked data proof verifiable credential detail artifacts to attach to RFC 453 messages.""" + +from marshmallow import fields, Schema, INCLUDE, post_load, post_dump + +from .......messaging.valid import INDY_ISO8601_DATETIME, UUIDFour +from .......vc.vc_ld.models.credential_schema import ( + CredentialSchema, +) + + +class CredentialStatusOptionsSchema(Schema): + """Linked data proof credential status options schema.""" + + class Meta: + """Accept parameter overload.""" + + unknown = INCLUDE + + type = fields.Str( + required=True, + description="Credential status method type to use for the credential. Should match status method registered in the Verifiable Credential Extension Registry", + example="CredentialStatusList2017", + ) + + @post_dump + def remove_none_values(self, data, **kwargs): + return {key: value for key, value in data.items() if value} + + +class LDProofVCDetailOptionsSchema(Schema): + """Linked data proof verifiable credential options schema.""" + + class Meta: + """Accept parameter overload.""" + + unknown = INCLUDE + + proof_type = fields.Str( + data_key="proofType", + required=True, + description="The proof type used for the proof. Should match suites registered in the Linked Data Cryptographic Suite Registry", + example="Ed25519Signature2018", + ) + + proof_purpose = fields.Str( + data_key="proofPurpose", + required=False, + description="The proof purpose used for the proof. Should match proof purposes registered in the Linked Data Proofs Specification", + example="assertionMethod", + ) + + created = fields.Str( + required=False, + description="The date and time of the proof (with a maximum accuracy in seconds). Defaults to current system time", + **INDY_ISO8601_DATETIME, + ) + + domain = fields.Str( + required=False, + description="The intended domain of validity for the proof", + example="example.com", + ) + + challenge = fields.Str( + required=False, + description="A challenge to include in the proof. SHOULD be provided by the requesting party of the credential (=holder)", + example=UUIDFour.EXAMPLE, + ) + + credential_status = fields.Nested( + CredentialStatusOptionsSchema(), + data_key="credentialStatus", + required=False, + description="The credential status mechanism to use for the credential. Omitting the property indicates the issued credential will not include a credential status", + ) + + @post_load + def make_ld_proof_detail_options(self, data, **kwargs): + from .cred_detail import LDProofVCDetailOptions + + return LDProofVCDetailOptions(**data) + + @post_dump + def remove_none_values(self, data, **kwargs): + return {key: value for key, value in data.items() if value} + + +class LDProofVCDetailSchema(Schema): + """Linked data proof verifiable credential detail schema.""" + + class Meta: + """Accept parameter overload.""" + + unknown = INCLUDE + + credential = fields.Nested( + CredentialSchema(), + required=True, + description="Detail of the JSON-LD Credential to be issued", + ) + + options = fields.Nested( + LDProofVCDetailOptionsSchema(), + required=True, + description="Options for specifying how the linked data proof is created.", + ) + + @post_load + def make_ld_proof_detail(self, data, **kwargs): + from .cred_detail import LDProofVCDetail + + return LDProofVCDetail(**data) + + @post_dump + def remove_none_values(self, data, **kwargs): + return {key: value for key, value in data.items() if value} diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py index b8b2a355d3..913d50710f 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py @@ -53,13 +53,13 @@ async def handle(self, context: RequestContext, responder: BaseResponder): # If auto_issue is enabled, respond immediately if cred_ex_record.auto_issue: # TODO: can only auto_issue for indy if cred proposal is present - # if ( - # cred_ex_record.cred_proposal - # and V20CredProposal.deserialize( - # cred_ex_record.cred_proposal - # ).credential_preview - # ): - if True: + if ( + cred_ex_record.cred_proposal + or cred_ex_record.cred_request + # and (V20CredProposal.deserialize( + # cred_ex_record.cred_proposal + # ).credential_preview) + ): ( cred_ex_record, cred_issue_message, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py index 1da205dc93..67fc222c33 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py @@ -384,6 +384,9 @@ async def receive_request( "preserve_exchange_records" ), trace=(cred_request_message._trace is not None), + auto_issue=self._profile.settings.get( + "debug.auto_respond_credential_request" + ), ) for cred_format in cred_request_message.formats: diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py index 308862fd2d..8047b2a4ee 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py @@ -46,7 +46,7 @@ from .models.cred_ex_record import V20CredExRecord, V20CredExRecordSchema from .models.detail.ld_proof import V20CredExRecordLDProofSchema from .models.detail.indy import V20CredExRecordIndySchema -from .formats.ld_proof.models.cred_detail import LDProofVCDetail +from .formats.ld_proof.models.cred_detail_schema import LDProofVCDetailSchema class V20IssueCredentialModuleResponseSchema(OpenAPISchema): @@ -158,7 +158,7 @@ class V20CredFilterSchema(OpenAPISchema): description="Credential filter for indy", ) ld_proof = fields.Nested( - LDProofVCDetail, + LDProofVCDetailSchema, required=False, description="Credential filter for linked data proof", ) @@ -209,10 +209,50 @@ class V20IssueCredSchemaCore(AdminAPIMessageTracingSchema): ) +class V20CredFilterLDProofSchema(OpenAPISchema): + """Credential filtration criteria.""" + + ld_proof = fields.Nested( + LDProofVCDetailSchema, + required=True, + description="Credential filter for linked data proof", + ) + + +class V20CredRequestFreeSchema(AdminAPIMessageTracingSchema): + """Filter, auto-remove, comment, trace.""" + + connection_id = fields.UUID( + description="Connection identifier", + required=True, + example=UUIDFour.EXAMPLE, # typically but not necessarily a UUID4 + ) + filter_ = fields.Nested( + V20CredFilterLDProofSchema, + required=True, + data_key="filter", + description="Credential specification criteria by format", + ) + auto_remove = fields.Bool( + description=( + "Whether to remove the credential exchange record on completion " + "(overrides --preserve-exchange-records configuration setting)" + ), + required=False, + ) + comment = fields.Str( + description="Human-readable comment", required=False, allow_none=True + ) + trace = fields.Bool( + description="Whether to trace event (default false)", + required=False, + example=False, + ) + + class V20CredCreateSchema(V20IssueCredSchemaCore): """Request schema for creating a credential from attr values.""" - # TODO: validate that credential preview is present when indy format is present? credential_preview = fields.Nested(V20CredPreviewSchema, required=False) @@ -247,7 +287,6 @@ class V20CredOfferRequestSchema(V20IssueCredSchemaCore): ), required=False, ) - # TODO: validate that credential preview is present when indy format is present? credential_preview = fields.Nested(V20CredPreviewSchema, required=False) @@ -428,16 +467,19 @@ async def credential_exchange_create(request: web.BaseRequest): comment = body.get("comment") preview_spec = body.get("credential_preview") - if not preview_spec: - raise web.HTTPBadRequest(reason="Missing credential_preview") - auto_remove = body.get("auto_remove") filt_spec = body.get("filter") + if V20CredFormat.Format.INDY.api in filt_spec and not preview_spec: + raise web.HTTPBadRequest(reason="Missing credential_preview for indy filter") + auto_remove = body.get("auto_remove") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") trace_msg = body.get("trace") try: - cred_preview = V20CredPreview.deserialize(preview_spec) + # Not all formats use credential preview + cred_preview = ( + V20CredPreview.deserialize(preview_spec) if preview_spec else None + ) cred_proposal = V20CredProposal( comment=comment, credential_preview=cred_preview, @@ -965,12 +1007,10 @@ async def credential_exchange_send_bound_offer(request: web.BaseRequest): @docs( tags=["issue-credential v2.0"], - summary="Send issuer a credential request", + summary="Send issuer a credential request not bound to an existing thread. Indy credentials cannot start at a request", ) -@request_schema(V20CredProposalRequestPreviewOptSchema()) +@request_schema(V20CredRequestFreeSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") -# TODO: remove indy from OpenAPI example (to avoid confusion) -# TODO: remove credential preview from OpenAPI schema and example (to avoid confusion) async def credential_exchange_send_free_request(request: web.BaseRequest): """ Request handler for sending free credential request. @@ -994,11 +1034,6 @@ async def credential_exchange_send_free_request(request: web.BaseRequest): filt_spec = body.get("filter") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") - # Indy cannot start from request - if V20CredFormat.Format.INDY.api in filt_spec: - raise web.HTTPBadRequest( - reason="Indy credential exchange cannot start with request" - ) auto_remove = body.get("auto_remove") trace_msg = body.get("trace") diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py index bfeaf58360..ca78b32584 100644 --- a/aries_cloudagent/vc/ld_proofs/ProofSet.py +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -3,6 +3,8 @@ from typing import List from pyld.jsonld import JsonLdProcessor +from .error import LinkedDataProofException +from .validation_result import DocumentVerificationResult, ProofResult from .constants import SECURITY_V2_URL from .document_loader import DocumentLoader from .purposes.ProofPurpose import ProofPurpose @@ -18,20 +20,19 @@ async def add( purpose: ProofPurpose, document_loader: DocumentLoader ) -> dict: - document = document.copy() + """Add a proof to the document.""" - if "proof" in document: - del document["proof"] + input = document.copy() + input.pop("proof", None) proof = await suite.create_proof( - document=document, purpose=purpose, document_loader=document_loader + document=input, purpose=purpose, document_loader=document_loader ) - if "@context" in proof: - del proof["@context"] + # remove context from proof, if it exists + proof.pop("@context", None) JsonLdProcessor.add_value(document, "proof", proof) - return document @staticmethod @@ -41,55 +42,59 @@ async def verify( suites: List[LinkedDataProof], purpose: ProofPurpose, document_loader: DocumentLoader - ) -> dict: + ) -> DocumentVerificationResult: + """Verify proof on the document""" try: document = document.copy() - proofs = await ProofSet._get_proofs(document=document) + # Get proof set, remove proof from document + proof_set = await ProofSet._get_proofs(document=document) + document.pop("proof", None) results = await ProofSet._verify( document=document, suites=suites, - proof_set=proofs.get("proof_set"), + proof_set=proof_set, purpose=purpose, document_loader=document_loader, ) if len(results) == 0: - raise Exception( + raise LinkedDataProofException( "Could not verify any proofs; no proofs matched the required suite and purpose" ) - verified = any(result.get("verified") for result in results) + # check if all results are valid, create result + verified = any(result.verified for result in results) + result = DocumentVerificationResult(verified=verified, results=results) + # If not valid, extract and optionally add errors to result if not verified: - errors = [ - result.get("error") for result in results if result.get("error") - ] - result = {"verified": verified, "results": results} + errors = [result.error for result in results if result.error] if len(errors) > 0: - result["error"] = errors + result.errors = errors - return result - - return {"verified": verified, "results": results} + return result except Exception as e: - return {"verified": verified, "error": e} + return DocumentVerificationResult(verified=False, errors=[e]) @staticmethod - async def _get_proofs(document: dict) -> dict: + async def _get_proofs(document: dict) -> list: + "Get proof set from document" "" proof_set = JsonLdProcessor.get_values(document, "proof") - if "proof" in document: - del document["proof"] - if len(proof_set) == 0: - raise Exception("No matching proofs found in the given document") + raise LinkedDataProofException( + "No matching proofs found in the given document" + ) + # TODO: digitalbazaar changed this to use the document context + # in jsonld-signatures. Does that mean we need to provide this + # ourselves? proof_set = [{"@context": SECURITY_V2_URL, **proof} for proof in proof_set] - return {"proof_set": proof_set, "document": document} + return proof_set @staticmethod async def _verify( @@ -98,7 +103,14 @@ async def _verify( proof_set: List[dict], purpose: ProofPurpose, document_loader: DocumentLoader, - ) -> List[dict]: + ) -> List[ProofResult]: + """Verify proofs in proof set. + + Returns results for proofs that match both on purpose and have a suite + in the suites lists. This means proofs that don't match on any of these + WILL NOT be verified OR included in the proof result list. + """ + # Matches proof purposes proof set to passed purpose. # Only proofs with a `proofPurpose` that match the purpose are verified # e.g.: @@ -121,6 +133,8 @@ async def _verify( purpose=purpose, document_loader=document_loader, ) - results.append({"proof": proof, **result}) + result.proof = proof + + results.append(result) return results diff --git a/aries_cloudagent/vc/ld_proofs/VerificationException.py b/aries_cloudagent/vc/ld_proofs/VerificationException.py deleted file mode 100644 index 9ab497aeba..0000000000 --- a/aries_cloudagent/vc/ld_proofs/VerificationException.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Union, List - - -class VerificationException(Exception): - """Raised when verification verification fails.""" - - def __init__(self, errors: Union[Exception, List[Exception]]): - self.errors = errors if isinstance(errors, List) else [errors] - super().__init__() diff --git a/aries_cloudagent/vc/ld_proofs/__init__.py b/aries_cloudagent/vc/ld_proofs/__init__.py index 68e30b33aa..73b6d61af5 100644 --- a/aries_cloudagent/vc/ld_proofs/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/__init__.py @@ -14,23 +14,30 @@ Ed25519Signature2018, ) from .crypto import KeyPair, Ed25519WalletKeyPair -from .document_loader import DocumentLoader, did_key_document_loader +from .document_loader import DocumentLoader, get_default_document_loader +from .error import LinkedDataProofException __all__ = [ sign, verify, ProofSet, + # Proof purposes ProofPurpose, ControllerProofPurpose, AssertionProofPurpose, AuthenticationProofPurpose, CredentialIssuancePurpose, + # Suites LinkedDataProof, LinkedDataSignature, JwsLinkedDataSignature, Ed25519Signature2018, + # Key pairs KeyPair, Ed25519WalletKeyPair, + # Document Loaders DocumentLoader, - did_key_document_loader, + get_default_document_loader, + # Exceptions + LinkedDataProofException, ] diff --git a/aries_cloudagent/vc/ld_proofs/constants.py b/aries_cloudagent/vc/ld_proofs/constants.py index fc774a984b..3be4381eed 100644 --- a/aries_cloudagent/vc/ld_proofs/constants.py +++ b/aries_cloudagent/vc/ld_proofs/constants.py @@ -1,8 +1,11 @@ +"""JSON-LD, Linked Data Proof and Verifiable Credential constants""" + SECURITY_V1_URL = "https://w3id.org/security/v1" SECURITY_V2_URL = "https://w3id.org/security/v2" DID_V1_URL = "https://w3id.org/did/v1" CREDENTIALS_ISSUER_URL = "https://www.w3.org/2018/credentials#issuer" CREDENTIALS_V1_URL = "https://www.w3.org/2018/credentials/v1" +VERIFIABLE_CREDENTIAL_TYPE = "VerifiableCredential" SECURITY_PROOF_URL = "https://w3id.org/security#proof" SECURITY_SIGNATURE_URL = "https://w3id.org/security#signature" SECURITY_BBS_URL = "https://w3id.org/security/bbs/v1" diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py index a506e59d6f..ca08033398 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py @@ -1,26 +1,57 @@ +"""Ed25519 key pair based on base wallet interface""" + +from typing import Optional + from ....wallet.base import BaseWallet -from ....did.did_key import DIDKey -from ....wallet.crypto import KeyType -from .KeyPair import KeyPair +from ..error import LinkedDataProofException +from .WalletKeyPair import WalletKeyPair -class Ed25519WalletKeyPair(KeyPair): - def __init__(self, wallet: BaseWallet, public_key_base58: str): - self.wallet = wallet - self.public_key_base58 = public_key_base58 +class Ed25519WalletKeyPair(WalletKeyPair): + """Ed25519 wallet key pair""" - def fingerprint(self) -> str: - return DIDKey.from_public_key_b58( - self.public_key_base58, KeyType.ED25519 - ).fingerprint + def __init__(self, *, wallet: BaseWallet, public_key_base58: Optional[str] = None): + """Initialize new Ed25519WalletKeyPair instance.""" + super().__init__(wallet=wallet) + + self.public_key_base58 = public_key_base58 async def sign(self, message: bytes) -> bytes: + """Sign message using Ed25519 key""" + if not self.public_key_base58: + raise LinkedDataProofException( + "Unable to sign message with Ed25519WalletKeyPair: No key to sign with" + ) return await self.wallet.sign_message( message, self.public_key_base58, ) async def verify(self, message: bytes, signature: bytes) -> bool: + """Verify message against signature using Ed25519 key""" + if not self.public_key_base58: + raise LinkedDataProofException( + "Unable to verify message with Ed25519WalletKeyPair: No key to sign verify with" + ) + return await self.wallet.verify_message( message, signature, self.public_key_base58 ) + + def from_verification_method( + self, verification_method: dict + ) -> "Ed25519WalletKeyPair": + """Create new Ed25519WalletKeyPair from public key in verification method""" + if "publicKeyBase58" not in verification_method: + raise LinkedDataProofException( + "Cannot set public key from verification method: publicKeyBase58 not found" + ) + + return Ed25519WalletKeyPair( + wallet=self.wallet, public_key_base58=verification_method["publicKeyBase58"] + ) + + @property + def has_public_key(self) -> bool: + """Whether key pair has public key""" + return self.public_key_base58 != None diff --git a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py index 8f55538177..6d702542b6 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py @@ -1,11 +1,27 @@ -from abc import ABC, abstractmethod +"""Base key pair class""" + +from abc import ABC, abstractmethod, abstractproperty class KeyPair(ABC): + """Base key pair class.""" + @abstractmethod async def sign(self, message: bytes) -> bytes: - pass + """Sign message using key pair""" @abstractmethod async def verify(self, message: bytes, signature: bytes) -> bool: - pass + """Verify message against signature using key pair""" + + @abstractproperty + def has_public_key(self) -> bool: + """Whether key pair has a public key. + + Public key is required for verification, but can be set dynamically + in the verification process. + """ + + @abstractmethod + def from_verification_method(self, verification_method: dict) -> "KeyPair": + """Create new key pair class based on the passed verification method.""" \ No newline at end of file diff --git a/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py new file mode 100644 index 0000000000..f1e7e1847f --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py @@ -0,0 +1,12 @@ +"""Key pair based on base wallet interface""" + +from ....wallet.base import BaseWallet +from .KeyPair import KeyPair + + +class WalletKeyPair(KeyPair): + """Base wallet key pair""" + + def __init__(self, *, wallet: BaseWallet) -> None: + """Initialize new WalletKeyPair instance.""" + self.wallet = wallet \ No newline at end of file diff --git a/aries_cloudagent/vc/ld_proofs/document_loader.py b/aries_cloudagent/vc/ld_proofs/document_loader.py index aace6bf01b..47631716e4 100644 --- a/aries_cloudagent/vc/ld_proofs/document_loader.py +++ b/aries_cloudagent/vc/ld_proofs/document_loader.py @@ -1,30 +1,39 @@ +"""JSON-LD document loader methods""" + from pyld.documentloader import requests +from typing import Callable +from ...core.profile import Profile from ...did.did_key import DIDKey +from .error import LinkedDataProofException -from typing import Callable +def get_default_document_loader(profile: Profile) -> "DocumentLoader": + """Return the default document loader""" + + def default_document_loader(url: str, options: dict): + """Default document loader implementation""" + # TODO: integrate with did resolver interface + if url.startswith("did:key:"): + did_key = DIDKey.from_did(url) -def did_key_document_loader(url: str, options: dict): - # TODO: integrate with did resolver interface - if url.startswith("did:key:"): - did_key = DIDKey.from_did(url) + return { + "contentType": "application/ld+json", + "contextUrl": None, + "documentUrl": url, + "document": did_key.did_doc, + } + elif url.startswith("http://") or url.startswith("https://"): + loader = requests.requests_document_loader() + return loader(url, options) + else: + raise LinkedDataProofException( + "Unrecognized url format. Must start with 'did:key:', 'http://' or 'https://'" + ) - return { - "contentType": "application/ld+json", - "contextUrl": None, - "documentUrl": url, - "document": did_key.did_doc, - } - elif url.startswith("http://") or url.startswith("https://"): - loader = requests.requests_document_loader() - return loader(url, options) - else: - raise Exception( - "Unrecognized url format. Must start with 'did:key:', 'http://' or 'https://'" - ) + return default_document_loader DocumentLoader = Callable[[str, dict], dict] -__all__ = [DocumentLoader, did_key_document_loader] +__all__ = [DocumentLoader, get_default_document_loader] diff --git a/aries_cloudagent/vc/ld_proofs/error.py b/aries_cloudagent/vc/ld_proofs/error.py new file mode 100644 index 0000000000..5a19b6228e --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/error.py @@ -0,0 +1,5 @@ +"""Linked data proof exception classes""" + + +class LinkedDataProofException(Exception): + "Base exception for linked data proof module." \ No newline at end of file diff --git a/aries_cloudagent/vc/ld_proofs/ld_proofs.py b/aries_cloudagent/vc/ld_proofs/ld_proofs.py index 76db7b6fd3..764e79f9b2 100644 --- a/aries_cloudagent/vc/ld_proofs/ld_proofs.py +++ b/aries_cloudagent/vc/ld_proofs/ld_proofs.py @@ -3,17 +3,17 @@ from typing import List from pyld.jsonld import JsonLdError +from .validation_result import DocumentVerificationResult from .document_loader import DocumentLoader from .ProofSet import ProofSet from .purposes import ProofPurpose from .suites import LinkedDataProof -from .VerificationException import VerificationException +from .error import LinkedDataProofException async def sign( *, document: dict, - # TODO: support multiple signature suites suite: LinkedDataProof, purpose: ProofPurpose, document_loader: DocumentLoader, @@ -31,7 +31,7 @@ async def sign( document_loader (DocumentLoader): The document loader to use. Raises: - Exception: When a jsonld url cannot be resolved, OR signing fails. + LinkedDataProofException: When a jsonld url cannot be resolved, OR signing fails. Returns: dict: Signed document. """ @@ -45,7 +45,7 @@ async def sign( except JsonLdError as e: if e.type == "jsonld.InvalidUrl": - raise Exception( + raise LinkedDataProofException( f'A URL "{e.details}" could not be fetched; you need to pass a DocumentLoader function that can resolve this URL, or resolve the URL before calling "sign".' ) raise e @@ -57,7 +57,7 @@ async def verify( suites: List[LinkedDataProof], purpose: ProofPurpose, document_loader: DocumentLoader, -) -> dict: +) -> DocumentVerificationResult: """Verifies the linked data signature on the provided document. Args: @@ -69,10 +69,10 @@ async def verify( document_loader (DocumentLoader): The document loader to use. Returns: - dict: Dict with a `verified` boolean property that is `True` if at least one + DocumentVerificationResult: Object with a `verified` boolean property that is `True` if at least one proof matching the given purpose and suite verifies and `False` otherwise. a `results` property with an array of detailed results. - if `False` an `error` property will be present, with `error.errors` + if `False` an `errors` property will be present, with a list containing all of the errors that occurred during the verification process. """ @@ -83,15 +83,4 @@ async def verify( document_loader=document_loader, ) - if result.get("error"): - # TODO: is this necessary? Seems like it is vc-js specific - # TODO: error returns list, not object with type?? - # if result.get("error", {}).get("type") == "jsonld.InvalidUrl": - # url_err = Exception( - # f'A URL "{result.get("error").get("details")}" could not be fetched; you need to pass a DocumentLoader function that can resolve this URL, or resolve the URL before calling "sign".' - # ) - # result["error"] = VerificationException(url_err) - # else: - result["error"] = VerificationException(result.get("error")) - return result diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py index 1d4a0a7146..d5e59606e8 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py @@ -1,10 +1,15 @@ +"""Assertion proof purpose class""" + from datetime import datetime, timedelta from .ControllerProofPurpose import ControllerProofPurpose class AssertionProofPurpose(ControllerProofPurpose): - def __init__(self, date: datetime = None, max_timestamp_delta: timedelta = None): + """Assertion proof purpose class""" + + def __init__(self, *, date: datetime = None, max_timestamp_delta: timedelta = None): + """Initialize new instance of AssertionProofPurpose.""" super().__init__( term="assertionMethod", date=date, max_timestamp_delta=max_timestamp_delta ) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py index b60783dae1..a253714eac 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py @@ -1,19 +1,26 @@ +"""Authentication proof purpose class""" + from datetime import datetime, timedelta -from typing import Awaitable +from ..error import LinkedDataProofException +from ..validation_result import PurposeResult from ..document_loader import DocumentLoader from ..suites import LinkedDataProof from .ControllerProofPurpose import ControllerProofPurpose class AuthenticationProofPurpose(ControllerProofPurpose): + """Authentication proof purpose.""" + def __init__( self, + *, challenge: str, domain: str = None, date: datetime = None, max_timestamp_delta: timedelta = None, ): + """Initialize new AuthenticationProofPurpose instance.""" super().__init__( term="authentication", date=date, max_timestamp_delta=max_timestamp_delta ) @@ -23,20 +30,22 @@ def __init__( def validate( self, + *, proof: dict, document: dict, suite: LinkedDataProof, verification_method: dict, document_loader: DocumentLoader, - ) -> dict: + ) -> PurposeResult: + """Validate whether challenge and domain are valid""" try: if proof.get("challenge") != self.challenge: - raise Exception( - f'The challenge is not expected; challenge={proof.get("challenge")}, expected={self.challenge}' + raise LinkedDataProofException( + f'The challenge is not as expected; challenge={proof.get("challenge")}, expected={self.challenge}' ) if self.domain and (proof.get("domain") != self.domain): - raise Exception( + raise LinkedDataProofException( f'The domain is not as expected; domain={proof.get("domain")}, expected={self.domain}' ) @@ -48,9 +57,10 @@ def validate( document_loader=document_loader, ) except Exception as e: - return {"valid": False, "error": e} + PurposeResult(valid=False, error=e) - async def update(self, proof: dict) -> Awaitable[dict]: + def update(self, proof: dict) -> dict: + """Update poof purpose, challenge and domain on proof""" proof = super().update(proof) proof["challenge"] = self.challenge diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py index 3bb5caca43..85453285b4 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py @@ -1,7 +1,10 @@ -from datetime import datetime, timedelta +"""Controller proof purpose class""" + from pyld.jsonld import JsonLdProcessor from pyld import jsonld +from ..error import LinkedDataProofException +from ..validation_result import PurposeResult from ..constants import SECURITY_V2_URL from ..suites import LinkedDataProof from ..document_loader import DocumentLoader @@ -9,19 +12,18 @@ class ControllerProofPurpose(ProofPurpose): - def __init__( - self, term: str, date: datetime = None, max_timestamp_delta: timedelta = None - ): - super().__init__(term=term, date=date, max_timestamp_delta=max_timestamp_delta) + """Controller proof purpose class.""" def validate( self, + *, proof: dict, document: dict, suite: LinkedDataProof, verification_method: dict, document_loader: DocumentLoader, - ) -> dict: + ) -> PurposeResult: + """Validate whether verification method of proof is authorized by controller.""" try: result = super().validate( proof=proof, @@ -31,8 +33,9 @@ def validate( document_loader=document_loader, ) - if not result.get("valid"): - raise result.get("error") + # Return early if super check was invalid + if not result.valid: + return result verification_id = verification_method.get("id") controller = verification_method.get("controller") @@ -42,9 +45,10 @@ def validate( elif isinstance(controller, str): controller_id = controller else: - raise Exception('"controller" must be a string or dict') + raise LinkedDataProofException('"controller" must be a string or dict') - framed = jsonld.frame( + # Get the controller + result.controller = jsonld.frame( controller_id, frame={ "@context": SECURITY_V2_URL, @@ -61,21 +65,20 @@ def validate( }, ) - result["controller"] = framed + # Retrieve al verification methods on controller associated with term + verification_methods = JsonLdProcessor.get_values(controller, self.term) - verification_methods = JsonLdProcessor.get_values( - result.get("controller"), self.term - ) - result["valid"] = any( + # Check if any of the verification methods matches with the verification id + result.valid = any( method == verification_id for method in verification_methods ) - if not result.get("valid"): - raise Exception( + if not result.valid: + raise LinkedDataProofException( f"Verification method {verification_id} not authorized by controller for proof purpose {self.term}" ) return result except Exception as e: - return {"valid": False, "error": e} + return PurposeResult(valid=False, error=e) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py index 7371d3174d..73f7d6d057 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py @@ -1,7 +1,11 @@ -from datetime import datetime, timedelta +"""Credential Issuance proof purpose class""" + +from typing import List from pyld.jsonld import JsonLdProcessor from pyld import jsonld +from ..error import LinkedDataProofException +from ..validation_result import PurposeResult from ..suites import LinkedDataProof from ..document_loader import DocumentLoader from ..constants import CREDENTIALS_ISSUER_URL @@ -9,17 +13,18 @@ class CredentialIssuancePurpose(AssertionProofPurpose): - def __init__(self, date: datetime = None, max_timestamp_delta: timedelta = None): - super().__init__(date=date, max_timestamp_delta=max_timestamp_delta) + """Credential Issuance proof purpose.""" def validate( self, + *, proof: dict, document: dict, suite: LinkedDataProof, verification_method: dict, document_loader: DocumentLoader, - ): + ) -> PurposeResult: + """Checks whether the issuer matches the controller of the verification method.""" try: result = super().validate( proof=proof, @@ -29,31 +34,33 @@ def validate( document_loader=document_loader, ) - if not result.get("valid"): - raise result.get("error") + # Return early if super check was invalid + if not result.valid: + return result - # TODO: move expansion to better place. But required for querying issuer - expanded = jsonld.expand( + # FIXME: Other implementations don't expand, but + # if we don't expand we can't get the property using + # the full CREDENTIALS_ISSUER_URL. + [expanded] = jsonld.expand( document, { "documentLoader": document_loader, }, ) - # TODO: what if array has no values? - issuer: list = JsonLdProcessor.get_values( - expanded[0], CREDENTIALS_ISSUER_URL + + issuer: List[dict] = JsonLdProcessor.get_values( + expanded, CREDENTIALS_ISSUER_URL ) - if not issuer or len(issuer) == 0: - raise Exception("Credential issuer is required.") + if len(issuer) == 0: + raise LinkedDataProofException("Credential issuer is required.") # TODO: we're mixing expanded and not-expanded here. Confusing - if result.get("controller", {}).get("id") != issuer[0].get("@id"): - raise Exception( + if result.controller.get("id") != issuer[0].get("@id"): + raise LinkedDataProofException( "Credential issuer must match the verification method controller." ) - return {"valid": True} - + return result except Exception as e: - return {"valid": False, "error": e} + return PurposeResult(valid=False, error=e) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py index add8a1e455..f15ad9ecc5 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py @@ -1,29 +1,39 @@ +"""Base Proof Purpose class""" + from datetime import datetime, timedelta +from ....messaging.util import str_to_datetime +from ..validation_result import PurposeResult from ..document_loader import DocumentLoader from ..suites import LinkedDataProof class ProofPurpose: + """Base proof purpose class""" + def __init__( - self, term: str, date: datetime = None, max_timestamp_delta: timedelta = None + self, *, term: str, date: datetime = None, max_timestamp_delta: timedelta = None ): + """Initialize new proof purpose instance""" self.term = term self.date = date or datetime.now() self.max_timestamp_delta = max_timestamp_delta def validate( self, + *, proof: dict, document: dict, suite: LinkedDataProof, verification_method: dict, document_loader: DocumentLoader, - ) -> dict: + ) -> PurposeResult: + """Validate whether created date of proof is out of max_timestamp_delta range.""" try: if self.max_timestamp_delta is not None: expected = self.date.time() - created = datetime.strptime(proof.get("created"), "%Y-%m-%dT%H:%M:%SZ") + + created = str_to_datetime(proof.get("created")) if not ( created >= (expected - self.max_timestamp_delta) @@ -31,13 +41,15 @@ def validate( ): raise Exception("The proof's created timestamp is out of range.") - return {"valid": True} + return PurposeResult(valid=True) except Exception as err: - return {"valid": False, "error": err} + return PurposeResult(valid=False, error=err) def update(self, proof: dict) -> dict: + """Update proof purpose on proof""" proof["proofPurpose"] = self.term return proof def match(self, proof: dict) -> bool: + """Check whether the passed proof matches with the term of this proof purpose""" return proof.get("proofPurpose") == self.term diff --git a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py index b2e07ad9ed..49d81e452a 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py +++ b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py @@ -1,3 +1,5 @@ +"""Ed25519Signature2018 suite""" + from datetime import datetime from typing import Union @@ -6,13 +8,16 @@ class Ed25519Signature2018(JwsLinkedDataSignature): + """Ed25519Signature2018 suite""" + def __init__( self, - verification_method: str, key_pair: Ed25519WalletKeyPair, + verification_method: str = None, proof: dict = None, date: Union[datetime, str] = None, ): + """Create new Ed25519Signature2018 instance""" super().__init__( signature_type="Ed25519Signature2018", algorithm="EdDSA", diff --git a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py index 7d486892c0..bce2c838ba 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py @@ -1,3 +1,5 @@ +"""JWS Linked Data class""" + from pyld.jsonld import JsonLdProcessor from datetime import datetime from typing import Union @@ -6,10 +8,13 @@ from ....wallet.util import b64_to_bytes, bytes_to_b64, str_to_b64, b64_to_str from ..crypto import KeyPair from ..document_loader import DocumentLoader +from ..error import LinkedDataProofException from .LinkedDataSignature import LinkedDataSignature class JwsLinkedDataSignature(LinkedDataSignature): + """JWS Linked Data class""" + def __init__( self, *, @@ -17,10 +22,11 @@ def __init__( algorithm: str, required_key_type: str, key_pair: KeyPair, - verification_method: dict, + verification_method: str = None, proof: dict = None, date: Union[datetime, str] = None, ): + """Create new JwsLinkedDataSignature instance""" super().__init__( signature_type=signature_type, @@ -33,11 +39,24 @@ def __init__( self.key_pair = key_pair self.required_key_type = required_key_type - async def sign(self, verify_data: bytes, proof: dict): + async def sign(self, *, verify_data: bytes, proof: dict) -> dict: + """Sign the data and add it to the proof + + Adds a jws to the proof that can be used for multiple + signature algorithms. + + Args: + verify_data (bytes): The data to sign. + proof (dict): The proof to add the signature to + + Returns: + dict: The proof object with the added signature + """ + header = {"alg": self.algorithm, "b64": False, "crit": ["b64"]} encoded_header = self._encode_header(header) - data = self._create_jws(encoded_header, verify_data) + data = self._create_jws(encoded_header=encoded_header, verify_data=verify_data) signature = await self.key_pair.sign(data) encoded_signature = bytes_to_b64( @@ -50,12 +69,27 @@ async def sign(self, verify_data: bytes, proof: dict): async def verify_signature( self, + *, verify_data: bytes, verification_method: dict, document: dict, proof: dict, document_loader: DocumentLoader, ): + """Verify the data against the proof. + + Checks for a jws on the proof. + + Args: + verify_data (bytes): The data to check + verification_method (dict): The verification method to use. + document (dict): The document the verify data is derived for as extra context + proof (dict): The proof to check + document_loader (DocumentLoader): Document loader used for resolving + + Returns: + bool: Whether the signature is valid for the data + """ if not (isinstance(proof.get("jws"), str) and (".." in proof.get("jws"))): raise Exception('The proof does not contain a valid "jws" property.') @@ -65,29 +99,38 @@ async def verify_signature( self._validate_header(header) signature = b64_to_bytes(encoded_signature, urlsafe=True) - data = self._create_jws(encoded_header, verify_data) + data = self._create_jws(encoded_header=encoded_header, verify_data=verify_data) + + # If the key pair has not public key yet, create a new key pair + # from the verification method. We don't want to overwrite data + # on the original key pair + key_pair = self.key_pair + if not key_pair.has_public_key: + key_pair = key_pair.from_verification_method(verification_method) - return await self.key_pair.verify(data, signature) + return await key_pair.verify(data, signature) def _decode_header(self, encoded_header: str) -> dict: + """Decode header""" header = None try: header = json.loads(b64_to_str(encoded_header, urlsafe=True)) except Exception: - raise Exception("Could not parse JWS header.") + raise LinkedDataProofException("Could not parse JWS header.") return header def _encode_header(self, header: dict) -> str: + """Encode header""" return str_to_b64(json.dumps(header), urlsafe=True, pad=False) - def _create_jws(self, encoded_header: str, verify_data: bytes) -> bytes: + def _create_jws(self, *, encoded_header: str, verify_data: bytes) -> bytes: """Compose JWS.""" return (encoded_header + ".").encode("utf-8") + verify_data def _validate_header(self, header: dict): """ Validates the JWS header, throws if not ok """ if not (header and isinstance(header, dict)): - raise Exception("Invalid JWS header.") + raise LinkedDataProofException("Invalid JWS header.") if not ( header.get("alg") == self.algorithm @@ -97,17 +140,24 @@ def _validate_header(self, header: dict): and header.get("crit")[0] == "b64" and len(header.keys()) == 3 ): - raise Exception(f"Invalid JWS header params for {self.signature_type}") + raise LinkedDataProofException( + f"Invalid JWS header params for {self.signature_type}" + ) def _assert_verification_method(self, verification_method: dict): + """Assert verification method. Throws if not ok""" if not JsonLdProcessor.has_value( verification_method, "type", self.required_key_type ): - raise Exception( + raise LinkedDataProofException( f"Invalid key type. The key type must be {self.required_key_type}" ) - def _get_verification_method(self, proof: dict, document_loader: DocumentLoader): - verification_method = super()._get_verification_method(proof, document_loader) + def _get_verification_method(self, *, proof: dict, document_loader: DocumentLoader): + """Get verification method""" + verification_method = super()._get_verification_method( + proof=proof, document_loader=document_loader + ) self._assert_verification_method(verification_method) + return verification_method diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py index 6086c1842e..6626be4d89 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py @@ -4,6 +4,7 @@ from abc import ABCMeta, abstractmethod from ..document_loader import DocumentLoader +from ..validation_result import ProofResult # ProofPurpose and LinkedDataProof depend on each other if TYPE_CHECKING: @@ -11,24 +12,53 @@ class LinkedDataProof(metaclass=ABCMeta): - def __init__(self, signature_type: str): + """Base Linked data proof""" + + def __init__(self, *, signature_type: str, proof: dict = None): + """Initialize new LinkedDataProof instance""" self.signature_type = signature_type + self.proof = proof @abstractmethod async def create_proof( - self, document: dict, purpose: "ProofPurpose", document_loader: DocumentLoader + self, + *, + document: dict, + purpose: "ProofPurpose", + document_loader: DocumentLoader, ) -> dict: - pass + """Create proof for document + + Args: + document (dict): The document to create the proof for + purpose (ProofPurpose): The proof purpose to include in the proof + document_loader (DocumentLoader): Document loader used for resolving + + Returns: + dict: The proof object + """ @abstractmethod async def verify_proof( self, + *, proof: dict, document: dict, purpose: "ProofPurpose", document_loader: DocumentLoader, - ) -> dict: - pass + ) -> ProofResult: + """Verify proof against document and proof purpose. + + Args: + proof (dict): The proof to verify + document (dict): The document to verify the proof against + purpose (ProofPurpose): The proof purpose to verify the proof against + document_loader (DocumentLoader): Document loader used for resolving + + Returns: + ValidationResult: The results of the proof verification + """ def match_proof(self, signature_type: str) -> bool: + """Match signature type to signature type of this suite""" return signature_type == self.signature_type diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py index 52917fdd8c..cce76f9d48 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -1,10 +1,13 @@ -import traceback +"""Linked Data Signature class""" + from pyld import jsonld from datetime import datetime from hashlib import sha256 from typing import Union from abc import abstractmethod, ABCMeta +from ..error import LinkedDataProofException +from ..validation_result import ProofResult from ..document_loader import DocumentLoader from ..purposes import ProofPurpose from ..constants import SECURITY_V2_URL @@ -12,55 +15,75 @@ class LinkedDataSignature(LinkedDataProof, metaclass=ABCMeta): + """Linked Data Signature class""" + def __init__( self, + *, signature_type: str, - verification_method: str, proof: dict = None, - date: Union[datetime, str, None] = None, + verification_method: str = None, + date: Union[str, None] = None, ): - super().__init__(signature_type=signature_type) + """Create new LinkedDataSignature instance""" + super().__init__(signature_type=signature_type, proof=proof) self.verification_method = verification_method - self.proof = proof self.date = date - if isinstance(date, str): - # cast date to datetime if str - self.date = datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ") + @abstractmethod + async def sign(self, *, verify_data: bytes, proof: dict) -> dict: + """Sign the data and add it to the proof - # ABSTRACT METHODS + Args: + verify_data (bytes): The data to sign. + proof (dict): The proof to add the signature to - @abstractmethod - async def sign(self, verify_data: bytes, proof: dict): + Returns: + dict: The proof object with the added signature + """ pass @abstractmethod async def verify_signature( self, + *, verify_data: bytes, verification_method: dict, document: dict, proof: dict, document_loader: DocumentLoader, - ): - pass - - # PUBLIC METHODS + ) -> bool: + """Verify the data against the proof. + + Args: + verify_data (bytes): The data to check + verification_method (dict): The verification method to use. + document (dict): The document the verify data is derived for as extra context + proof (dict): The proof to check + document_loader (DocumentLoader): Document loader used for resolving + + Returns: + bool: Whether the signature is valid for the data + """ async def create_proof( - self, document: dict, purpose: ProofPurpose, document_loader: DocumentLoader + self, *, document: dict, purpose: ProofPurpose, document_loader: DocumentLoader ) -> dict: + """Create proof for document, return proof""" proof = None if self.proof: # TODO remove hardcoded security context # TODO verify if the other optional params shown in jsonld-signatures are # required + # TODO: digitalbazaar changed this implementation after we wrote it. Should + # double check to make sure we're doing it correctly proof = jsonld.compact( self.proof, SECURITY_V2_URL, {"documentLoader": document_loader} ) else: proof = {"@context": SECURITY_V2_URL} + # TODO: validate if verification_method is set? proof["type"] = self.signature_type proof["verificationMethod"] = self.verification_method @@ -70,7 +93,6 @@ async def create_proof( if not proof.get("created"): proof["created"] = self.date.isoformat() - proof = self.update_proof(proof=proof) proof = purpose.update(proof) verify_data = self._create_verify_data( @@ -80,19 +102,15 @@ async def create_proof( proof = await self.sign(verify_data=verify_data, proof=proof) return proof - def update_proof(self, proof: dict): - """ - Extending classes may do more - """ - return proof - async def verify_proof( self, + *, proof: dict, document: dict, purpose: ProofPurpose, document_loader: DocumentLoader, - ) -> dict: + ) -> ProofResult: + """Verify proof against document and proof purpose.""" try: verify_data = self._create_verify_data( proof=proof, document=document, document_loader=document_loader @@ -110,7 +128,9 @@ async def verify_proof( ) if not verified: - raise Exception("Invalid signature") + raise LinkedDataProofException( + f"Invalid signature on document {document}" + ) purpose_result = purpose.validate( proof=proof, @@ -120,20 +140,15 @@ async def verify_proof( document_loader=document_loader, ) - if not purpose_result["valid"]: - raise purpose_result["error"] + if not purpose_result.valid: + raise purpose_result.error - return {"verified": True, "purpose_result": purpose_result} + return ProofResult(verified=True, purpose_result=purpose_result) except Exception as err: - return { - "verified": False, - "error": err, - # TODO: leave trace in error? - "trace": traceback.format_exc(), - } + return ProofResult(verified=False, error=err) def _get_verification_method( - self, proof: dict, document_loader: DocumentLoader + self, *, proof: dict, document_loader: DocumentLoader ) -> dict: verification_method = proof.get("verificationMethod") @@ -161,15 +176,17 @@ def _get_verification_method( ) if not framed: - raise Exception(f"Verification method {verification_method} not found") + raise LinkedDataProofException( + f"Verification method {verification_method} not found" + ) if framed.get("revoked"): - raise Exception("The verification method has been revoked.") + raise LinkedDataProofException("The verification method has been revoked.") return framed def _create_verify_data( - self, proof: dict, document: dict, document_loader: DocumentLoader + self, *, proof: dict, document: dict, document_loader: DocumentLoader ) -> bytes: c14n_proof_options = self._canonize_proof( proof=proof, document_loader=document_loader @@ -183,7 +200,8 @@ def _create_verify_data( + sha256(c14n_doc.encode("utf-8")).digest() ) - def _canonize(self, input, document_loader: DocumentLoader = None) -> str: + def _canonize(self, *, input, document_loader: DocumentLoader = None) -> str: + """Canonize input document using URDNA2015 algorithm""" # application/n-quads format always returns str return jsonld.normalize( input, @@ -194,7 +212,8 @@ def _canonize(self, input, document_loader: DocumentLoader = None) -> str: }, ) - def _canonize_proof(self, proof: dict, document_loader: DocumentLoader = None): + def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None): + """Canonize proof dictionary. Removes jws, signature, etc...""" proof = proof.copy() proof.pop("jws", None) diff --git a/aries_cloudagent/vc/ld_proofs/tests/test_doc.py b/aries_cloudagent/vc/ld_proofs/tests/test_doc.py index 33d70179b8..e9d74f1ac6 100644 --- a/aries_cloudagent/vc/ld_proofs/tests/test_doc.py +++ b/aries_cloudagent/vc/ld_proofs/tests/test_doc.py @@ -1,3 +1,5 @@ +from ..validation_result import DocumentVerificationResult, ProofResult, PurposeResult + DOC_TEMPLATE = { "@context": { "schema": "http://schema.org/", @@ -29,11 +31,12 @@ }, } -DOC_VERIFIED = { - "verified": True, - "results": [ - { - "proof": { +DOC_VERIFIED = DocumentVerificationResult( + verified=True, + results=[ + ProofResult( + verified=True, + proof={ "@context": "https://w3id.org/security/v2", "proofPurpose": "assertionMethod", "created": "2019-12-11T03:50:55", @@ -41,16 +44,19 @@ "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..Q6amIrxGiSbM7Ce6DxlfwLCjVcYyclas8fMxaecspXFUcFW9DAAxKzgHx93FWktnlZjM_biitkMgZdStgvivAQ", }, - "verified": True, - "purpose_result": { - "valid": True, - "controller": { + purpose_result=PurposeResult( + valid=True, + controller={ "@context": "https://w3id.org/security/v2", "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", "assertionMethod": [ "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" ], - "authentication": [ + # FIXME: this should be authentication instead of sec:authenticationMethod + # SEE: https://github.com/w3c/did-spec-registries/issues/235 + # SEE: https://github.com/w3c-ccg/security-vocab/issues/91 + "sec:authenticationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "capabilityDelegation": [ { "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", "type": "Ed25519VerificationKey2018", @@ -58,9 +64,6 @@ "publicKeyBase58": "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx", } ], - "capabilityDelegation": [ - "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" - ], "capabilityInvocation": [ "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" ], @@ -74,7 +77,7 @@ ], "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", }, - }, - } + ), + ) ], -} +) diff --git a/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py b/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py index 8b982004f6..d6f67af0af 100644 --- a/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py +++ b/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py @@ -1,4 +1,5 @@ from asynctest import TestCase +import traceback from datetime import datetime @@ -26,26 +27,24 @@ async def setUp(self): self.profile = InMemoryProfile.test_profile() self.wallet = InMemoryWallet(self.profile) self.key_info = await self.wallet.create_signing_key(self.test_seed) - - self.key_pair = Ed25519WalletKeyPair( - wallet=self.wallet, public_key_base58=self.key_info.verkey - ) self.verification_method = DIDKey.from_public_key_b58( self.key_info.verkey, KeyType.ED25519 ).key_id - self.suite = Ed25519Signature2018( - # TODO: should we provide verification_method here? Or abstract? + + async def test_sign(self): + # Use different key pair and suite for signing and verification + # as during verification a lot of information can be extracted + # from the proof / document + suite = Ed25519Signature2018( verification_method=self.verification_method, key_pair=Ed25519WalletKeyPair( wallet=self.wallet, public_key_base58=self.key_info.verkey ), date=datetime.strptime("2019-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), ) - - async def test_sign(self): signed = await sign( document=DOC_TEMPLATE, - suite=self.suite, + suite=suite, purpose=AssertionProofPurpose(), document_loader=custom_document_loader, ) @@ -53,11 +52,19 @@ async def test_sign(self): assert DOC_SIGNED == signed async def test_verify(self): - verified = await verify( + # Verification requires lot less input parameters + suite = Ed25519Signature2018( + key_pair=Ed25519WalletKeyPair(wallet=self.wallet), + ) + + result = await verify( document=DOC_SIGNED, - suites=[self.suite], + suites=[suite], purpose=AssertionProofPurpose(), document_loader=custom_document_loader, ) - assert DOC_VERIFIED == verified + if not result.verified: + raise result.errors[0] + + assert DOC_VERIFIED == result diff --git a/aries_cloudagent/vc/ld_proofs/validation_result.py b/aries_cloudagent/vc/ld_proofs/validation_result.py new file mode 100644 index 0000000000..f5ec307bc0 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/validation_result.py @@ -0,0 +1,116 @@ +"""Proof verification and validation result classes""" + +from typing import List + + +class PurposeResult: + """Proof purpose result class""" + + def __init__( + self, *, valid: bool, error: Exception = None, controller: dict = None + ) -> None: + """Create new PurposeResult instance""" + self.valid = valid + self.error = error + self.controller = controller + + def __eq__(self, other: object) -> bool: + """Comparison between proof purpose results.""" + if isinstance(other, PurposeResult): + return ( + self.valid == other.valid + and self.error == other.error + and self.controller == other.controller + ) + return False + + +class ProofResult: + """Proof result class""" + + def __init__( + self, + *, + verified: bool, + proof: dict = None, + error: Exception = None, + purpose_result: PurposeResult = None, + ) -> None: + """Create new ProofResult instance""" + self.verified = verified + self.proof = proof + self.error = error + self.purpose_result = purpose_result + + def __eq__(self, other: object) -> bool: + """Comparison between proof results.""" + if isinstance(other, ProofResult): + return ( + self.verified == other.verified + and self.proof == other.proof + and self.error == other.error + and self.purpose_result == other.purpose_result + ) + return False + + +class DocumentVerificationResult: + """Domain verification result class""" + + def __init__( + self, + *, + verified: bool, + results: List[ProofResult] = None, + errors: List[Exception] = None, + ) -> None: + """Create new DocumentVerificationResult instance""" + self.verified = verified + self.results = results + self.errors = errors + + def __eq__(self, other: object) -> bool: + """Comparison between document verification results.""" + if isinstance(other, DocumentVerificationResult): + print( + all( + self_result == other_result + for (self_result, other_result) in zip(self.results, other.results) + ) + ) + return ( + self.verified == other.verified + # check results list + and ( + # both not present + (not self.results and not other.results) + # both list and matching + or ( + isinstance(self.results, list) + and isinstance(other.results, list) + and all( + self_result == other_result + for (self_result, other_result) in zip( + self.results, other.results + ) + ) + ) + ) + # check error list + and ( + # both not present + (not self.errors and not other.errors) + # both list and matching + or ( + isinstance(self.errors, list) + and isinstance(other.errors, list) + and all( + self_error == other_error + for (self_error, other_error) in zip( + self.errors, other.errors + ) + ) + ) + ) + ) + return False \ No newline at end of file diff --git a/aries_cloudagent/vc/vc_ld/checker.py b/aries_cloudagent/vc/vc_ld/checker.py deleted file mode 100644 index 089d25e4c0..0000000000 --- a/aries_cloudagent/vc/vc_ld/checker.py +++ /dev/null @@ -1,79 +0,0 @@ -from pyld.jsonld import JsonLdProcessor -import re - -# from ...messaging.valid import RFC3339DateTime -from ..ld_proofs.constants import CREDENTIALS_V1_URL - - -def get_id(obj): - if type(obj) is str: - return obj - - if "id" not in obj: - return - - return obj["id"] - - -def check_credential(credential: dict): - if not (credential["@context"] and credential["@context"][0] == CREDENTIALS_V1_URL): - raise Exception( - f"{CREDENTIALS_V1_URL} needs to be first in the list of contexts" - ) - - if not credential["type"]: - raise Exception('"type" property is required') - - if "VerifiableCredential" not in JsonLdProcessor.get_values(credential, "type"): - raise Exception('"type" must include "VerifiableCredential"') - - if not credential["credentialSubject"]: - raise Exception('"credentialSubject" property is required') - - if not credential["issuer"]: - raise Exception('"issuer" property is required') - - if len(JsonLdProcessor.get_values(credential, "issuanceDate")) > 1: - raise Exception('"issuanceDate" property can only have one value') - - if not credential["issuanceDate"]: - raise Exception('"issuanceDate" property is required') - - # if not re.match(RFC3339DateTime.PATTERN, credential["issuanceDate"]): - # raise Exception( - # f'"issuanceDate" must be a valid date {credential["issuanceDate"]}' - # ) - - if len(JsonLdProcessor.get_values(credential, "issuer")) > 1: - raise Exception('"issuer" property can only have one value') - - if "issuer" in credential: - issuer = get_id(credential["issuer"]) - - if not issuer: - raise Exception('"issuer" id is required') - - if ":" not in issuer: - raise Exception(f'"issuer" id must be a URL: {issuer}') - - if "credentialStatus" in credential: - credential_status = credential["credentialStatus"] - - if not credential_status["id"]: - raise Exception('"credentialStatus" must include an id') - - if not credential_status["type"]: - raise Exception('"credentialStatus" must include a type') - - for evidence in JsonLdProcessor.get_values(credential, "evidence"): - evidence_id = get_id(evidence) - - if evidence_id and ":" not in evidence_id: - raise Exception(f'"evidence" id must be a URL: {evidence}') - - # if "expirationDate" in credential and not re.match( - # RFC3339DateTime.PATTERN, credential["issuanceDate"] - # ): - # raise Exception( - # f'"expirationDate" must be a valid date {credential["expirationDate"]}' - # ) diff --git a/aries_cloudagent/vc/vc_ld/issue.py b/aries_cloudagent/vc/vc_ld/issue.py index f5240b4b49..82f9bed044 100644 --- a/aries_cloudagent/vc/vc_ld/issue.py +++ b/aries_cloudagent/vc/vc_ld/issue.py @@ -2,13 +2,11 @@ LinkedDataProof, ProofPurpose, sign, - did_key_document_loader, + default_document_loader, CredentialIssuancePurpose, DocumentLoader, ) -# from .checker import check_credential - async def issue( *, @@ -17,7 +15,8 @@ async def issue( purpose: ProofPurpose = None, document_loader: DocumentLoader = None, ) -> dict: - # TODO: validate credential format + # NOTE: API assumes credential is validated on higher level + # we should probably change that, but also want to avoid revalidation on every level if not purpose: purpose = CredentialIssuancePurpose() @@ -26,7 +25,7 @@ async def issue( document=credential, suite=suite, purpose=purpose, - document_loader=document_loader or did_key_document_loader, + document_loader=document_loader or default_document_loader, ) return signed_credential diff --git a/aries_cloudagent/vc/vc_ld/models/credential.py b/aries_cloudagent/vc/vc_ld/models/credential.py new file mode 100644 index 0000000000..aa3d258ce9 --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/models/credential.py @@ -0,0 +1,297 @@ +from marshmallow import ValidationError +import copy +import json +from typing import List, Optional, Union +from datetime import datetime + +from ....messaging.valid import Uri +from ...ld_proofs.constants import CREDENTIALS_V1_URL, VERIFIABLE_CREDENTIAL_TYPE +from .credential_schema import VerifiableCredentialSchema, LinkedDataProofSchema + + +class LDProof: + """Linked Data Proof model""" + + def __init__( + self, + type: Optional[str] = None, + proof_purpose: Optional[str] = None, + verification_method: Optional[str] = None, + created: Optional[str] = None, + domain: Optional[str] = None, + challenge: Optional[str] = None, + jws: Optional[str] = None, + proof_value: Optional[str] = None, + **kwargs, + ) -> None: + """Initialize the LDProof instance.""" + + self.type = type + self.proof_purpose = proof_purpose + self.verification_method = verification_method + self.created = created + self.domain = domain + self.challenge = challenge + self.jws = jws + self.proof_value = proof_value + self.extra = kwargs + + @classmethod + def deserialize(cls, proof: Union[dict, str]) -> "LDProof": + """Deserialize a dict into a LDProof object. + + Args: + proof: proof + + Returns: + LDProof: The deserialized LDProof object + """ + if isinstance(proof, str): + proof = json.loads(proof) + schema = LinkedDataProofSchema() + proof = schema.load(proof) + return proof + + def serialize(self) -> dict: + """Serialize the LDProof object into dict. + + Returns: + dict: The LDProof serialized as dict. + """ + schema = LinkedDataProofSchema() + proof: dict = schema.dump(copy.deepcopy(self)) + proof.update(self.extra) + return proof + + +class VerifiableCredential: + """Verifiable Credential model""" + + def __init__( + self, + context: Optional[List[Union[str, dict]]] = None, + id: Optional[str] = None, + type: Optional[List[str]] = None, + issuer: Optional[Union[dict, str]] = None, + issuance_date: Optional[str] = None, + expiration_date: Optional[str] = None, + credential_subject: Optional[Union[dict, List[dict]]] = None, + proof: Optional[Union[dict, LDProof]] = None, + **kwargs, + ) -> None: + """Initialize the VerifiableCredential instance.""" + self._context = context or [CREDENTIALS_V1_URL] + self._id = id + self._type = type or [VERIFIABLE_CREDENTIAL_TYPE] + self._issuer = issuer + self._credential_subject = credential_subject + + # TODO: proper date parsing + self._issuance_date = issuance_date + self._expiration_date = expiration_date + + if isinstance(proof, dict): + proof = LDProof.deserialize(proof) + self._proof = proof + + self.extra = kwargs + + @classmethod + def deserialize(cls, credential: Union[dict, str]) -> "VerifiableCredential": + """Deserialize a dict into a VerifiableCredential object. + + Args: + credential: credential + + Returns: + VerifiableCredential: The deserialized VerifiableCredential object + """ + if isinstance(credential, str): + credential = json.loads(credential) + schema = VerifiableCredentialSchema() + credential = schema.load(credential) + return credential + + def serialize(self) -> dict: + """Serialize the VerifiableCredential object into dict. + + Returns: + dict: The VerifiableCredential serialized as dict. + """ + schema = VerifiableCredentialSchema() + credential: dict = schema.dump(copy.deepcopy(self)) + credential.update(self.extra) + return credential + + @property + def context(self): + """Getter for context.""" + return self._context + + @context.setter + def context(self, context: List[Union[str, dict]]): + """Setter for context. + + First item must be credentials v1 url + """ + assert context[0] == CREDENTIALS_V1_URL + + self._context = context + + def add_context(self, context: Union[str, dict]): + """Add a context to this credential.""" + self._context.append(context) + + @property + def context_urls(self) -> List[str]: + """Getter for context urls.""" + return [context for context in self.context if type(context) is str] + + @property + def type(self) -> List[str]: + """Getter for type.""" + return self._type + + @type.setter + def type(self, type: List[str]): + """Setter for type. + + First item must be VerifiableCredential + """ + assert type[0] == VERIFIABLE_CREDENTIAL_TYPE + + self._type = type + + def add_type(self, type: str): + """Add a type to this credential.""" + self._type.append(type) + + @property + def id(self): + """Getter for id.""" + return self._id + + @id.setter + def id(self, id: Union[str]): + """Setter for id.""" + if id: + uri_validator = Uri() + uri_validator(id) + + self._id = id + + @property + def issuer_id(self) -> Optional[str]: + """Getter for issuer id.""" + if not self._issuer: + return None + elif type(self._issuer) is str: + return self._issuer + + return self._issuer.get("id") + + @issuer_id.setter + def issuer_id(self, issuer_id: str): + """Setter for issuer id.""" + uri_validator = Uri() + uri_validator(issuer_id) + + # Use simple string variant if possible + if not self._issuer or isinstance(self._issuer, str): + self._issuer = issuer_id + else: + self._issuer["id"] = issuer_id + + @property + def issuer(self): + """Getter for issuer.""" + return self._issuer + + @issuer.setter + def issuer(self, issuer: Union[str, dict]): + """Setter for issuer.""" + uri_validator = Uri() + + issuer_id = issuer if isinstance(issuer, str) else issuer.get("issuer_id") + + if not issuer_id: + raise ValidationError("Issuer id is required") + uri_validator(issuer_id) + + self._issuer = issuer + + @property + def issuance_date(self): + """Getter for issuance date.""" + return self._issuance_date + + @issuance_date.setter + def issuance_date(self, date: Union[str, datetime]): + """Setter for issuance date.""" + if isinstance(date, datetime): + date = date.isoformat() + + self._issuance_date = date + + @property + def expiration_date(self): + """Getter for expiration date.""" + return self._expiration_date + + @expiration_date.setter + def expiration_date(self, date: Union[str, datetime, None]): + """Setter for expiration date.""" + if isinstance(date, datetime): + date = date.isoformat() + + self._expiration_date = date + + @property + def credential_subject_ids(self) -> List[str]: + """Getter for credential subject ids.""" + if not self._credential_subject: + return [] + elif type(self._credential_subject) is dict: + subject_id = self._credential_subject.get("id") + + return [subject_id] if subject_id else [] + else: + return [ + subject.get("id") + for subject in self._credential_subject + if subject.get("id") + ] + + @property + def credential_subject(self): + """Getter for credential subject.""" + return self._credential_subject + + @credential_subject.setter + def credential_subject(self, credential_subject: Union[dict, List[dict]]): + """Setter for credential subject.""" + + uri_validator = Uri() + + subjects = ( + [credential_subject] + if isinstance(credential_subject, dict) + else credential_subject + ) + + # loop trough all credential subjects and check for valid id uri + for subject in subjects: + if subject.get("id"): + uri_validator(subject.get("id")) + + self._credential_subject = credential_subject + + @property + def proof(self): + """Getter for proof.""" + return self._proof + + @proof.setter + def proof(self, proof: LDProof): + """Setter for proof.""" + self._proof = proof \ No newline at end of file diff --git a/aries_cloudagent/vc/vc_ld/models/credential_schema.py b/aries_cloudagent/vc/vc_ld/models/credential_schema.py index 998162935b..119621ccad 100644 --- a/aries_cloudagent/vc/vc_ld/models/credential_schema.py +++ b/aries_cloudagent/vc/vc_ld/models/credential_schema.py @@ -1,4 +1,4 @@ -from marshmallow import fields +from marshmallow import INCLUDE, fields, post_load, post_dump from ....messaging.models.base import Schema from ....messaging.valid import ( @@ -6,14 +6,28 @@ CREDENTIAL_TYPE, CREDENTIAL_SUBJECT, DIDKey, - DID_KEY, + DictOrDictListField, INDY_ISO8601_DATETIME, - URI, + RFC3339_DATETIME, + StrOrDictField, + Uri, UUIDFour, + UriOrDictField, ) -class LDSignatureSchema(Schema): +class LinkedDataProofSchema(Schema): + """Linked data proof schema + + Based on https://w3c-ccg.github.io/ld-proofs + + """ + + class Meta: + """Accept parameter overload.""" + + unknown = INCLUDE + type = fields.Str( required=True, description="Identifies the digital signature suite that was used to create the signature", @@ -23,7 +37,7 @@ class LDSignatureSchema(Schema): proof_purpose = fields.Str( data_key="proofPurpose", required=True, - description="", + description="Proof purpose", example="assertionMethod", ) @@ -32,7 +46,7 @@ class LDSignatureSchema(Schema): required=True, description="Information used for proof verification", example="did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", - validate=URI(), + validate=Uri(), ) created = fields.Str( @@ -45,6 +59,7 @@ class LDSignatureSchema(Schema): required=False, description="A string value specifying the restricted domain of the signature.", example="example.com", + validate=Uri(), ) challenge = fields.Str( @@ -65,61 +80,99 @@ class LDSignatureSchema(Schema): example="z76WGJzY2rXtSiZ8BDwU4VgcLqcMEm2dXdgVVS1QCZQUptZ5P8n5YCcnbuMUASYhVNihae7m8VeYvfViYf2KqTMVEH1BKNF6Xc5S2kPpBwsNos6egnrmDMxhtQppZjb47Mi2xG89jZm654uZUatDvfTCoDWuethfRHPSk81qn6od9zGxBxxAYyUPnY9Fs9QEQETm53AN9uk6erSAhJ2R3K8rosrBkSZbVhbzUJTPg22wpddVY8Xu3vhRVNpzyUvCEedg5EM6i7wE4G1CYsz7tbaApEF9aFRB92v4DoiY5GXGjwH5PhhGstJB9ySh9FyDfSYN8qRVVR7i5No2eBi3AjQ7cqaBiWkoSrCoQK7jJ4PyFsu3ZaAuUx8LAtkhaChmwfxH8E25LcTENJhFxqVnPd7f7Q3cUrFciYRqmg8eJsy1AahqbzJQ63n9RtekmwzqnMYrTwft6cLJJGeTSSxCCJ6HKnRtwE7jjDh6sB2ZeVj494VppdAVJBz2AAiZY9BBnCD8wUVgwqH3qchGRCuC2RugA4eQ9fUrR4Yuycac3caiaaay", ) + @post_load + def make_proof(self, data, **kwargs): + from .credential import LDProof + + return LDProof(**data) + + @post_dump + def remove_none_values(self, data, **kwargs): + return {key: value for key, value in data.items() if value} + + +class CredentialSchema(Schema): + """Linked data credential schema + + Does not include proof. Based on https://www.w3.org/TR/vc-data-model + + """ + + class Meta: + """Accept parameter overload.""" + + unknown = INCLUDE -class LDCredentialSchema(Schema): context = fields.List( - # TODO: can be Str or Dict (wait for PE type) - fields.Str(), + UriOrDictField( + required=True, + ), data_key="@context", required=True, description="The JSON-LD context of the credential", **CREDENTIAL_CONTEXT, ) + id = fields.Str( required=False, desscription="The ID of the credential", example="http://example.edu/credentials/1872", - validate=URI(), + validate=Uri(), ) + type = fields.List( - fields.Str(), + fields.Str(required=True), required=True, description="The JSON-LD type of the credential", **CREDENTIAL_TYPE, ) - # TODO: can be Str or Dict (wait for PE type) - issuer = fields.Str( - required=False, - description="The JSON-LD Verifiable Credential Issuer", + + issuer = StrOrDictField( + required=True, + description="The JSON-LD Verifiable Credential Issuer. Either string of object with id field.", example=DIDKey.EXAMPLE, ) - # TODO: Check for RFC3339 format + issuance_date = fields.Str( data_key="issuanceDate", - required=False, + required=True, description="The issuance date", - example="2010-01-01T19:73:24Z", + **RFC3339_DATETIME, ) - # TODO: Check for RFC3339 format + expiration_date = fields.Str( data_key="expirationDate", required=False, description="The expiration date", - example="2010-01-01T19:73:24Z", + **RFC3339_DATETIME, ) - # TODO: Can be List or Dict - credential_subject = fields.Dict( + + credential_subject = DictOrDictListField( required=True, - keys=fields.Str(), data_key="credentialSubject", **CREDENTIAL_SUBJECT, ) - # TODO: Add extra fields + @post_load + def make_credential(self, data, **kwargs): + from .credential import VerifiableCredential + + return VerifiableCredential(**data) + + @post_dump + def remove_none_values(self, data, **kwargs): + return {key: value for key, value in data.items() if value} + + +class VerifiableCredentialSchema(CredentialSchema): + """Linked data verifiable credential schema + + Based on https://www.w3.org/TR/vc-data-model + + """ -class LDVerifiableCredentialSchema(LDCredentialSchema): proof = fields.Nested( - LDSignatureSchema, + LinkedDataProofSchema(), required=True, description="The proof of the credential", example={ @@ -129,4 +182,4 @@ class LDVerifiableCredentialSchema(LDCredentialSchema): "proofPurpose": "assertionMethod", "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..lKJU0Df_keblRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQKBhQDxvXNo7nvtUBb_Eq1Ch6YBKY7UBAjg6iBX5qBQ", }, - ) + ) \ No newline at end of file diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index 4354c66304..afe23e308d 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -1,6 +1,6 @@ import asyncio from pyld.jsonld import JsonLdProcessor -from typing import Callable, Mapping +from typing import Mapping from ..ld_proofs import ( LinkedDataProof, @@ -10,7 +10,6 @@ AuthenticationProofPurpose, verify as ld_proofs_verify, ) -from .checker import check_credential async def _verify_credential( @@ -19,17 +18,9 @@ async def _verify_credential( document_loader: DocumentLoader, suite: LinkedDataProof, purpose: ProofPurpose = None, - # TODO: add check_status method signature (like DocumentLoader) - check_status: Callable = None, ) -> dict: # TODO: validate credential structure - # TODO: what if we don't want to check for credentialStatus? - if "credentialStatus" in credential and not check_status: - raise Exception( - 'A "check_status function must be provided to verify credentials with "credentialStatus" set.' - ) - if not purpose: purpose = CredentialIssuancePurpose() @@ -40,16 +31,6 @@ async def _verify_credential( document_loader=document_loader, ) - if not result.get("verified"): - return result - - if "credentialStatus" in credential: - # CHECK make sure this is how check_status should be called - result["statusResult"] = await check_status(credential) - - if not result.get("statusResult").get("verified"): - result["verified"] = False - return result @@ -59,7 +40,6 @@ async def verify_credential( suite: LinkedDataProof, document_loader: DocumentLoader, purpose: ProofPurpose = None, - check_status: Callable = None, ) -> dict: try: return await _verify_credential( @@ -67,7 +47,6 @@ async def verify_credential( document_loader=document_loader, suite=suite, purpose=purpose, - check_status=check_status, ) except Exception as e: # TODO: use class instance OR typed dict, as this is confusing @@ -79,59 +58,41 @@ async def verify_credential( async def _verify_presentation( - challenge: str, - presentation: dict = None, + *, + presentation: dict, + challenge: str = None, + domain: str = None, purpose: ProofPurpose = None, - unsigned_presentation: dict = None, suite_map: Mapping[str, LinkedDataProof] = None, suite: LinkedDataProof = None, - domain: str = None, document_loader: DocumentLoader = None, ): - if presentation and unsigned_presentation: + + if not purpose and not challenge: raise Exception( - 'Either "presentation" or "unsigned_presentation" must be present, not both.' + 'A "challenge" param is required for AuthenticationProofPurpose.' ) - if not purpose: purpose = AuthenticationProofPurpose(challenge=challenge, domain=domain) - vp, presentation_result = None, None - - if presentation: - # TODO validate presentation structure here - - vp = presentation + # TODO validate presentation structure here + if "proof" not in presentation: + raise Exception('presentation must contain "proof"') - if "proof" not in vp: - raise Exception('presentation must contain "proof"') + proof_type = presentation.get("proof").get("type") + suite = suite_map[proof_type] - if not purpose and not challenge: - raise Exception( - 'A "challenge" param is required for AuthenticationProofPurpose.' - ) - - proof_type = presentation.get("proof").get("type") - suite = suite_map[proof_type]() - - presentation_result = await ld_proofs_verify( - document=presentation, - suite=suite, - purpose=purpose, - document_loader=document_loader, - ) - - if unsigned_presentation: - # TODO check presentation here - vp = unsigned_presentation - - if vp["proof"]: - raise Exception('"unsigned_presentation" must not contain "proof"') + presentation_result = await ld_proofs_verify( + document=presentation, + suite=suite, + purpose=purpose, + document_loader=document_loader, + ) credential_results = None verified = True - credentials = JsonLdProcessor.get_values(vp, "verifiableCredential") + credentials = JsonLdProcessor.get_values(presentation, "verifiableCredential") def v(credential: dict): if suite_map: @@ -148,13 +109,6 @@ def d(cred: dict, index: int): verified = all([x["verified"] for x in credential_results]) - if unsigned_presentation: - return { - "verified": verified, - "results": [vp], - "credential_results": credential_results, - } - return { "presentation_result": presentation_result, "verified": verified and presentation_result["verified"], @@ -168,7 +122,6 @@ async def verify_presentation( presentation: dict = None, challenge: str, purpose: ProofPurpose = None, - unsigned_presentation: dict = None, suite_map: Mapping[str, LinkedDataProof] = None, suite: LinkedDataProof = None, controller: dict = None, From f02eac1fb672c2f826d15cfb205f3ca99e91867d Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sun, 21 Mar 2021 17:40:02 +0100 Subject: [PATCH 022/138] issue and verify credential overhaul Signed-off-by: Timo Glastra --- .../v2_0/formats/ld_proof/handler.py | 8 +- aries_cloudagent/vc/ld_proofs/ProofSet.py | 19 ++-- aries_cloudagent/vc/ld_proofs/__init__.py | 5 + .../vc/ld_proofs/tests/test_doc.py | 18 ++++ .../vc/ld_proofs/validation_result.py | 9 +- aries_cloudagent/vc/vc_ld/__init__.py | 2 + aries_cloudagent/vc/vc_ld/issue.py | 5 +- aries_cloudagent/vc/vc_ld/prove.py | 13 +-- .../vc/vc_ld/tests/test_credential.py | 80 +++++++++++++-- aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py | 28 +++--- .../vc/vc_ld/validation_result.py | 64 ++++++++++++ aries_cloudagent/vc/vc_ld/verify.py | 97 ++++++++----------- 12 files changed, 245 insertions(+), 103 deletions(-) create mode 100644 aries_cloudagent/vc/vc_ld/validation_result.py diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index f923d7b544..66b92ae42d 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -17,6 +17,7 @@ LinkedDataProof, CredentialIssuancePurpose, ProofPurpose, + get_default_document_loader, ) from ......wallet.error import WalletNotFoundError from ......wallet.base import BaseWallet @@ -285,8 +286,13 @@ async def issue_credential( suite = await self._get_suite_for_detail(detail) proof_purpose = self._get_proof_purpose(detail.options.proof_purpose) + # best to pass profile, session, ...? + document_loader = get_default_document_loader(profile=self.profile) vc = await issue( - credential=detail.credential.serialize(), suite=suite, purpose=proof_purpose + credential=detail.credential.serialize(), + suite=suite, + document_loader=document_loader, + purpose=proof_purpose, ) self.validate_fields(CRED_20_ISSUE, vc) diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py index ca78b32584..7d8c838c46 100644 --- a/aries_cloudagent/vc/ld_proofs/ProofSet.py +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -45,14 +45,17 @@ async def verify( ) -> DocumentVerificationResult: """Verify proof on the document""" try: - document = document.copy() + input = document.copy() + + if len(suites) == 0: + raise LinkedDataProofException("At least one suite is required.") # Get proof set, remove proof from document - proof_set = await ProofSet._get_proofs(document=document) - document.pop("proof", None) + proof_set = await ProofSet._get_proofs(document=input) + input.pop("proof", None) results = await ProofSet._verify( - document=document, + document=input, suites=suites, proof_set=proof_set, purpose=purpose, @@ -66,7 +69,9 @@ async def verify( # check if all results are valid, create result verified = any(result.verified for result in results) - result = DocumentVerificationResult(verified=verified, results=results) + result = DocumentVerificationResult( + verified=verified, document=document, results=results + ) # If not valid, extract and optionally add errors to result if not verified: @@ -77,7 +82,9 @@ async def verify( return result except Exception as e: - return DocumentVerificationResult(verified=False, errors=[e]) + return DocumentVerificationResult( + verified=False, document=document, errors=[e] + ) @staticmethod async def _get_proofs(document: dict) -> list: diff --git a/aries_cloudagent/vc/ld_proofs/__init__.py b/aries_cloudagent/vc/ld_proofs/__init__.py index 73b6d61af5..abfeca26e2 100644 --- a/aries_cloudagent/vc/ld_proofs/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/__init__.py @@ -16,6 +16,7 @@ from .crypto import KeyPair, Ed25519WalletKeyPair from .document_loader import DocumentLoader, get_default_document_loader from .error import LinkedDataProofException +from .validation_result import DocumentVerificationResult, ProofResult, PurposeResult __all__ = [ sign, @@ -40,4 +41,8 @@ get_default_document_loader, # Exceptions LinkedDataProofException, + # Validation results + DocumentVerificationResult, + ProofResult, + PurposeResult, ] diff --git a/aries_cloudagent/vc/ld_proofs/tests/test_doc.py b/aries_cloudagent/vc/ld_proofs/tests/test_doc.py index e9d74f1ac6..90fb377022 100644 --- a/aries_cloudagent/vc/ld_proofs/tests/test_doc.py +++ b/aries_cloudagent/vc/ld_proofs/tests/test_doc.py @@ -33,6 +33,24 @@ DOC_VERIFIED = DocumentVerificationResult( verified=True, + document={ + "@context": { + "schema": "http://schema.org/", + "name": "schema:name", + "homepage": "schema:url", + "image": "schema:image", + }, + "name": "Manu Sporny", + "homepage": "https://manu.sporny.org/", + "image": "https://manu.sporny.org/images/manu.png", + "proof": { + "proofPurpose": "assertionMethod", + "created": "2019-12-11T03:50:55", + "type": "Ed25519Signature2018", + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..Q6amIrxGiSbM7Ce6DxlfwLCjVcYyclas8fMxaecspXFUcFW9DAAxKzgHx93FWktnlZjM_biitkMgZdStgvivAQ", + }, + }, results=[ ProofResult( verified=True, diff --git a/aries_cloudagent/vc/ld_proofs/validation_result.py b/aries_cloudagent/vc/ld_proofs/validation_result.py index f5ec307bc0..25a3474648 100644 --- a/aries_cloudagent/vc/ld_proofs/validation_result.py +++ b/aries_cloudagent/vc/ld_proofs/validation_result.py @@ -61,25 +61,22 @@ def __init__( self, *, verified: bool, + document: dict = None, results: List[ProofResult] = None, errors: List[Exception] = None, ) -> None: """Create new DocumentVerificationResult instance""" self.verified = verified + self.document = document self.results = results self.errors = errors def __eq__(self, other: object) -> bool: """Comparison between document verification results.""" if isinstance(other, DocumentVerificationResult): - print( - all( - self_result == other_result - for (self_result, other_result) in zip(self.results, other.results) - ) - ) return ( self.verified == other.verified + and self.document == other.document # check results list and ( # both not present diff --git a/aries_cloudagent/vc/vc_ld/__init__.py b/aries_cloudagent/vc/vc_ld/__init__.py index 4a9aa9629f..6e55a83269 100644 --- a/aries_cloudagent/vc/vc_ld/__init__.py +++ b/aries_cloudagent/vc/vc_ld/__init__.py @@ -1,6 +1,7 @@ from .issue import issue from .verify import verify_presentation, verify_credential from .prove import create_presentation, sign_presentation +from .validation_result import PresentationVerificationResult __all__ = [ issue, @@ -8,4 +9,5 @@ verify_credential, create_presentation, sign_presentation, + PresentationVerificationResult, ] diff --git a/aries_cloudagent/vc/vc_ld/issue.py b/aries_cloudagent/vc/vc_ld/issue.py index 82f9bed044..3d30ae4fac 100644 --- a/aries_cloudagent/vc/vc_ld/issue.py +++ b/aries_cloudagent/vc/vc_ld/issue.py @@ -2,7 +2,6 @@ LinkedDataProof, ProofPurpose, sign, - default_document_loader, CredentialIssuancePurpose, DocumentLoader, ) @@ -12,8 +11,8 @@ async def issue( *, credential: dict, suite: LinkedDataProof, + document_loader: DocumentLoader, purpose: ProofPurpose = None, - document_loader: DocumentLoader = None, ) -> dict: # NOTE: API assumes credential is validated on higher level # we should probably change that, but also want to avoid revalidation on every level @@ -25,7 +24,7 @@ async def issue( document=credential, suite=suite, purpose=purpose, - document_loader=document_loader or default_document_loader, + document_loader=document_loader, ) return signed_credential diff --git a/aries_cloudagent/vc/vc_ld/prove.py b/aries_cloudagent/vc/vc_ld/prove.py index 5d90241dea..60a2f85a1d 100644 --- a/aries_cloudagent/vc/vc_ld/prove.py +++ b/aries_cloudagent/vc/vc_ld/prove.py @@ -1,4 +1,4 @@ -from typing import List, Union +from typing import List from ..ld_proofs import ( @@ -12,22 +12,19 @@ async def create_presentation( - verifiable_credential: Union[dict, List[dict]], id_: str = None + *, credentials: List[dict], presentation_id: str = None ) -> dict: presentation = { "@context": [CREDENTIALS_V1_URL], "type": ["VerifiablePresentation"], } - if isinstance(verifiable_credential, dict): - verifiable_credential = [verifiable_credential] - # TODO loop through all credentials and validate credential structure - presentation["verifiableCredential"] = verifiable_credential + presentation["verifiableCredential"] = credentials - if id_: - presentation["id"] = id_ + if presentation_id: + presentation["id"] = presentation_id # TODO validate presentation structure diff --git a/aries_cloudagent/vc/vc_ld/tests/test_credential.py b/aries_cloudagent/vc/vc_ld/tests/test_credential.py index a73a8ceea4..79817f5003 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_credential.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_credential.py @@ -1,3 +1,10 @@ +from ...ld_proofs import ( + DocumentVerificationResult, + ProofResult, + PurposeResult, +) + + CREDENTIAL_TEMPLATE = { "@context": [ "https://www.w3.org/2018/credentials/v1", @@ -35,11 +42,37 @@ }, } -CREDENTIAL_VERIFIED = { - "verified": True, - "results": [ - { - "proof": { + +CREDENTIAL_VERIFIED = DocumentVerificationResult( + verified=True, + document={ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1", + ], + "id": "http://example.gov/credentials/3732", + "type": ["VerifiableCredential", "UniversityDegreeCredential"], + "issuer": {"id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL"}, + "issuanceDate": "2020-03-10T04:24:12.164Z", + "credentialSubject": { + "id": "did:example:456", + "degree": { + "type": "BachelorDegree", + "name": "Bachelor of Science and Arts", + }, + }, + "proof": { + "type": "Ed25519Signature2018", + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "created": "2019-12-11T03:50:55", + "proofPurpose": "assertionMethod", + "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..lKJU0Df_keblRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQKBhQDxvXNo7nvtUBb_Eq1Ch6YBKY7UBAjg6iBX5qBQ", + }, + }, + results=[ + ProofResult( + verified=True, + proof={ "@context": "https://w3id.org/security/v2", "type": "Ed25519Signature2018", "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", @@ -47,8 +80,37 @@ "proofPurpose": "assertionMethod", "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..lKJU0Df_keblRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQKBhQDxvXNo7nvtUBb_Eq1Ch6YBKY7UBAjg6iBX5qBQ", }, - "verified": True, - "purpose_result": {"valid": True}, - } + purpose_result=PurposeResult( + valid=True, + controller={ + "@context": "https://w3id.org/security/v2", + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "assertionMethod": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], + "sec:authenticationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "capabilityDelegation": [ + { + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "type": "Ed25519VerificationKey2018", + "controller": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "publicKeyBase58": "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx", + } + ], + "capabilityInvocation": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], + "keyAgreement": [ + { + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6LSbkodSr6SU2trs8VUgnrnWtSm7BAPG245ggrBmSrxbv1R", + "type": "X25519KeyAgreementKey2019", + "controller": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "publicKeyBase58": "5dTvYHaNaB7mk7iA9LqCJEHG2dGZQsvoi8WGzDRtYEf", + } + ], + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + }, + ), + ) ], -} +) diff --git a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py index fefc7eabb8..ed8a8f762f 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py @@ -10,7 +10,6 @@ from ...ld_proofs import ( Ed25519Signature2018, Ed25519WalletKeyPair, - CredentialIssuancePurpose, ) from ...vc_ld import issue, verify_credential from ...tests.document_loader import custom_document_loader @@ -25,16 +24,15 @@ async def setUp(self): self.profile = InMemoryProfile.test_profile() self.wallet = InMemoryWallet(self.profile) self.key_info = await self.wallet.create_signing_key(self.test_seed) - - self.key_pair = Ed25519WalletKeyPair( - wallet=self.wallet, public_key_base58=self.key_info.verkey - ) - self.verification_method = DIDKey.from_public_key_b58( self.key_info.verkey, KeyType.ED25519 ).key_id - self.suite = Ed25519Signature2018( - # TODO: should we provide verification_method here? Or abstract? + + async def test_issue(self): + # Use different key pair and suite for signing and verification + # as during verification a lot of information can be extracted + # from the proof / document + suite = Ed25519Signature2018( verification_method=self.verification_method, key_pair=Ed25519WalletKeyPair( wallet=self.wallet, public_key_base58=self.key_info.verkey @@ -42,22 +40,26 @@ async def setUp(self): date=datetime.strptime("2019-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), ) - async def test_issue(self): issued = await issue( credential=CREDENTIAL_TEMPLATE, - suite=self.suite, - purpose=CredentialIssuancePurpose(), + suite=suite, document_loader=custom_document_loader, ) assert issued == CREDENTIAL_ISSUED async def test_verify(self): + # Verification requires lot less input parameters + suite = Ed25519Signature2018( + key_pair=Ed25519WalletKeyPair(wallet=self.wallet), + ) verified = await verify_credential( credential=CREDENTIAL_ISSUED, - suite=self.suite, + suites=[suite], document_loader=custom_document_loader, - purpose=CredentialIssuancePurpose(), ) assert verified == CREDENTIAL_VERIFIED + + async def test_create_presentation(self): + pass diff --git a/aries_cloudagent/vc/vc_ld/validation_result.py b/aries_cloudagent/vc/vc_ld/validation_result.py new file mode 100644 index 0000000000..427cfa5d49 --- /dev/null +++ b/aries_cloudagent/vc/vc_ld/validation_result.py @@ -0,0 +1,64 @@ +"""Presentation verification and validation result classes""" + +from typing import List + +from ..ld_proofs import DocumentVerificationResult + + +class PresentationVerificationResult: + """Presentation verification result class""" + + def __init__( + self, + *, + verified: bool, + presentation_result: DocumentVerificationResult = None, + credential_results: List[DocumentVerificationResult] = None, + errors: List[Exception] = None, + ) -> None: + """Create new PresentationVerificationResult instance""" + self.verified = verified + self.presentation_result = presentation_result + self.credential_results = credential_results + self.errors = errors + + def __eq__(self, other: object) -> bool: + """Comparison between presentation verification results.""" + if isinstance(other, PresentationVerificationResult): + return ( + self.verified == other.verified + and self.presentation_result == other.presentation_result + # check credential results list + and ( + # both not present + (not self.credential_results and not other.credential_results) + # both list and matching + or ( + isinstance(self.credential_results, list) + and isinstance(other.credential_results, list) + and all( + self_result == other_result + for (self_result, other_result) in zip( + self.credential_results, other.credential_results + ) + ) + ) + ) + # check error list + and ( + # both not present + (not self.errors and not other.errors) + # both list and matching + or ( + isinstance(self.errors, list) + and isinstance(other.errors, list) + and all( + self_error == other_error + for (self_error, other_error) in zip( + self.errors, other.errors + ) + ) + ) + ) + ) + return False \ No newline at end of file diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index afe23e308d..172461be97 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -1,6 +1,6 @@ import asyncio +from typing import List from pyld.jsonld import JsonLdProcessor -from typing import Mapping from ..ld_proofs import ( LinkedDataProof, @@ -9,16 +9,19 @@ ProofPurpose, AuthenticationProofPurpose, verify as ld_proofs_verify, + DocumentVerificationResult, + LinkedDataProofException, ) +from .validation_result import PresentationVerificationResult async def _verify_credential( *, credential: dict, + suites: List[LinkedDataProof], document_loader: DocumentLoader, - suite: LinkedDataProof, purpose: ProofPurpose = None, -) -> dict: +) -> DocumentVerificationResult: # TODO: validate credential structure if not purpose: @@ -26,7 +29,7 @@ async def _verify_credential( result = await ld_proofs_verify( document=credential, - suites=[suite], + suites=suites, purpose=purpose, document_loader=document_loader, ) @@ -37,24 +40,21 @@ async def _verify_credential( async def verify_credential( *, credential: dict, - suite: LinkedDataProof, + suites: List[LinkedDataProof], document_loader: DocumentLoader, purpose: ProofPurpose = None, -) -> dict: +) -> DocumentVerificationResult: try: return await _verify_credential( credential=credential, document_loader=document_loader, - suite=suite, + suites=suites, purpose=purpose, ) except Exception as e: - # TODO: use class instance OR typed dict, as this is confusing - return { - "verified": False, - "results": [{"credential": credential, "verified": False, "error": e}], - "error": e, - } + return DocumentVerificationResult( + verified=False, document=credential, errors=[e] + ) async def _verify_presentation( @@ -63,28 +63,24 @@ async def _verify_presentation( challenge: str = None, domain: str = None, purpose: ProofPurpose = None, - suite_map: Mapping[str, LinkedDataProof] = None, - suite: LinkedDataProof = None, + suites: List[LinkedDataProof], document_loader: DocumentLoader = None, ): if not purpose and not challenge: - raise Exception( + raise LinkedDataProofException( 'A "challenge" param is required for AuthenticationProofPurpose.' ) - if not purpose: + elif not purpose: purpose = AuthenticationProofPurpose(challenge=challenge, domain=domain) # TODO validate presentation structure here if "proof" not in presentation: - raise Exception('presentation must contain "proof"') - - proof_type = presentation.get("proof").get("type") - suite = suite_map[proof_type] + raise LinkedDataProofException('presentation must contain "proof"') presentation_result = await ld_proofs_verify( document=presentation, - suite=suite, + suites=suites, purpose=purpose, document_loader=document_loader, ) @@ -93,28 +89,27 @@ async def _verify_presentation( verified = True credentials = JsonLdProcessor.get_values(presentation, "verifiableCredential") + credential_results = await asyncio.gather( + *[ + verify_credential( + credential=credential, + suites=suites, + document_loader=document_loader, + purpose=purpose, + ) + for credential in credentials + ] + ) - def v(credential: dict): - if suite_map: - suite = suite_map[credential["proof"]["type"]]() - return verify_credential(credential, suite, purpose) - - credential_results = asyncio.gather(*[v(x) for x in credentials]) - - def d(cred: dict, index: int): - cred["credentialId"] = credentials[index]["id"] - return cred - - credential_results = [d(x, i) for x, i in enumerate(credential_results)] - - verified = all([x["verified"] for x in credential_results]) + verified = all([result.verified for result in credential_results]) - return { - "presentation_result": presentation_result, - "verified": verified and presentation_result["verified"], - "credential_results": credential_results, - "error": presentation_result["error"], - } + return PresentationVerificationResult( + verified=verified, + presentation_result=presentation_result, + credential_results=credential_results, + # TODO: should this also include credential results errors? + errors=presentation_result.errors, + ) async def verify_presentation( @@ -122,36 +117,24 @@ async def verify_presentation( presentation: dict = None, challenge: str, purpose: ProofPurpose = None, - suite_map: Mapping[str, LinkedDataProof] = None, - suite: LinkedDataProof = None, + suites: List[LinkedDataProof] = None, controller: dict = None, domain: str = None, document_loader: DocumentLoader = None, ): try: - if not presentation and not unsigned_presentation: - raise TypeError( - 'A "presentation" or "unsignedPresentation" property is required for verifying.' - ) - return await _verify_presentation( presentation=presentation, - unsigned_presentation=unsigned_presentation, challenge=challenge, purpose=purpose, - suite=suite, - suite_map=suite_map, + suites=suites, controller=controller, domain=domain, document_loader=document_loader, ) except Exception as e: - return { - "verified": False, - "results": [{"presentation": presentation, "verified": False, "error": e}], - "error": e, - } + return PresentationVerificationResult(verified=False, errors=[e]) __all__ = [verify_presentation, verify_credential] From 407c5b2ff9488e4f0198138ca4cf917221e1b632 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sun, 21 Mar 2021 18:40:25 +0100 Subject: [PATCH 023/138] create, sign and verify presentation Signed-off-by: Timo Glastra --- aries_cloudagent/vc/ld_proofs/ProofSet.py | 7 +- .../vc/ld_proofs/tests/test_doc.py | 7 +- aries_cloudagent/vc/tests/contexts/did_v1.py | 2 +- aries_cloudagent/vc/vc_ld/prove.py | 15 ++-- .../vc/vc_ld/tests/test_credential.py | 81 ++++++++++++++++++- aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py | 63 ++++++++++++++- aries_cloudagent/vc/vc_ld/verify.py | 21 ++--- 7 files changed, 167 insertions(+), 29 deletions(-) diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py index 7d8c838c46..4e256905ae 100644 --- a/aries_cloudagent/vc/ld_proofs/ProofSet.py +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -18,7 +18,7 @@ async def add( document: dict, suite: LinkedDataProof, purpose: ProofPurpose, - document_loader: DocumentLoader + document_loader: DocumentLoader, ) -> dict: """Add a proof to the document.""" @@ -41,7 +41,7 @@ async def verify( document: dict, suites: List[LinkedDataProof], purpose: ProofPurpose, - document_loader: DocumentLoader + document_loader: DocumentLoader, ) -> DocumentVerificationResult: """Verify proof on the document""" try: @@ -63,8 +63,9 @@ async def verify( ) if len(results) == 0: + suite_names = ", ".join([suite.signature_type for suite in suites]) raise LinkedDataProofException( - "Could not verify any proofs; no proofs matched the required suite and purpose" + f"Could not verify any proofs; no proofs matched the required suites ({suite_names}) and purpose ({purpose.term})" ) # check if all results are valid, create result diff --git a/aries_cloudagent/vc/ld_proofs/tests/test_doc.py b/aries_cloudagent/vc/ld_proofs/tests/test_doc.py index 90fb377022..a43d22d18b 100644 --- a/aries_cloudagent/vc/ld_proofs/tests/test_doc.py +++ b/aries_cloudagent/vc/ld_proofs/tests/test_doc.py @@ -73,8 +73,8 @@ # FIXME: this should be authentication instead of sec:authenticationMethod # SEE: https://github.com/w3c/did-spec-registries/issues/235 # SEE: https://github.com/w3c-ccg/security-vocab/issues/91 - "sec:authenticationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", - "capabilityDelegation": [ + # "sec:authenticationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "authentication": [ { "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", "type": "Ed25519VerificationKey2018", @@ -82,6 +82,9 @@ "publicKeyBase58": "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx", } ], + "capabilityDelegation": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], "capabilityInvocation": [ "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" ], diff --git a/aries_cloudagent/vc/tests/contexts/did_v1.py b/aries_cloudagent/vc/tests/contexts/did_v1.py index 2f669b3521..a6e5527ab6 100644 --- a/aries_cloudagent/vc/tests/contexts/did_v1.py +++ b/aries_cloudagent/vc/tests/contexts/did_v1.py @@ -22,7 +22,7 @@ "RsaSignature2015": "sec:RsaSignature2015", "RsaSignature2017": "sec:RsaSignature2017", "UpdateDidDescription": "didv:UpdateDidDescription", - "authentication": "sec:authenticationMethod", + # "authentication": "sec:authenticationMethod", "authenticationCredential": "sec:authenticationCredential", "authorizationCapability": "sec:authorizationCapability", "canonicalizationAlgorithm": "sec:canonicalizationAlgorithm", diff --git a/aries_cloudagent/vc/vc_ld/prove.py b/aries_cloudagent/vc/vc_ld/prove.py index 60a2f85a1d..52ecc4293d 100644 --- a/aries_cloudagent/vc/vc_ld/prove.py +++ b/aries_cloudagent/vc/vc_ld/prove.py @@ -7,6 +7,7 @@ DocumentLoader, sign, LinkedDataProof, + LinkedDataProofException, ) from ..ld_proofs.constants import CREDENTIALS_V1_URL @@ -32,19 +33,19 @@ async def create_presentation( async def sign_presentation( + *, presentation: dict, suite: LinkedDataProof, document_loader: DocumentLoader, - domain: str, - challenge: str, + challenge: str = None, + domain: str = None, purpose: ProofPurpose = None, ): - + if not purpose and not challenge: + raise LinkedDataProofException( + 'A "challenge" param is required when not providing a "purpose" (for AuthenticationProofPurpose).' + ) if not purpose: - if not domain and challenge: - raise Exception( - '"domain" and "challenge" must be provided when not providing a "purpose"' - ) purpose = AuthenticationProofPurpose(challenge=challenge, domain=domain) return await sign( diff --git a/aries_cloudagent/vc/vc_ld/tests/test_credential.py b/aries_cloudagent/vc/vc_ld/tests/test_credential.py index 79817f5003..4d2daa831a 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_credential.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_credential.py @@ -4,6 +4,7 @@ PurposeResult, ) +# All signed documents manually tested for validity on https://univerifier.io CREDENTIAL_TEMPLATE = { "@context": [ @@ -88,8 +89,7 @@ "assertionMethod": [ "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" ], - "sec:authenticationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", - "capabilityDelegation": [ + "authentication": [ { "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", "type": "Ed25519VerificationKey2018", @@ -97,6 +97,9 @@ "publicKeyBase58": "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx", } ], + "capabilityDelegation": [ + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ], "capabilityInvocation": [ "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" ], @@ -114,3 +117,77 @@ ) ], ) + +PRESENTATION_UNSIGNED = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiablePresentation"], + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1", + ], + "id": "http://example.gov/credentials/3732", + "type": ["VerifiableCredential", "UniversityDegreeCredential"], + "issuer": { + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + }, + "issuanceDate": "2020-03-10T04:24:12.164Z", + "credentialSubject": { + "id": "did:example:456", + "degree": { + "type": "BachelorDegree", + "name": "Bachelor of Science and Arts", + }, + }, + "proof": { + "type": "Ed25519Signature2018", + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "created": "2019-12-11T03:50:55", + "proofPurpose": "assertionMethod", + "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..lKJU0Df_keblRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQKBhQDxvXNo7nvtUBb_Eq1Ch6YBKY7UBAjg6iBX5qBQ", + }, + } + ], +} + +PRESENTATION_SIGNED = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiablePresentation"], + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1", + ], + "id": "http://example.gov/credentials/3732", + "type": ["VerifiableCredential", "UniversityDegreeCredential"], + "issuer": { + "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + }, + "issuanceDate": "2020-03-10T04:24:12.164Z", + "credentialSubject": { + "id": "did:example:456", + "degree": { + "type": "BachelorDegree", + "name": "Bachelor of Science and Arts", + }, + }, + "proof": { + "type": "Ed25519Signature2018", + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "created": "2019-12-11T03:50:55", + "proofPurpose": "assertionMethod", + "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..lKJU0Df_keblRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQKBhQDxvXNo7nvtUBb_Eq1Ch6YBKY7UBAjg6iBX5qBQ", + }, + } + ], + "proof": { + "type": "Ed25519Signature2018", + "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "created": "2020-12-11T03:50:55", + "proofPurpose": "authentication", + "challenge": "2b1bbff6-e608-4368-bf84-67471b27e41c", + "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..IOjNkdpk4lF8uU8N7n0OMc3opqU1wtCTu3KbJcdDIKvSt6QLEy-ofRDVgN2xo-21yxzx36mXVjiilWdB6A-dDg", + }, +} diff --git a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py index ed8a8f762f..060c513e88 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py @@ -1,5 +1,4 @@ from asynctest import TestCase - from datetime import datetime from ....wallet.base import KeyInfo @@ -11,9 +10,21 @@ Ed25519Signature2018, Ed25519WalletKeyPair, ) -from ...vc_ld import issue, verify_credential +from ...vc_ld import ( + issue, + verify_credential, + create_presentation, + sign_presentation, + verify_presentation, +) from ...tests.document_loader import custom_document_loader -from .test_credential import CREDENTIAL_TEMPLATE, CREDENTIAL_ISSUED, CREDENTIAL_VERIFIED +from .test_credential import ( + CREDENTIAL_TEMPLATE, + CREDENTIAL_ISSUED, + CREDENTIAL_VERIFIED, + PRESENTATION_SIGNED, + PRESENTATION_UNSIGNED, +) class TestLinkedDataVerifiableCredential(TestCase): @@ -27,6 +38,7 @@ async def setUp(self): self.verification_method = DIDKey.from_public_key_b58( self.key_info.verkey, KeyType.ED25519 ).key_id + self.presentation_challenge = "2b1bbff6-e608-4368-bf84-67471b27e41c" async def test_issue(self): # Use different key pair and suite for signing and verification @@ -62,4 +74,47 @@ async def test_verify(self): assert verified == CREDENTIAL_VERIFIED async def test_create_presentation(self): - pass + # TODO: create presentation from subject id controller + # TODO: create presentation with multiple credentials + unsigned_presentation = await create_presentation( + credentials=[CREDENTIAL_ISSUED] + ) + + suite = Ed25519Signature2018( + verification_method=self.verification_method, + key_pair=Ed25519WalletKeyPair( + wallet=self.wallet, public_key_base58=self.key_info.verkey + ), + date=datetime.strptime("2020-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), + ) + + assert unsigned_presentation == PRESENTATION_UNSIGNED + + presentation = await sign_presentation( + presentation=unsigned_presentation, + suite=suite, + document_loader=custom_document_loader, + challenge=self.presentation_challenge, + ) + + assert presentation == PRESENTATION_SIGNED + + async def test_verify_presentation(self): + # TODO: verify with multiple suites + suite = Ed25519Signature2018( + key_pair=Ed25519WalletKeyPair(wallet=self.wallet), + ) + verification_result = await verify_presentation( + presentation=PRESENTATION_SIGNED, + challenge=self.presentation_challenge, + suites=[suite], + document_loader=custom_document_loader, + ) + + if not verification_result.verified: + if verification_result.errors and len(verification_result.errors) > 0: + raise verification_result.errors[0] + + for credential_result in verification_result.credential_results: + if credential_result.errors and len(credential_result.errors) > 0: + raise credential_result.errors[0] diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index 172461be97..9fcbfa8324 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -60,11 +60,11 @@ async def verify_credential( async def _verify_presentation( *, presentation: dict, + suites: List[LinkedDataProof], + document_loader: DocumentLoader, challenge: str = None, domain: str = None, purpose: ProofPurpose = None, - suites: List[LinkedDataProof], - document_loader: DocumentLoader = None, ): if not purpose and not challenge: @@ -95,7 +95,10 @@ async def _verify_presentation( credential=credential, suites=suites, document_loader=document_loader, - purpose=purpose, + # FIXME: we don't want to interhit the authentication purpose + # from the presentation. However we do want to have subject + # authentication I guess + # purpose=purpose, ) for credential in credentials ] @@ -114,14 +117,13 @@ async def _verify_presentation( async def verify_presentation( *, - presentation: dict = None, - challenge: str, + presentation: dict, + suites: List[LinkedDataProof], + document_loader: DocumentLoader, purpose: ProofPurpose = None, - suites: List[LinkedDataProof] = None, - controller: dict = None, + challenge: str = None, domain: str = None, - document_loader: DocumentLoader = None, -): +) -> PresentationVerificationResult: try: return await _verify_presentation( @@ -129,7 +131,6 @@ async def verify_presentation( challenge=challenge, purpose=purpose, suites=suites, - controller=controller, domain=domain, document_loader=document_loader, ) From 0f605e39df0f3e7344800c757032773a675be7ce Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sun, 21 Mar 2021 18:43:21 +0100 Subject: [PATCH 024/138] small improvements test_vc_ld Signed-off-by: Timo Glastra --- aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py index 060c513e88..462e966316 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py @@ -111,10 +111,5 @@ async def test_verify_presentation(self): document_loader=custom_document_loader, ) - if not verification_result.verified: - if verification_result.errors and len(verification_result.errors) > 0: - raise verification_result.errors[0] - - for credential_result in verification_result.credential_results: - if credential_result.errors and len(credential_result.errors) > 0: - raise credential_result.errors[0] + # TODO match against stored verification result for continuity + assert verification_result.verified == True From 45996d4fb333f1f2615a9302cd2e4b1976db5e74 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 22 Mar 2021 14:11:39 +0100 Subject: [PATCH 025/138] abstract base did key resolver Signed-off-by: Timo Glastra --- aries_cloudagent/did/did_key.py | 60 +++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/aries_cloudagent/did/did_key.py b/aries_cloudagent/did/did_key.py index c5ee23e762..518572d200 100644 --- a/aries_cloudagent/did/did_key.py +++ b/aries_cloudagent/did/did_key.py @@ -109,29 +109,47 @@ def construct_did_key_ed25519(did_key: "DIDKey") -> dict: # TODO: update once https://github.com/multiformats/py-multicodec/pull/14 is merged curve25519_fingerprint = "z" + bytes_to_b58(b"".join([b"\xec\x01", curve25519])) + did_doc = construct_did_signature_key_base( + id=did_key.did, + key_id=did_key.key_id, + verification_method={ + "id": did_key.key_id, + "type": "Ed25519VerificationKey2018", + "controller": did_key.did, + "publicKeyBase58": did_key.public_key_b58, + }, + ) + + # Ed25519 has pair with X25519 + did_doc["keyAgreement"].append( + { + "id": f"{did_key.did}#{curve25519_fingerprint}", + "type": "X25519KeyAgreementKey2019", + "controller": did_key.did, + "publicKeyBase58": bytes_to_b58(curve25519), + } + ) + + return did_doc + + +def construct_did_signature_key_base( + *, id: str, key_id: str, verification_method: dict +): + """Creates base did key structure used for most signature keys. + + May not be suitable for all did key types + """ + return { "@context": "https://w3id.org/did/v1", - "id": did_key.did, - "verificationMethod": [ - { - "id": did_key.key_id, - "type": "Ed25519VerificationKey2018", - "controller": did_key.did, - "publicKeyBase58": did_key.public_key_b58, - } - ], - "authentication": [did_key.key_id], - "assertionMethod": [did_key.key_id], - "capabilityDelegation": [did_key.key_id], - "capabilityInvocation": [did_key.key_id], - "keyAgreement": [ - { - "id": f"{did_key.did}#{curve25519_fingerprint}", - "type": "X25519KeyAgreementKey2019", - "controller": did_key.did, - "publicKeyBase58": bytes_to_b58(curve25519), - } - ], + "id": id, + "verificationMethod": [verification_method], + "authentication": [key_id], + "assertionMethod": [key_id], + "capabilityDelegation": [key_id], + "capabilityInvocation": [key_id], + "keyAgreement": [], } From 64b6bc32445a9682d8ebdc73a812f0de7bcf7b2e Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 22 Mar 2021 14:11:56 +0100 Subject: [PATCH 026/138] fix rfc3339 date time regex Signed-off-by: Timo Glastra --- aries_cloudagent/messaging/valid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aries_cloudagent/messaging/valid.py b/aries_cloudagent/messaging/valid.py index f323b56b05..4f7e1c9a9f 100644 --- a/aries_cloudagent/messaging/valid.py +++ b/aries_cloudagent/messaging/valid.py @@ -391,7 +391,7 @@ class RFC3339DateTime(Regexp): EXAMPLE = "2010-01-01T19:73:24Z" PATTERN = ( r"^([0-9]{4})-([0-9]{2})-([0-9]{2})([Tt]([0-9]{2}):([0-9]{2}):" - r"([0-9]{2})(\\.[0-9]+)?)?(([Zz]|([+-])([0-9]{2}):([0-9]{2})))?$" + r"([0-9]{2})(\.[0-9]+)?)?(([Zz]|([+-])([0-9]{2}):([0-9]{2})))?$" ) def __init__(self): From 2f488cdd765f8ff7628ffd7ede55f6cfd604b456 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 22 Mar 2021 14:12:30 +0100 Subject: [PATCH 027/138] verify credential structure before issue/verify Signed-off-by: Timo Glastra --- aries_cloudagent/vc/vc_ld/issue.py | 10 ++++- aries_cloudagent/vc/vc_ld/models/__init__.py | 0 .../vc/vc_ld/models/credential.py | 15 +++++-- aries_cloudagent/vc/vc_ld/verify.py | 43 ++++++++++++++++++- 4 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 aries_cloudagent/vc/vc_ld/models/__init__.py diff --git a/aries_cloudagent/vc/vc_ld/issue.py b/aries_cloudagent/vc/vc_ld/issue.py index 3d30ae4fac..a394a24bc4 100644 --- a/aries_cloudagent/vc/vc_ld/issue.py +++ b/aries_cloudagent/vc/vc_ld/issue.py @@ -4,7 +4,9 @@ sign, CredentialIssuancePurpose, DocumentLoader, + LinkedDataProofException, ) +from .models.credential_schema import CredentialSchema async def issue( @@ -14,8 +16,12 @@ async def issue( document_loader: DocumentLoader, purpose: ProofPurpose = None, ) -> dict: - # NOTE: API assumes credential is validated on higher level - # we should probably change that, but also want to avoid revalidation on every level + # Validate credential + errors = CredentialSchema().validate(credential) + if len(errors) > 0: + raise LinkedDataProofException( + f"Credential contains invalid structure: {errors}" + ) if not purpose: purpose = CredentialIssuancePurpose() diff --git a/aries_cloudagent/vc/vc_ld/models/__init__.py b/aries_cloudagent/vc/vc_ld/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/vc/vc_ld/models/credential.py b/aries_cloudagent/vc/vc_ld/models/credential.py index aa3d258ce9..6e62194bd8 100644 --- a/aries_cloudagent/vc/vc_ld/models/credential.py +++ b/aries_cloudagent/vc/vc_ld/models/credential.py @@ -6,7 +6,11 @@ from ....messaging.valid import Uri from ...ld_proofs.constants import CREDENTIALS_V1_URL, VERIFIABLE_CREDENTIAL_TYPE -from .credential_schema import VerifiableCredentialSchema, LinkedDataProofSchema +from .credential_schema import ( + CredentialSchema, + VerifiableCredentialSchema, + LinkedDataProofSchema, +) class LDProof: @@ -97,18 +101,21 @@ def __init__( self.extra = kwargs @classmethod - def deserialize(cls, credential: Union[dict, str]) -> "VerifiableCredential": + def deserialize( + cls, credential: Union[dict, str], without_proof=False + ) -> "VerifiableCredential": """Deserialize a dict into a VerifiableCredential object. Args: - credential: credential + credential: The credential to deserialize + without_proof: To deserialize without checking for required proof property Returns: VerifiableCredential: The deserialized VerifiableCredential object """ if isinstance(credential, str): credential = json.loads(credential) - schema = VerifiableCredentialSchema() + schema = CredentialSchema() if without_proof else VerifiableCredentialSchema() credential = schema.load(credential) return credential diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index 9fcbfa8324..d01ef0350f 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -1,3 +1,5 @@ +"""Verifiable Credential and Presentation verification methods.""" + import asyncio from typing import List from pyld.jsonld import JsonLdProcessor @@ -12,6 +14,7 @@ DocumentVerificationResult, LinkedDataProofException, ) +from .models.credential_schema import VerifiableCredentialSchema from .validation_result import PresentationVerificationResult @@ -22,7 +25,14 @@ async def _verify_credential( document_loader: DocumentLoader, purpose: ProofPurpose = None, ) -> DocumentVerificationResult: - # TODO: validate credential structure + """Verify credential structure, proof purpose and signature""" + + # Validate credential structure + errors = VerifiableCredentialSchema().validate(credential) + if len(errors) > 0: + raise LinkedDataProofException( + f"Unable to verify credential with invalid structure: {errors}" + ) if not purpose: purpose = CredentialIssuancePurpose() @@ -44,6 +54,19 @@ async def verify_credential( document_loader: DocumentLoader, purpose: ProofPurpose = None, ) -> DocumentVerificationResult: + """Verify credential structure, proof purpose and signature + + Args: + credential (dict): The credential to verify + suites (List[LinkedDataProof]): The signature suites to verify with + document_loader (DocumentLoader): Document loader used for resolving of documents + purpose (ProofPurpose, optional): Proof purpose to use. + Defaults to CredentialIssuancePurpose + + Returns: + DocumentVerificationResult: The result of the verification. Verified property + indicates whether the verification was successful + """ try: return await _verify_credential( credential=credential, @@ -66,6 +89,7 @@ async def _verify_presentation( domain: str = None, purpose: ProofPurpose = None, ): + """Verify presentation structure, credentials, proof purpose and signature""" if not purpose and not challenge: raise LinkedDataProofException( @@ -124,6 +148,23 @@ async def verify_presentation( challenge: str = None, domain: str = None, ) -> PresentationVerificationResult: + """Verify presentation structure, credentials, proof purpose and signature + + Args: + presentation (dict): The presentation to verify + suites (List[LinkedDataProof]): The signature suites to verify with + document_loader (DocumentLoader): Document loader used for resolving of documents + purpose (ProofPurpose, optional): Proof purpose to use. + Defaults to AuthenticationProofPurpose + challenge (str, optional): The challenge to use for authentication. + Required if purpose is not passed, not used if purpose is passed + domain (str, optional): Domain to use for the authentication proof purpose. + Not used if purpose is passed + + Returns: + PresentationVerificationResult: The result of the verification. Verified property + indicates whether the verification was successful + """ try: return await _verify_presentation( From 8a36c46da6f4cd1b7b1405adff81936f4d2070b9 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 22 Mar 2021 14:30:06 +0100 Subject: [PATCH 028/138] validate credentials in create presentation Signed-off-by: Timo Glastra --- aries_cloudagent/vc/ld_proofs/ProofSet.py | 4 ++-- aries_cloudagent/vc/ld_proofs/document_loader.py | 1 + .../vc/ld_proofs/suites/LinkedDataSignature.py | 1 + aries_cloudagent/vc/vc_ld/prove.py | 12 +++++++++--- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py index 4e256905ae..b2f8fc3350 100644 --- a/aries_cloudagent/vc/ld_proofs/ProofSet.py +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -98,8 +98,8 @@ async def _get_proofs(document: dict) -> list: ) # TODO: digitalbazaar changed this to use the document context - # in jsonld-signatures. Does that mean we need to provide this - # ourselves? + # in jsonld-signatures. Does that mean we need to provide this ourselves? + # https://github.com/digitalbazaar/jsonld-signatures/commit/5046805653ea7db47540e5c9c77578d134a559e1 proof_set = [{"@context": SECURITY_V2_URL, **proof} for proof in proof_set] return proof_set diff --git a/aries_cloudagent/vc/ld_proofs/document_loader.py b/aries_cloudagent/vc/ld_proofs/document_loader.py index 47631716e4..5034614e4e 100644 --- a/aries_cloudagent/vc/ld_proofs/document_loader.py +++ b/aries_cloudagent/vc/ld_proofs/document_loader.py @@ -14,6 +14,7 @@ def get_default_document_loader(profile: Profile) -> "DocumentLoader": def default_document_loader(url: str, options: dict): """Default document loader implementation""" # TODO: integrate with did resolver interface + # https://github.com/hyperledger/aries-cloudagent-python/pull/1033 if url.startswith("did:key:"): did_key = DIDKey.from_did(url) diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py index cce76f9d48..dc656bef31 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -77,6 +77,7 @@ async def create_proof( # required # TODO: digitalbazaar changed this implementation after we wrote it. Should # double check to make sure we're doing it correctly + # https://github.com/digitalbazaar/jsonld-signatures/commit/2c98a2fb626b85e31d16b16e7ea6a90fd83534c5 proof = jsonld.compact( self.proof, SECURITY_V2_URL, {"documentLoader": document_loader} ) diff --git a/aries_cloudagent/vc/vc_ld/prove.py b/aries_cloudagent/vc/vc_ld/prove.py index 52ecc4293d..74ce7158af 100644 --- a/aries_cloudagent/vc/vc_ld/prove.py +++ b/aries_cloudagent/vc/vc_ld/prove.py @@ -10,6 +10,7 @@ LinkedDataProofException, ) from ..ld_proofs.constants import CREDENTIALS_V1_URL +from .models.credential_schema import VerifiableCredentialSchema async def create_presentation( @@ -20,15 +21,18 @@ async def create_presentation( "type": ["VerifiablePresentation"], } - # TODO loop through all credentials and validate credential structure + # Validate structure of all credentials + errors = VerifiableCredentialSchema().validate(credentials, many=True) + if len(errors) > 0: + raise LinkedDataProofException( + f"Not all credentials have a valid structure: {errors}" + ) presentation["verifiableCredential"] = credentials if presentation_id: presentation["id"] = presentation_id - # TODO validate presentation structure - return presentation @@ -48,6 +52,8 @@ async def sign_presentation( if not purpose: purpose = AuthenticationProofPurpose(challenge=challenge, domain=domain) + # TODO: validate structure of presentation + return await sign( document=presentation, suite=suite, From d7763e0700ca82851ab69d1b52ec5870435ec3d2 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 22 Mar 2021 14:54:30 +0100 Subject: [PATCH 029/138] fix query dids route Signed-off-by: Timo Glastra --- .../v2_0/formats/ld_proof/handler.py | 29 +++++++++---------- aries_cloudagent/wallet/crypto.py | 2 ++ aries_cloudagent/wallet/routes.py | 4 +-- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index 66b92ae42d..24428ff837 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -269,7 +269,7 @@ async def receive_request( # so maybe do partial check? # e.g. options.challenge may be filled in request # OR credentialSubject.id - # TODO: Send problem report if no match?s + # TODO: Send problem report if no match? assert cred_offer_detail == cred_request_detail async def issue_credential( @@ -295,7 +295,6 @@ async def issue_credential( purpose=proof_purpose, ) - self.validate_fields(CRED_20_ISSUE, vc) return self.get_format_data(CRED_20_ISSUE, vc) async def receive_credential( @@ -312,29 +311,27 @@ async def store_credential( cred_ex_record.cred_issue ).attachment(self.format) - credential = VerifiableCredential.deserialize(cred_dict) - async with self.profile.session() as session: wallet = session.inject(BaseWallet) - # TODO: better way to get suite - # (possibly combine with creating issuer suite) - suite = Ed25519Signature2018( - key_pair=Ed25519WalletKeyPair(wallet=wallet), - ) + # TODO: extract to suite provider or something + suites = [ + Ed25519Signature2018( + key_pair=Ed25519WalletKeyPair(wallet=wallet), + ) + ] result = await verify_credential( - credential=credential, - suite=suite, - document_loader=default_document_loader, + credential=cred_dict, + suites=suites, + document_loader=get_default_document_loader(self.profile), ) - if not result.get("verified"): - raise V20CredFormatError( - f"Received invalid credential: {result}", - ) + if not result.verified: + raise V20CredFormatError(f"Received invalid credential: {result}") vc_holder = session.inject(VCHolder) + credential = VerifiableCredential.deserialize(cred_dict) # TODO: tags vc_record = VCRecord( diff --git a/aries_cloudagent/wallet/crypto.py b/aries_cloudagent/wallet/crypto.py index 80c50029d6..b44394bbd7 100644 --- a/aries_cloudagent/wallet/crypto.py +++ b/aries_cloudagent/wallet/crypto.py @@ -97,6 +97,8 @@ def from_method(method: str) -> Optional["DIDMethod"]: if method == did_method.method_name: return did_method + return None + class PackMessageSchema(Schema): """Packed message schema.""" diff --git a/aries_cloudagent/wallet/routes.py b/aries_cloudagent/wallet/routes.py index a142e1bbee..48fef33949 100644 --- a/aries_cloudagent/wallet/routes.py +++ b/aries_cloudagent/wallet/routes.py @@ -135,7 +135,7 @@ class DIDCreateSchema(OpenAPISchema): method = fields.Str( required=False, default=DIDMethod.SOV.method_name, - example=DIDMethod.KEY.method_name, + example=DIDMethod.SOV.method_name, validate=validate.OneOf([DIDMethod.KEY.method_name, DIDMethod.SOV.method_name]), ) @@ -177,7 +177,7 @@ async def wallet_did_list(request: web.BaseRequest): raise web.HTTPForbidden(reason="No wallet available") filter_did = request.query.get("did") filter_verkey = request.query.get("verkey") - filter_method = DIDMethod.from_method(request.query.get("method")) or DIDMethod.SOV + filter_method = DIDMethod.from_method(request.query.get("method")) filter_posture = DIDPosture.get(request.query.get("posture")) results = [] public_did_info = await wallet.get_public_did() From afa1bee6f3cad706e3d358c0c48de945f46ef894 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 22 Mar 2021 19:47:21 +0100 Subject: [PATCH 030/138] fix incorrect did context url Signed-off-by: Timo Glastra --- aries_cloudagent/did/did_key.py | 2 +- aries_cloudagent/did/tests/test_did_key.py | 2 +- aries_cloudagent/vc/ld_proofs/constants.py | 2 +- aries_cloudagent/vc/tests/contexts/did_v1.py | 105 +++++++++---------- aries_cloudagent/vc/tests/dids.py | 2 +- aries_cloudagent/vc/vc_ld/verify.py | 3 + 6 files changed, 58 insertions(+), 58 deletions(-) diff --git a/aries_cloudagent/did/did_key.py b/aries_cloudagent/did/did_key.py index 518572d200..b60dfb5e8f 100644 --- a/aries_cloudagent/did/did_key.py +++ b/aries_cloudagent/did/did_key.py @@ -142,7 +142,7 @@ def construct_did_signature_key_base( """ return { - "@context": "https://w3id.org/did/v1", + "@context": "https://www.w3.org/ns/did/v1", "id": id, "verificationMethod": [verification_method], "authentication": [key_id], diff --git a/aries_cloudagent/did/tests/test_did_key.py b/aries_cloudagent/did/tests/test_did_key.py index d87563ae76..87e4b0c51d 100644 --- a/aries_cloudagent/did/tests/test_did_key.py +++ b/aries_cloudagent/did/tests/test_did_key.py @@ -59,7 +59,7 @@ def test_ed25519_resolver(self): # resolved using uniresolver, updated to did v1 expected = { - "@context": "https://w3id.org/did/v1", + "@context": "https://www.w3.org/ns/did/v1", "id": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", "verificationMethod": [ { diff --git a/aries_cloudagent/vc/ld_proofs/constants.py b/aries_cloudagent/vc/ld_proofs/constants.py index 3be4381eed..94261ef599 100644 --- a/aries_cloudagent/vc/ld_proofs/constants.py +++ b/aries_cloudagent/vc/ld_proofs/constants.py @@ -2,7 +2,7 @@ SECURITY_V1_URL = "https://w3id.org/security/v1" SECURITY_V2_URL = "https://w3id.org/security/v2" -DID_V1_URL = "https://w3id.org/did/v1" +DID_V1_URL = "https://www.w3.org/ns/did/v1" CREDENTIALS_ISSUER_URL = "https://www.w3.org/2018/credentials#issuer" CREDENTIALS_V1_URL = "https://www.w3.org/2018/credentials/v1" VERIFIABLE_CREDENTIAL_TYPE = "VerifiableCredential" diff --git a/aries_cloudagent/vc/tests/contexts/did_v1.py b/aries_cloudagent/vc/tests/contexts/did_v1.py index a6e5527ab6..ae65529c10 100644 --- a/aries_cloudagent/vc/tests/contexts/did_v1.py +++ b/aries_cloudagent/vc/tests/contexts/did_v1.py @@ -4,64 +4,61 @@ "id": "@id", "type": "@type", "dc": "http://purl.org/dc/terms/", - "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "schema": "http://schema.org/", "sec": "https://w3id.org/security#", - "didv": "https://w3id.org/did#", + "didns": "https://www.w3.org/ns/did#", "xsd": "http://www.w3.org/2001/XMLSchema#", - "AuthenticationSuite": "sec:AuthenticationSuite", - "CryptographicKey": "sec:Key", - "EquihashProof2017": "sec:EquihashProof2017", - "GraphSignature2012": "sec:GraphSignature2012", - "IssueCredential": "didv:IssueCredential", - "LinkedDataSignature2015": "sec:LinkedDataSignature2015", - "LinkedDataSignature2016": "sec:LinkedDataSignature2016", - "RsaCryptographicKey": "sec:RsaCryptographicKey", - "RsaSignatureAuthentication2018": "sec:RsaSignatureAuthentication2018", - "RsaSigningKey2018": "sec:RsaSigningKey", - "RsaSignature2015": "sec:RsaSignature2015", - "RsaSignature2017": "sec:RsaSignature2017", - "UpdateDidDescription": "didv:UpdateDidDescription", - # "authentication": "sec:authenticationMethod", - "authenticationCredential": "sec:authenticationCredential", - "authorizationCapability": "sec:authorizationCapability", - "canonicalizationAlgorithm": "sec:canonicalizationAlgorithm", - "capability": "sec:capability", - "comment": "rdfs:comment", + "EcdsaSecp256k1VerificationKey2019": "sec:EcdsaSecp256k1VerificationKey2019", + "Ed25519Signature2018": "sec:Ed25519Signature2018", + "Ed25519VerificationKey2018": "sec:Ed25519VerificationKey2018", + "JsonWebKey2020": "sec:JsonWebKey2020", + "JsonWebSignature2020": "sec:JsonWebSignature2020", + "Bls12381G1Key2020": "sec:Bls12381G1Key2020", + "Bls12381G2Key2020": "sec:Bls12381G2Key2020", + "RsaVerificationKey2018": "sec:RsaVerificationKey2018", + "SchnorrSecp256k1VerificationKey2019": "sec:SchnorrSecp256k1VerificationKey2019", + "X25519KeyAgreementKey2019": "sec:X25519KeyAgreementKey2019", + "ServiceEndpointProxyService": "didns:ServiceEndpointProxyService", + "LinkedDomains": "https://identity.foundation/.well-known/resources/did-configuration/#LinkedDomains", + "alsoKnownAs": { + "@id": "https://www.w3.org/ns/activitystreams#alsoKnownAs", + "@type": "@id", + "@container": "@set", + }, + "assertionMethod": { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + "authentication": { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + "capabilityDelegation": { + "@id": "sec:capabilityDelegationMethod", + "@type": "@id", + "@container": "@set", + }, + "capabilityInvocation": { + "@id": "sec:capabilityInvocationMethod", + "@type": "@id", + "@container": "@set", + }, + "controller": {"@id": "sec:controller", "@type": "@id"}, "created": {"@id": "dc:created", "@type": "xsd:dateTime"}, - "creator": {"@id": "dc:creator", "@type": "@id"}, - "description": "schema:description", - "digestAlgorithm": "sec:digestAlgorithm", - "digestValue": "sec:digestValue", - "domain": "sec:domain", - "entity": "sec:entity", - "equihashParameterAlgorithm": "sec:equihashParameterAlgorithm", - "equihashParameterK": {"@id": "sec:equihashParameterK", "@type": "xsd:integer"}, - "equihashParameterN": {"@id": "sec:equihashParameterN", "@type": "xsd:integer"}, - "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, - "field": {"@id": "didv:field", "@type": "@id"}, - "label": "rdfs:label", - "minimumProofsRequired": "sec:minimumProofsRequired", - "minimumSignaturesRequired": "sec:minimumSignaturesRequired", - "name": "schema:name", - "nonce": "sec:nonce", - "normalizationAlgorithm": "sec:normalizationAlgorithm", - "owner": {"@id": "sec:owner", "@type": "@id"}, - "permission": "sec:permission", - "permittedProofType": "sec:permittedProofType", - "privateKey": {"@id": "sec:privateKey", "@type": "@id"}, - "privateKeyPem": "sec:privateKeyPem", - "proof": "sec:proof", - "proofAlgorithm": "sec:proofAlgorithm", - "proofType": "sec:proofType", - "proofValue": "sec:proofValue", + "blockchainAccountId": "sec:blockchainAccountId", + "keyAgreement": { + "@id": "sec:keyAgreementMethod", + "@type": "@id", + "@container": "@set", + }, "publicKey": {"@id": "sec:publicKey", "@type": "@id", "@container": "@set"}, - "publicKeyPem": "sec:publicKeyPem", - "requiredProof": "sec:requiredProof", - "revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"}, - "seeAlso": {"@id": "rdfs:seeAlso", "@type": "@id"}, - "signature": "sec:signature", - "signatureAlgorithm": "sec:signatureAlgorithm", - "signatureValue": "sec:signatureValue", + "publicKeyBase58": "sec:publicKeyBase58", + "publicKeyJwk": {"@id": "sec:publicKeyJwk", "@type": "@json"}, + "service": {"@id": "didns:service", "@type": "@id", "@container": "@set"}, + "serviceEndpoint": {"@id": "didns:serviceEndpoint", "@type": "@id"}, + "updated": {"@id": "dc:modified", "@type": "xsd:dateTime"}, + "verificationMethod": {"@id": "sec:verificationMethod", "@type": "@id"}, } } \ No newline at end of file diff --git a/aries_cloudagent/vc/tests/dids.py b/aries_cloudagent/vc/tests/dids.py index 372f9acc39..faf5d1542d 100644 --- a/aries_cloudagent/vc/tests/dids.py +++ b/aries_cloudagent/vc/tests/dids.py @@ -1,5 +1,5 @@ DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL = { - "@context": "https://w3id.org/did/v1", + "@context": "https://www.w3.org/ns/did/v1", "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", "verificationMethod": [ { diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index d01ef0350f..57fc13950d 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -166,6 +166,9 @@ async def verify_presentation( indicates whether the verification was successful """ + # TODO: I think we should add some sort of options to authenticate the subject id + # to the presentation verification method controller + try: return await _verify_presentation( presentation=presentation, From 808112e7b157a650b490a30ba7562647ecea74e7 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 22 Mar 2021 22:11:11 +0100 Subject: [PATCH 031/138] fix incorrect import Signed-off-by: Timo Glastra --- .../protocols/issue_credential/v2_0/formats/ld_proof/handler.py | 1 - 1 file changed, 1 deletion(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index 24428ff837..39aca265d3 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -13,7 +13,6 @@ from ......vc.ld_proofs import ( Ed25519Signature2018, Ed25519WalletKeyPair, - default_document_loader, LinkedDataProof, CredentialIssuancePurpose, ProofPurpose, From 3b5cf34ff6c61d8f98d74218b3b662e3bcd7641b Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 22 Mar 2021 22:40:37 +0100 Subject: [PATCH 032/138] sequence should be string Signed-off-by: Timo Glastra --- aries_cloudagent/protocols/issue_credential/v2_0/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py index 67fc222c33..6be4a60e0f 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py @@ -132,7 +132,7 @@ async def create_proposal( filters_attach=[attach for (_, attach) in formats], ) - cred_ex_record.thread_id = (cred_proposal_message._thread_id,) + cred_ex_record.thread_id = cred_proposal_message._thread_id cred_ex_record.cred_proposal = cred_proposal_message.serialize() cred_proposal_message.assign_trace_decorator(self._profile.settings, trace) From 9877d923b4fa08b49996d4f2915b747c95674398 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 22 Mar 2021 23:17:39 +0100 Subject: [PATCH 033/138] check dict vs class instances Signed-off-by: Timo Glastra --- .../v2_0/formats/ld_proof/handler.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index 39aca265d3..32fc09c1b1 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -254,22 +254,19 @@ async def receive_request( ) -> None: # If we sent an offer, check if request matches this if cred_ex_record.cred_offer: - cred_request_detail = LDProofVCDetail.deserialize( - cred_request_message.attachment(self.format) - ) - - cred_offer_detail = LDProofVCDetail.deserialize( - V20CredOffer.deserialize(cred_ex_record.cred_offer).attachment( - self.format - ) - ) + request_detail = cred_request_message.attachment(self.format) + offer_detail = V20CredOffer.deserialize( + cred_ex_record.cred_offer + ).attachment(self.format) # TODO: probably some fields can be different # so maybe do partial check? # e.g. options.challenge may be filled in request # OR credentialSubject.id # TODO: Send problem report if no match? - assert cred_offer_detail == cred_request_detail + # TODO: implement __eq__ for all descendant classes + # assert cred_offer_detail == cred_request_detail + assert offer_detail == request_detail async def issue_credential( self, cred_ex_record: V20CredExRecord, retries: int = 5 From 8b3dde6227be2c2af376889dc66965bff4b45d26 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 22 Mar 2021 23:34:06 +0100 Subject: [PATCH 034/138] await result with details Signed-off-by: Timo Glastra --- .../v2_0/formats/ld_proof/models/cred_detail.py | 6 ++++++ aries_cloudagent/protocols/issue_credential/v2_0/routes.py | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py index a72cb33dea..074bc1870e 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py @@ -104,3 +104,9 @@ def serialize(self) -> dict: schema = LDProofVCDetailSchema() detail: dict = schema.dump(copy.deepcopy(self)) return detail + + def __eq__(self, other: object) -> bool: + """Comparison between linked data vc details.""" + if isinstance(other, LDProofVCDetail): + return self.credential == other.credential and self.options == other.options + return False diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py index 8047b2a4ee..fadac37b2d 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py @@ -1209,7 +1209,7 @@ async def credential_exchange_issue(request: web.BaseRequest): comment=comment, ) - result = _get_result_with_details(context.profile, cred_ex_record) + result = await _get_result_with_details(context.profile, cred_ex_record) except (BaseModelError, V20CredManagerError, IndyIssuerError, StorageError) as err: await internal_error( @@ -1284,7 +1284,7 @@ async def credential_exchange_store(request: web.BaseRequest): cred_id, ) - result = _get_result_with_details(context.profile, cred_ex_record) + result = await _get_result_with_details(context.profile, cred_ex_record) except (StorageError, V20CredManagerError, BaseModelError) as err: await internal_error( From 8a0f511eca2e3f2190f4aa172a027da35db7699e Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 22 Mar 2021 23:40:23 +0100 Subject: [PATCH 035/138] fix: unhashable type: 'list' for subject ids Signed-off-by: Timo Glastra --- .../protocols/issue_credential/v2_0/formats/ld_proof/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index 32fc09c1b1..b5091f9ef1 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -334,7 +334,7 @@ async def store_credential( contexts=credential.context_urls, types=credential.type, issuer_id=credential.issuer_id, - subject_ids=[credential.credential_subject_ids], + subject_ids=credential.credential_subject_ids, schema_ids=[], # Schemas not supported yet value=json.dumps(credential.serialize()), given_id=credential.id, From 6b417fb851cb348904f0dc21f38366aa93b17a72 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Tue, 23 Mar 2021 00:00:36 +0100 Subject: [PATCH 036/138] store credential id Signed-off-by: Timo Glastra --- .../issue_credential/v2_0/formats/ld_proof/handler.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index b5091f9ef1..d44bdbc842 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -335,9 +335,12 @@ async def store_credential( types=credential.type, issuer_id=credential.issuer_id, subject_ids=credential.credential_subject_ids, - schema_ids=[], # Schemas not supported yet + # schema_ids=[], Schemas not supported yet value=json.dumps(credential.serialize()), given_id=credential.id, record_id=cred_id, ) await vc_holder.store_credential(vc_record) + + # TODO: doesn't work with multiple attachments + cred_ex_record.cred_id_stored = vc_record.record_id From 2d7cb982ee999739f8dd5b181ce2fc04837f7445 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Tue, 23 Mar 2021 00:06:23 +0100 Subject: [PATCH 037/138] schema ids requird property Signed-off-by: Timo Glastra --- .../protocols/issue_credential/v2_0/formats/ld_proof/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index d44bdbc842..659ba345b3 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -335,7 +335,7 @@ async def store_credential( types=credential.type, issuer_id=credential.issuer_id, subject_ids=credential.credential_subject_ids, - # schema_ids=[], Schemas not supported yet + schema_ids=[], # Schemas not supported yet value=json.dumps(credential.serialize()), given_id=credential.id, record_id=cred_id, From bfb118e66155455f05c01c21e7b301a78559a4d0 Mon Sep 17 00:00:00 2001 From: Shaanjot Gill Date: Tue, 23 Mar 2021 07:44:32 -0700 Subject: [PATCH 038/138] DIF PresExch Impl Signed-off-by: Shaanjot Gill --- .../presentation_exchange/__init__.py | 0 .../presentation_exchange/pres_exch.py | 1000 +++++++++++++ .../pres_exch_handler.py | 828 +++++++++++ .../presentation_exchange/tests/__init__.py | 0 .../presentation_exchange/tests/test_data.py | 606 ++++++++ .../tests/test_pres_exch.py | 552 ++++++++ .../tests/test_pres_exch_handler.py | 1231 +++++++++++++++++ .../storage/vc_holder/tests/test_vc_record.py | 98 ++ .../storage/vc_holder/vc_record.py | 84 ++ requirements.txt | 3 + 10 files changed, 4402 insertions(+) create mode 100644 aries_cloudagent/protocols/present_proof/presentation_exchange/__init__.py create mode 100644 aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch.py create mode 100644 aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py create mode 100644 aries_cloudagent/protocols/present_proof/presentation_exchange/tests/__init__.py create mode 100644 aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_data.py create mode 100644 aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_pres_exch.py create mode 100644 aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_pres_exch_handler.py diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/__init__.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch.py new file mode 100644 index 0000000000..1f56618daf --- /dev/null +++ b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch.py @@ -0,0 +1,1000 @@ +"""Schemas for dif presentation exchange attachment.""" +from marshmallow import ( + fields, + validate, + EXCLUDE, + pre_load, + post_dump, + ValidationError, +) +from typing import Sequence, Union + +from ....messaging.models.base import BaseModelSchema, BaseModel +from ....messaging.valid import ( + UUID4, +) + + +class ClaimFormat(BaseModel): + """Defines Claim field.""" + + class Meta: + """ClaimFormat metadata.""" + + schema_class = "ClaimFormatSchema" + + def __init__( + self, + *, + jwt_format_data: Sequence[str] = None, + jwt_vc_format_data: Sequence[str] = None, + jwt_vp_format_data: Sequence[str] = None, + ldp_format_data: Sequence[str] = None, + ldp_vc_format_data: Sequence[str] = None, + ldp_vp_format_data: Sequence[str] = None, + ): + """Initialize format.""" + self.jwt_format_data = jwt_format_data + self.jwt_vc_format_data = jwt_vc_format_data + self.jwt_vp_format_data = jwt_vp_format_data + self.ldp_format_data = ldp_format_data + self.ldp_vc_format_data = ldp_vc_format_data + self.ldp_vp_format_data = ldp_vp_format_data + + +class ClaimFormatSchema(BaseModelSchema): + """Single ClaimFormat Schema.""" + + class Meta: + """ClaimFormatSchema metadata.""" + + model_class = ClaimFormat + unknown = EXCLUDE + + jwt_format_data = fields.List( + fields.Str(required=False), + required=False, + data_key="jwt", + ) + jwt_vc_format_data = fields.List( + fields.Str(required=False), + required=False, + data_key="jwt_vc", + ) + jwt_vp_format_data = fields.List( + fields.Str(required=False), + required=False, + data_key="jwt_vp", + ) + ldp_format_data = fields.List( + fields.Str(required=False), + required=False, + data_key="ldp", + ) + ldp_vc_format_data = fields.List( + fields.Str(required=False), + required=False, + data_key="ldp_vc", + ) + ldp_vp_format_data = fields.List( + fields.Str(required=False), + required=False, + data_key="ldp_vp", + ) + + @pre_load + def extract_format_info(self, data, **kwargs): + """Support deserialization from format dict in pd (DIF spec).""" + if "jwt" in data: + data["jwt"] = data["jwt"].pop("alg") + if "jwt_vc" in data: + data["jwt_vc"] = data["jwt_vc"].pop("alg") + if "jwt_vp" in data: + data["jwt_vp"] = data["jwt_vp"].pop("alg") + if "ldp" in data: + data["ldp"] = data["ldp"].pop("proof_type") + if "ldp_vc" in data: + data["ldp_vc"] = data["ldp_vc"].pop("proof_type") + if "ldp_vp" in data: + data["ldp_vp"] = data["ldp_vp"].pop("proof_type") + return data + + @post_dump + def serialize_reformat(self, data, **kwargs): + """Support serialization to format dict (DIF spec).""" + new_data = {} + if "jwt" in data: + new_data["jwt"] = {"alg": data.get("jwt")} + if "jwt_vc" in data: + new_data["jwt_vc"] = {"alg": data.get("jwt_vc")} + if "jwt_vp" in data: + new_data["jwt_vp"] = {"alg": data.get("jwt_vp")} + if "ldp" in data: + new_data["ldp"] = {"proof_type": data.get("ldp")} + if "ldp_vc" in data: + new_data["ldp_vc"] = {"proof_type": data.get("ldp_vc")} + if "ldp_vp" in data: + new_data["ldp_vp"] = {"proof_type": data.get("ldp_vp")} + return new_data + + +class SubmissionRequirements(BaseModel): + """describes input to be submitted via a presentation submission.""" + + class Meta: + """SubmissionRequirements metadata.""" + + schema_class = "SubmissionRequirementsSchema" + + def __init__( + self, + *, + _name: str = None, + purpose: str = None, + rule: str = None, + count: int = None, + minimum: int = None, + maximum: int = None, + _from: str = None, + # Self_reference + from_nested: Sequence = None, + ): + """Initialize SubmissionRequirement.""" + self._name = _name + self.purpose = purpose + self.rule = rule + self.count = count + self.minimum = minimum + self.maximum = maximum + self._from = _from + self.from_nested = from_nested + + +class SubmissionRequirementsSchema(BaseModelSchema): + """Single Presentation Definition Schema.""" + + class Meta: + """SubmissionRequirementsSchema metadata.""" + + model_class = SubmissionRequirements + unknown = EXCLUDE + + _name = fields.Str(description="Name", required=False, data_key="name") + purpose = fields.Str(description="Purpose", required=False, data_key="purpose") + rule = fields.Str( + description="Selection", + required=False, + validate=validate.OneOf(["all", "pick"]), + data_key="rule", + ) + count = fields.Int( + description="Count Value", + example=1234, + required=False, + strict=True, + data_key="count", + ) + minimum = fields.Int( + description="Min Value", + example=1234, + required=False, + strict=True, + data_key="min", + ) + maximum = fields.Int( + description="Max Value", + example=1234, + required=False, + strict=True, + data_key="max", + ) + _from = fields.Str(description="From", required=False, data_key="from") + # Self References + from_nested = fields.List( + fields.Nested(lambda: SubmissionRequirementsSchema(exclude=("from_nested",))), + required=False, + data_key="from_nested", + ) + + @pre_load + def validate_from(self, data, **kwargs): + """Support validation of from and from_nested.""" + if "from" in data and "from_nested" in data: + raise ValidationError( + "Both from and from_nested cannot be " + "specified in the submission requirement" + ) + if "from" not in data and "from_nested" not in data: + raise ValidationError( + "Either from or from_nested needs to be " + "specified in the submission requirement" + ) + return data + + +class SchemaInputDescriptor(BaseModel): + """SchemaInputDescriptor.""" + + class Meta: + """SchemaInputDescriptor metadata.""" + + schema_class = "SchemaInputDescriptorSchema" + + def __init__( + self, + *, + uri: str = None, + required: bool = False, + ): + """Initialize InputDescriptors.""" + self.uri = uri + self.required = required + + +class SchemaInputDescriptorSchema(BaseModelSchema): + """Single SchemaField Schema.""" + + class Meta: + """SchemaInputDescriptorSchema metadata.""" + + model_class = SchemaInputDescriptor + unknown = EXCLUDE + + uri = fields.Str( + description="URI", + required=False, + data_key="uri", + ) + required = fields.Bool(description="Required", required=False, data_key="required") + + +class Holder(BaseModel): + """Single Holder object for Constraints.""" + + class Meta: + """Holder metadata.""" + + schema_class = "HolderSchema" + + def __init__( + self, + *, + field_ids: Sequence[str] = None, + directive: str = None, + ): + """Initialize Holder.""" + self.field_ids = field_ids + self.directive = directive + + +class HolderSchema(BaseModelSchema): + """Single Holder Schema.""" + + class Meta: + """HolderSchema metadata.""" + + model_class = Holder + unknown = EXCLUDE + + field_ids = fields.List( + fields.Str( + description="FieldID", + required=False, + **UUID4, + ), + required=False, + data_key="field_id", + ) + directive = fields.Str( + description="Preference", + required=False, + validate=validate.OneOf(["required", "preferred"]), + data_key="directive", + ) + + +# Union of str or int or float +class StrOrNumberField(fields.Field): + """Custom Marshmallow field - union of str, int and float.""" + + def _deserialize(self, value, attr, data, **kwargs): + """Return value if type is str, float or int else raise ValidationError.""" + if isinstance(value, str) or isinstance(value, float) or isinstance(value, int): + return value + else: + raise ValidationError("Field should be str or int or float") + + +class Filter(BaseModel): + """Single Filter for the Constraint object.""" + + class Meta: + """Filter metadata.""" + + schema_class = "FilterSchema" + + def __init__( + self, + *, + _not: bool = False, + _type: str = None, + fmt: str = None, + pattern: str = None, + minimum: str = None, + maximum: str = None, + min_length: int = None, + max_length: int = None, + exclusive_min: str = None, + exclusive_max: str = None, + const: str = None, + enums: Sequence[str] = None, + ): + """Initialize Filter.""" + self._type = _type + self.fmt = fmt + self.pattern = pattern + self.minimum = minimum + self.maximum = maximum + self.min_length = min_length + self.max_length = max_length + self.exclusive_min = exclusive_min + self.exclusive_max = exclusive_max + self.const = const + self.enums = enums + self._not = _not + + +class FilterSchema(BaseModelSchema): + """Single Filter Schema.""" + + class Meta: + """FilterSchema metadata.""" + + model_class = Filter + unknown = EXCLUDE + + _type = fields.Str(description="Type", required=False, data_key="type") + fmt = fields.Str( + description="Format", + required=False, + data_key="format", + ) + pattern = fields.Str( + description="Pattern", + required=False, + data_key="pattern", + ) + minimum = StrOrNumberField( + description="Minimum", + required=False, + data_key="minimum", + ) + maximum = StrOrNumberField( + description="Maximum", + required=False, + data_key="maximum", + ) + min_length = fields.Int( + description="Min Length", + example=1234, + strict=True, + required=False, + data_key="minLength", + ) + max_length = fields.Int( + description="Max Length", + example=1234, + strict=True, + required=False, + data_key="maxLength", + ) + exclusive_min = StrOrNumberField( + description="ExclusiveMinimum", + required=False, + data_key="exclusiveMinimum", + ) + exclusive_max = StrOrNumberField( + description="ExclusiveMaximum", + required=False, + data_key="exclusiveMaximum", + ) + const = StrOrNumberField( + description="Const", + required=False, + data_key="const", + ) + enums = fields.List( + StrOrNumberField(description="Enum", required=False), + required=False, + data_key="enum", + ) + _not = fields.Boolean( + description="Not", + required=False, + example=False, + data_key="not", + ) + + @pre_load + def extract_info(self, data, **kwargs): + """Enum validation and not filter logic.""" + if "not" in data: + new_data = {"not": True} + for key, value in data.get("not").items(): + new_data[key] = value + data = new_data + if "enum" in data: + if type(data.get("enum")) is not list: + raise ValidationError("enum is not specified as a list") + return data + + @post_dump + def serialize_reformat(self, data, **kwargs): + """Support serialization of not filter according to DIF spec.""" + if data.pop("not", False): + return {"not": data} + + return data + + +class Field(BaseModel): + """Single Field object for the Constraint.""" + + class Meta: + """Field metadata.""" + + schema_class = "FieldSchema" + + def __init__( + self, + *, + paths: Sequence[str] = None, + purpose: str = None, + predicate: str = None, + _filter: Filter = None, + ): + """Initialize Field.""" + self.paths = paths + self.purpose = purpose + self.predicate = predicate + self._filter = _filter + + +class FieldSchema(BaseModelSchema): + """Single Field Schema.""" + + class Meta: + """FieldSchema metadata.""" + + model_class = Field + unknown = EXCLUDE + + paths = fields.List( + fields.Str(description="Path", required=False), + required=False, + data_key="path", + ) + purpose = fields.Str( + description="Purpose", + required=False, + data_key="purpose", + ) + predicate = fields.Str( + description="Preference", + required=False, + validate=validate.OneOf(["required", "preferred"]), + data_key="predicate", + ) + _filter = fields.Nested(FilterSchema, data_key="filter") + + +class Constraints(BaseModel): + """Single Constraints which describes InputDescriptor's Contraint field.""" + + class Meta: + """Constraints metadata.""" + + schema_class = "ConstraintsSchema" + + def __init__( + self, + *, + subject_issuer: str = None, + limit_disclosure: bool = None, + holders: Sequence[Holder] = None, + _fields: Sequence[Field] = None, + status_active: str = None, + status_suspended: str = None, + status_revoked: str = None, + ): + """Initialize Constraints for Input Descriptor.""" + self.subject_issuer = subject_issuer + self.limit_disclosure = limit_disclosure + self.holders = holders + self._fields = _fields + self.status_active = status_active + self.status_suspended = status_suspended + self.status_revoked = status_revoked + + +class ConstraintsSchema(BaseModelSchema): + """Single Constraints Schema.""" + + class Meta: + """ConstraintsSchema metadata.""" + + model_class = Constraints + unknown = EXCLUDE + + subject_issuer = fields.Str( + description="SubjectIsIssuer", + required=False, + validate=validate.OneOf(["required", "preferred"]), + data_key="subject_is_issuer", + ) + limit_disclosure = fields.Bool( + description="LimitDisclosure", required=False, data_key="limit_disclosure" + ) + holders = fields.List( + fields.Nested(HolderSchema), + required=False, + data_key="is_holder", + ) + _fields = fields.List( + fields.Nested(FieldSchema), + required=False, + data_key="fields", + ) + status_active = fields.Str( + required=False, + validate=validate.OneOf(["required", "allowed", "disallowed"]), + ) + status_suspended = fields.Str( + required=False, + validate=validate.OneOf(["required", "allowed", "disallowed"]), + ) + status_revoked = fields.Str( + required=False, + validate=validate.OneOf(["required", "allowed", "disallowed"]), + ) + + @pre_load + def extract_info(self, data, **kwargs): + """Support deserialization of statuses according to DIF spec.""" + if "statuses" in data: + if "active" in data.get("statuses"): + if "directive" in data.get("statuses").get("active"): + data["status_active"] = data["statuses"]["active"]["directive"] + if "suspended" in data.get("statuses"): + if "directive" in data.get("statuses").get("suspended"): + data["status_suspended"] = data["statuses"]["suspended"][ + "directive" + ] + if "revoked" in data.get("statuses"): + if "directive" in data.get("statuses").get("revoked"): + data["status_revoked"] = data["statuses"]["revoked"]["directive"] + return data + + @post_dump + def reformat_data(self, data, **kwargs): + """Support serialization of statuses according to DIF spec.""" + if "status_active" in data: + tmp_dict = {} + tmp_dict["directive"] = data.get("status_active") + tmp_dict2 = data.get("statuses") or {} + tmp_dict2["active"] = tmp_dict + data["statuses"] = tmp_dict2 + del data["status_active"] + if "status_suspended" in data: + tmp_dict = {} + tmp_dict["directive"] = data.get("status_suspended") + tmp_dict2 = data.get("statuses") or {} + tmp_dict2["suspended"] = tmp_dict + data["statuses"] = tmp_dict2 + del data["status_suspended"] + if "status_revoked" in data: + tmp_dict = {} + tmp_dict["directive"] = data.get("status_revoked") + tmp_dict2 = data.get("statuses") or {} + tmp_dict2["revoked"] = tmp_dict + data["statuses"] = tmp_dict2 + del data["status_revoked"] + return data + + +class InputDescriptors(BaseModel): + """Input Descriptors.""" + + class Meta: + """InputDescriptors metadata.""" + + schema_class = "InputDescriptorsSchema" + + def __init__( + self, + *, + _id: str = None, + groups: Sequence[str] = None, + name: str = None, + purpose: str = None, + metadata: dict = None, + constraint: Constraints = None, + schemas: Sequence[SchemaInputDescriptor] = None, + ): + """Initialize InputDescriptors.""" + self._id = _id + self.groups = groups + self.name = name + self.purpose = purpose + self.metadata = metadata + self.constraint = constraint + self.schemas = schemas + + +class InputDescriptorsSchema(BaseModelSchema): + """Single InputDescriptors Schema.""" + + class Meta: + """InputDescriptorsSchema metadata.""" + + model_class = InputDescriptors + unknown = EXCLUDE + + _id = fields.Str(description="ID", required=False, data_key="id") + groups = fields.List( + fields.Str( + description="Group", + required=False, + ), + required=False, + data_key="group", + ) + name = fields.Str(description="Name", required=False, data_key="name") + purpose = fields.Str(description="Purpose", required=False, data_key="purpose") + metadata = fields.Dict( + description="Metadata dictionary", required=False, data_key="metadata" + ) + constraint = fields.Nested( + ConstraintsSchema, required=False, data_key="constraints" + ) + schemas = fields.List( + fields.Nested(SchemaInputDescriptorSchema), required=False, data_key="schema" + ) + + +class Requirement(BaseModel): + """Single Requirement generated from toRequirement function.""" + + class Meta: + """Requirement metadata.""" + + schema_class = "RequirementSchema" + + def __init__( + self, + *, + count: int = None, + maximum: int = None, + minimum: int = None, + input_descriptors: Sequence[InputDescriptors] = None, + nested_req: Sequence = None, + ): + """Initialize Requirement.""" + self.count = count + self.maximum = maximum + self.minimum = minimum + self.input_descriptors = input_descriptors + self.nested_req = nested_req + + +class RequirementSchema(BaseModelSchema): + """Single Requirement Schema.""" + + class Meta: + """RequirementSchema metadata.""" + + model_class = Requirement + unknown = EXCLUDE + + count = fields.Int( + description="Count Value", + example=1234, + strict=True, + required=False, + ) + maximum = fields.Int( + description="Max Value", + example=1234, + strict=True, + required=False, + ) + minimum = fields.Int( + description="Min Value", + example=1234, + strict=True, + required=False, + ) + input_descriptors = fields.List( + fields.Nested(InputDescriptorsSchema), + required=False, + ) + # Self References + nested_req = fields.List( + fields.Nested(lambda: RequirementSchema(exclude=("_nested_req",))), + required=False, + ) + + +class PresentationDefinition(BaseModel): + """https://identity.foundation/presentation-exchange/.""" + + class Meta: + """PresentationDefinition metadata.""" + + schema_class = "PresentationDefinitionSchema" + + def __init__( + self, + *, + _id: str = None, + name: str = None, + purpose: str = None, + fmt: ClaimFormat = None, + submission_requirements: Sequence[SubmissionRequirements] = None, + input_descriptors: Sequence[InputDescriptors] = None, + **kwargs, + ): + """Initialize flattened single-JWS to include in attach decorator data.""" + super().__init__(**kwargs) + self._id = _id + self.name = name + self.purpose = purpose + self.fmt = fmt + self.submission_requirements = submission_requirements + self.input_descriptors = input_descriptors + + +class PresentationDefinitionSchema(BaseModelSchema): + """Single Presentation Definition Schema.""" + + class Meta: + """PresentationDefinitionSchema metadata.""" + + model_class = PresentationDefinition + unknown = EXCLUDE + + _id = fields.Str( + required=False, + description="Unique Resource Identifier", + **UUID4, + data_key="id", + ) + name = fields.Str( + description=( + "Human-friendly name that describes" + " what the presentation definition pertains to" + ), + required=False, + data_key="name", + ) + purpose = fields.Str( + description=( + "Describes the purpose for which" + " the Presentation Definition's inputs are being requested" + ), + required=False, + data_key="purpose", + ) + fmt = fields.Nested( + ClaimFormatSchema, + required=False, + data_key="format", + ) + submission_requirements = fields.List( + fields.Nested(SubmissionRequirementsSchema), + required=False, + data_key="submission_requirements", + ) + input_descriptors = fields.List( + fields.Nested(InputDescriptorsSchema), + required=False, + data_key="input_descriptors", + ) + + +class InputDescriptorMapping(BaseModel): + """Single InputDescriptorMapping object.""" + + class Meta: + """InputDescriptorMapping metadata.""" + + schema_class = "InputDescriptorMappingSchema" + + def __init__( + self, + *, + _id: str = None, + fmt: str = None, + path: str = None, + ): + """Initialize InputDescriptorMapping.""" + self._id = _id + self.fmt = fmt + self.path = path + + +class InputDescriptorMappingSchema(BaseModelSchema): + """Single InputDescriptorMapping Schema.""" + + class Meta: + """InputDescriptorMappingSchema metadata.""" + + model_class = InputDescriptorMapping + unknown = EXCLUDE + + _id = fields.Str( + description="ID", + required=False, + data_key="id", + ) + fmt = fields.Str( + description="Format", + required=False, + default="ldp_vp", + data_key="format", + ) + path = fields.Str( + description="Path", + required=False, + data_key="path", + ) + + +class PresentationSubmission(BaseModel): + """Single PresentationSubmission object.""" + + class Meta: + """PresentationSubmission metadata.""" + + schema_class = "PresentationSubmissionSchema" + + def __init__( + self, + *, + _id: str = None, + definition_id: str = None, + descriptor_maps: Sequence[InputDescriptorMapping] = None, + ): + """Initialize InputDescriptorMapping.""" + self._id = _id + self.definition_id = definition_id + self.descriptor_maps = descriptor_maps + + +class PresentationSubmissionSchema(BaseModelSchema): + """Single PresentationSubmission Schema.""" + + class Meta: + """PresentationSubmissionSchema metadata.""" + + model_class = PresentationSubmission + unknown = EXCLUDE + + _id = fields.Str( + description="ID", + required=False, + **UUID4, + data_key="id", + ) + definition_id = fields.Str( + description="DefinitionID", + required=False, + **UUID4, + data_key="definition_id", + ) + descriptor_maps = fields.List( + fields.Nested(InputDescriptorMappingSchema), + required=False, + data_key="descriptor_map", + ) + + +# Union of str or dict +class StrOrDictField(fields.Field): + """Custom Marshmallow field - union of str and dict.""" + + def _deserialize(self, value, attr, data, **kwargs): + """Return value if type is str or dict else raise ValidationError.""" + if isinstance(value, str) or isinstance(value, dict): + return value + else: + raise ValidationError("Field should be str or dict") + + +class VerifiablePresentation(BaseModel): + """Single VerifiablePresentation object.""" + + class Meta: + """VerifiablePresentation metadata.""" + + schema_class = "VerifiablePresentationSchema" + + def __init__( + self, + *, + _id: str = None, + contexts: Sequence[Union[str, dict]] = None, + types: Sequence[str] = None, + credentials: Sequence[dict] = None, + holder: str = None, + proofs: Sequence[dict] = None, + tags: dict = None, + presentation_submission: PresentationSubmission = None, + ): + """Initialize VerifiablePresentation.""" + self._id = _id + self.contexts = contexts + self.types = types + self.credentials = credentials + self.holder = holder + self.proofs = proofs + self.tags = tags + self.presentation_submission = presentation_submission + + +class VerifiablePresentationSchema(BaseModelSchema): + """Single Field Schema.""" + + class Meta: + """VerifiablePresentationSchema metadata.""" + + model_class = VerifiablePresentation + unknown = EXCLUDE + + _id = fields.Str( + description="ID", + required=False, + **UUID4, + data_key="id", + ) + contexts = fields.List( + StrOrDictField(), + data_key="@context", + ) + types = fields.List( + fields.Str(description="Types", required=False), + data_key="type", + ) + credentials = fields.List( + fields.Dict(description="Credentials", required=False), + data_key="verifiableCredential", + ) + holder = fields.Str( + description="Holder", + required=False, + data_key="holder", + ) + proofs = fields.List( + fields.Dict(description="Proofs", required=False), + data_key="proof", + ) + tags = fields.Dict(description="Tags", required=False) + presentation_submission = fields.Nested( + PresentationSubmissionSchema, data_key="presentation_submission" + ) + + @pre_load + def extract_info(self, data, **kwargs): + """Support deserialization of W3C spec VP.""" + if "proof" in data: + if type(data.get("proof")) is not list: + data["proof"] = [data.get("proof")] + + return data + + @post_dump + def reformat_data(self, data, **kwargs): + """Support serialization to W3C spec VP and remove id.""" + if "id" in data: + del data["id"] + return data diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py new file mode 100644 index 0000000000..c9bb0fa893 --- /dev/null +++ b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py @@ -0,0 +1,828 @@ +""" +Utilities for dif presentation exchange attachment. + +General Flow: +create_vp -> +make_requirement [create a Requirement from SubmissionRequirements and Descriptors] -> +apply_requirement [filter credentials] -> +merge [return applicable credential list and descriptor_map for presentation_submission] +returns VerifiablePresentation +""" +import json +import pytz +import re + +from dateutil.parser import parse as dateutil_parser +from jsonpath_ng import parse +from typing import Sequence, Optional +from uuid import uuid4 + +from ....core.error import BaseError +from ....storage.vc_holder.vc_record import VCRecord + +from .pres_exch import ( + PresentationDefinition, + InputDescriptors, + Field, + Filter, + Constraints, + SubmissionRequirements, + Requirement, + SchemaInputDescriptor, + VerifiablePresentation, + InputDescriptorMapping, + PresentationSubmission, +) + + +class PresentationExchError(BaseError): + """Base class for DIF Presentation Exchange related errors.""" + + +CREDENTIAL_JSONLD_CONTEXT = "https://www.w3.org/2018/credentials/v1" +PRESENTATION_SUBMISSION_JSONLD_CONTEXT = ( + "https://identity.foundation/presentation-exchange/submission/v1" +) +VERIFIABLE_PRESENTATION_JSONLD_TYPE = "VerifiablePresentation" +PRESENTATION_SUBMISSION_JSONLD_TYPE = "PresentationSubmission" + + +async def to_requirement( + sr: SubmissionRequirements, descriptors: Sequence[InputDescriptors] +) -> Requirement: + """ + Return Requirement. + + Args: + sr: submission_requirement + descriptors: list of input_descriptors + Raises: + PresentationExchError: If not able to create requirement + + """ + input_descriptors = [] + nested = [] + total_count = 0 + + if sr._from: + if sr._from != "": + for descriptor in descriptors: + if contains(descriptor.groups, sr._from): + input_descriptors.append(descriptor) + total_count = len(input_descriptors) + if total_count == 0: + raise PresentationExchError(f"No descriptors for from: {sr._from}") + else: + for submission_requirement in sr.from_nested: + try: + # recursion logic + requirement = await to_requirement(submission_requirement, descriptors) + nested.append(requirement) + except Exception as err: + raise PresentationExchError( + ( + "Error creating requirement from " + f"nested submission_requirements, {err}" + ) + ) + total_count = len(nested) + count = sr.count + if sr.rule == "all": + count = total_count + requirement = Requirement( + count=count, + maximum=sr.maximum, + minimum=sr.minimum, + input_descriptors=input_descriptors, + nested_req=nested, + ) + return requirement + + +async def make_requirement( + srs: Sequence[SubmissionRequirements] = None, + descriptors: Sequence[InputDescriptors] = None, +) -> Requirement: + """ + Return Requirement. + + Creates and return Requirement with nesting if required + using to_requirement() + + Args: + srs: list of submission_requirements + descriptors: list of input_descriptors + Raises: + PresentationExchError: If not able to create requirement + + """ + if not srs: + srs = [] + if not descriptors: + descriptors = [] + if len(srs) == 0: + requirement = Requirement( + count=len(descriptors), + input_descriptors=descriptors, + ) + return requirement + requirement = Requirement( + count=len(srs), + nested_req=[], + ) + for submission_requirement in srs: + try: + requirement.nested_req.append( + await to_requirement(submission_requirement, descriptors) + ) + except Exception as err: + raise PresentationExchError( + "Error creating requirement " f"inside to_requirement function, {err}" + ) + return requirement + + +def is_len_applicable(req: Requirement, val: int) -> bool: + """ + Check and validate requirement minimum, maximum and count. + + Args: + req: Requirement + val: int value to check + Return: + bool + + """ + if req.count: + if req.count > 0 and val != req.count: + return False + if req.minimum: + if req.minimum > 0 and req.minimum > val: + return False + if req.maximum: + if req.maximum > 0 and req.maximum < val: + return False + return True + + +def contains(data: Sequence[str], e: str) -> bool: + """ + Check for e in data. + + Returns True if e exists in data else return False + + Args: + data: Sequence of str + e: str value to check + Return: + bool + + """ + data_list = list(data) if data else [] + for k in data_list: + if e == k: + return True + return False + + +async def filter_constraints( + constraints: Constraints, credentials: Sequence[VCRecord] +) -> Sequence[VCRecord]: + """ + Return list of applicable VCRecords after applying filtering. + + Args: + constraints: Constraints + credentials: Sequence of credentials + to apply filtering on + Return: + Sequence of applicable VCRecords + + """ + result = [] + for credential in credentials: + if constraints.subject_issuer == "required" and not await subject_is_issuer( + credential=credential + ): + continue + + applicable = False + predicate = False + for field in constraints._fields: + applicable = await filter_by_field(field, credential) + if field.predicate == "required": + predicate = True + if applicable: + break + if not applicable: + continue + + # TODO: create new credential with selective disclosure + if constraints.limit_disclosure or predicate: + raise PresentationExchError("Not yet implemented - createNewCredential") + + result.append(credential) + return result + + +async def filter_by_field(field: Field, credential: VCRecord) -> bool: + """ + Apply filter on VCRecord. + + Checks if a credential is applicable + + Args: + field: Field contains filtering spec + credential: credential to apply filtering on + Return: + bool + + """ + credential_dict = json.loads(credential.value) + for path in field.paths: + jsonpath = parse(path) + match = jsonpath.find(credential_dict) + if len(match) == 0: + continue + for match_item in match: + if validate_patch(match_item.value, field._filter): + return True + return False + + +def validate_patch(to_check: any, _filter: Filter) -> bool: + """ + Apply filter on match_value. + + Utility function used in applying filtering to a cred + by triggering checks according to filter specification + + Args: + to_check: value to check, extracted from match + _filter: Filter + Return: + bool + + """ + return_val = None + if _filter._type: + if _filter._type == "number": + return_val = process_numeric_val(to_check, _filter) + elif _filter._type == "string": + return_val = process_string_val(to_check, _filter) + else: + if _filter.enums: + return_val = enum_check(val=to_check, _filter=_filter) + if _filter.const: + return_val = const_check(val=to_check, _filter=_filter) + + if _filter._not: + return not return_val + else: + return return_val + + +def process_numeric_val(val: any, _filter: Filter) -> bool: + """ + Trigger Filter checks. + + Trigger appropriate check for a number type filter, + according to _filter spec. + + Args: + val: value to check, extracted from match + _filter: Filter + Return: + bool + + """ + if _filter.exclusive_max: + return exclusive_maximum_check(val, _filter) + elif _filter.exclusive_min: + return exclusive_minimum_check(val, _filter) + elif _filter.minimum: + return minimum_check(val, _filter) + elif _filter.maximum: + return maximum_check(val, _filter) + elif _filter.const: + return const_check(val, _filter) + elif _filter.enums: + return enum_check(val, _filter) + else: + return False + + +def process_string_val(val: any, _filter: Filter) -> bool: + """ + Trigger Filter checks. + + Trigger appropriate check for a string type filter, + according to _filter spec. + + Args: + val: value to check, extracted from match + _filter: Filter + Return: + bool + + """ + if _filter.min_length or _filter.max_length: + return length_check(val, _filter) + elif _filter.pattern: + return pattern_check(val, _filter) + elif _filter.enums: + return enum_check(val, _filter) + elif _filter.exclusive_max: + if _filter.fmt: + return exclusive_maximum_check(val, _filter) + elif _filter.exclusive_min: + if _filter.fmt: + return exclusive_minimum_check(val, _filter) + elif _filter.minimum: + if _filter.fmt: + return minimum_check(val, _filter) + elif _filter.maximum: + if _filter.fmt: + return maximum_check(val, _filter) + elif _filter.const: + return const_check(val, _filter) + else: + return False + + +def exclusive_minimum_check(val: any, _filter: Filter) -> bool: + """ + Exclusiveminimum check. + + Returns True if value greater than filter specified check + + Args: + val: value to check, extracted from match + _filter: Filter + Return: + bool + + """ + try: + if _filter.fmt: + utc = pytz.UTC + if _filter.fmt == "date" or _filter.fmt == "date-time": + to_compare_date = dateutil_parser(_filter.exclusive_min).replace( + tzinfo=utc + ) + given_date = dateutil_parser(str(val)).replace(tzinfo=utc) + return given_date > to_compare_date + else: + if is_numeric(val): + return val > _filter.exclusive_min + return False + except (TypeError, ValueError): + return False + + +def exclusive_maximum_check(val: any, _filter: Filter) -> bool: + """ + Exclusivemaximum check. + + Returns True if value less than filter specified check + + Args: + val: value to check, extracted from match + _filter: Filter + Return: + bool + + """ + try: + if _filter.fmt: + utc = pytz.UTC + if _filter.fmt == "date" or _filter.fmt == "date-time": + to_compare_date = dateutil_parser(_filter.exclusive_max).replace( + tzinfo=utc + ) + given_date = dateutil_parser(str(val)).replace(tzinfo=utc) + return given_date < to_compare_date + else: + if is_numeric(val): + return val < _filter.exclusive_max + return False + except (TypeError, ValueError): + return False + + +def maximum_check(val: any, _filter: Filter) -> bool: + """ + Maximum check. + + Returns True if value less than equal to filter specified check + + Args: + val: value to check, extracted from match + _filter: Filter + Return: + bool + + """ + try: + if _filter.fmt: + utc = pytz.UTC + if _filter.fmt == "date" or _filter.fmt == "date-time": + to_compare_date = dateutil_parser(_filter.maximum).replace(tzinfo=utc) + given_date = dateutil_parser(str(val)).replace(tzinfo=utc) + return given_date <= to_compare_date + else: + if is_numeric(val): + return val <= _filter.maximum + return False + except (TypeError, ValueError): + return False + + +def minimum_check(val: any, _filter: Filter) -> bool: + """ + Minimum check. + + Returns True if value greater than equal to filter specified check + + Args: + val: value to check, extracted from match + _filter: Filter + Return: + bool + + """ + try: + if _filter.fmt: + utc = pytz.UTC + if _filter.fmt == "date" or _filter.fmt == "date-time": + to_compare_date = dateutil_parser(_filter.minimum).replace(tzinfo=utc) + given_date = dateutil_parser(str(val)).replace(tzinfo=utc) + return given_date >= to_compare_date + else: + if is_numeric(val): + return val >= _filter.minimum + return False + except (TypeError, ValueError): + return False + + +def length_check(val: any, _filter: Filter) -> bool: + """ + Length check. + + Returns True if length value string meets the minLength and maxLength specs + + Args: + val: value to check, extracted from match + _filter: Filter + Return: + bool + + """ + given_len = len(str(val)) + if _filter.max_length and _filter.min_length: + if given_len <= _filter.max_length and given_len >= _filter.min_length: + return True + elif _filter.max_length and not _filter.min_length: + if given_len <= _filter.max_length: + return True + elif not _filter.max_length and _filter.min_length: + if given_len >= _filter.min_length: + return True + return False + + +def pattern_check(val: any, _filter: Filter) -> bool: + """ + Pattern check. + + Returns True if value string matches the specified pattern + + Args: + val: value to check, extracted from match + _filter: Filter + Return: + bool + + """ + if _filter.pattern: + return bool(re.search(pattern=_filter.pattern, string=str(val))) + return False + + +def const_check(val: any, _filter: Filter) -> bool: + """ + Const check. + + Returns True if value is equal to filter specified check + + Args: + val: value to check, extracted from match + _filter: Filter + Return: + bool + + """ + if val == _filter.const: + return True + return False + + +def enum_check(val: any, _filter: Filter) -> bool: + """ + Enum check. + + Returns True if value is contained to filter specified list + + Args: + val: value to check, extracted from match + _filter: Filter + Return: + bool + + """ + if val in _filter.enums: + return True + return False + + +async def subject_is_issuer(credential: VCRecord) -> bool: + """ + subject_is_issuer check. + + Returns True if cred issuer_id is in subject_ids + + Args: + credential: VCRecord + Return: + bool + + """ + subject_ids = credential.subject_ids + for subject_id in subject_ids: + issuer_id = credential.issuer_id + if subject_id != "" and subject_id == issuer_id: + return True + return False + + +async def filter_schema( + credentials: Sequence[VCRecord], schemas: Sequence[SchemaInputDescriptor] +) -> Sequence[VCRecord]: + """ + Filter by schema. + + Returns list of credentials where credentialSchema.id or types matched + with input_descriptors.schema.uri + + Args: + credentials: list of VCRecords to check + schemas: list of schemas from the input_descriptors + Return: + Sequence of filtered VCRecord + + """ + result = [] + for credential in credentials: + applicable = False + for schema in schemas: + applicable = await credential_match_schema( + credential=credential, schema_id=schema.uri + ) + if schema.required and not applicable: + break + if applicable: + result.append(credential) + return result + + +async def credential_match_schema(credential: VCRecord, schema_id: str) -> bool: + """ + Credential matching by schema. + + Used by filter_schema to check if credential.schema_ids or credential.types + matched with schema_id + + Args: + credential: VCRecord to check + schema_id: schema uri to check + Return: + bool + """ + for cred_schema_id in credential.schema_ids: + if cred_schema_id == schema_id: + return True + for cred_type in credential.types: + if cred_type == schema_id: + return True + return False + + +async def apply_requirements(req: Requirement, credentials: Sequence[VCRecord]) -> dict: + """ + Apply Requirement. + + Args: + req: Requirement + credentials: Sequence of credentials to check against + Return: + dict of input_descriptor ID key to list of credential_json + """ + # Dict for storing descriptor_id keys and list of applicable + # credentials values + result = {} + # Get all input_descriptors attached to the PresentationDefinition + descriptor_list = req.input_descriptors or [] + for descriptor in descriptor_list: + # Filter credentials to apply filtering upon by matching each credentialSchema.id + # or expanded types on each InputDescriptor's schema URIs + filtered_by_schema = await filter_schema( + credentials=credentials, schemas=descriptor.schemas + ) + # Filter credentials based upon path expressions specified in constraints + filtered = await filter_constraints( + constraints=descriptor.constraint, credentials=filtered_by_schema + ) + if len(filtered) != 0: + result[descriptor._id] = filtered + + if len(descriptor_list) != 0: + # Applies min, max or count attributes of submission_requirement + if is_len_applicable(req, len(result)): + return result + return {} + + nested_result = [] + tmp_dict = {} + # recursion logic for nested requirements + for requirement in req.nested_req: + # recursive call + result = await apply_requirements(requirement, credentials) + if result == {}: + continue + # tmp_dict maps applicable credentials to their respective descriptor. + # Structure: {cred.given_id: { + # desc_id_1: {} + # }, + # ...... + # } + # This will be used to construct exclude dict. + for descriptor_id in result.keys(): + credential_list = result.get(descriptor_id) + for credential in credential_list: + if credential.given_id not in tmp_dict: + tmp_dict[credential.given_id] = {} + tmp_dict[credential.given_id][descriptor_id] = {} + + if len(result.keys()) != 0: + nested_result.append(result) + + exclude = {} + for k in tmp_dict.keys(): + # Check if number of applicable credentials + # does not meet requirement specification + if not is_len_applicable(req, len(tmp_dict[k])): + for descriptor_id in tmp_dict[k]: + # Add to exclude dict + # with cred.given_id + descriptor_id as key + exclude[descriptor_id + k] = {} + # merging credentials and excluding credentials that don't satisfy the requirement + return await merge_nested_results(nested_result=nested_result, exclude=exclude) + + +def is_numeric(val: any) -> bool: + """ + Check if val is an int or float. + + Args: + val: to check + Return: + bool + """ + if isinstance(val, float) or isinstance(val, int): + return True + else: + return False + + +async def merge_nested_results(nested_result: Sequence[dict], exclude: dict) -> dict: + """ + Merge nested results with merged credentials. + + Args: + nested_result: Sequence of dict containing input_descriptor.id as keys + and list of creds as values + exclude: dict containing info about credentials to exclude + Return: + dict with input_descriptor.id as keys and merged_credentials_list as values + """ + result = {} + for res in nested_result: + for key in res.keys(): + credentials = res[key] + tmp_dict = {} + merged_credentials = [] + + if key in result: + for credential in result[key]: + if credential.given_id not in tmp_dict: + merged_credentials.append(credential) + tmp_dict[credential.given_id] = {} + + for credential in credentials: + if credential.given_id not in tmp_dict: + if (key + (credential.given_id)) not in exclude: + merged_credentials.append(credential) + tmp_dict[credential.given_id] = {} + result[key] = merged_credentials + return result + + +async def create_vp( + credentials: Sequence[VCRecord], pd: PresentationDefinition +) -> Optional[VerifiablePresentation]: + """ + Create VerifiablePresentation. + + Args: + credentials: Sequence of VCRecords + pd: PresentationDefinition + Return: + VerifiablePresentation + """ + req = await make_requirement( + srs=pd.submission_requirements, descriptors=pd.input_descriptors + ) + result = await apply_requirements(req=req, credentials=credentials) + applicable_creds, descriptor_maps = await merge(result) + # convert list of verifiable credentials to list to dict + applicable_cred_dict = [] + for credential in applicable_creds: + applicable_cred_dict.append(json.loads(credential.value)) + # submission_property + submission_property = PresentationSubmission( + _id=str(uuid4()), definition_id=pd._id, descriptor_maps=descriptor_maps + ) + + # defaultVPContext + default_vp_context = [ + CREDENTIAL_JSONLD_CONTEXT, + PRESENTATION_SUBMISSION_JSONLD_CONTEXT, + ] + # defaultVPType + default_vp_type = [ + VERIFIABLE_PRESENTATION_JSONLD_TYPE, + PRESENTATION_SUBMISSION_JSONLD_TYPE, + ] + + vp = VerifiablePresentation( + _id=str(uuid4()), + contexts=default_vp_context, + types=default_vp_type, + credentials=applicable_cred_dict, + presentation_submission=submission_property, + ) + return vp + + +async def merge( + dict_descriptor_creds: dict, +) -> (Sequence[VCRecord], Sequence[InputDescriptorMapping]): + """ + Return applicable credentials and descriptor_map for attachment. + + Used for generating the presentation_submission property with the + descriptor_map, mantaining the order in which applicable credential + list is returned. + + Args: + dict_descriptor_creds: dict with input_descriptor.id as keys + and merged_credentials_list + Return: + Tuple of applicable credential list and descriptor map + """ + dict_of_creds = {} + dict_of_descriptors = {} + result = [] + descriptors = [] + sorted_desc_keys = sorted(list(dict_descriptor_creds.keys())) + for desc_id in sorted_desc_keys: + credentials = dict_descriptor_creds.get(desc_id) + for credential in credentials: + if credential.given_id not in dict_of_creds: + result.append(credential) + dict_of_creds[credential.given_id] = len(descriptors) + + if ( + f"{credential.given_id}-{credential.given_id}" + not in dict_of_descriptors + ): + descriptor_map = InputDescriptorMapping( + _id=desc_id, + fmt="ldp_vp", + path=f"$.verifiableCredential[{dict_of_creds[credential.given_id]}]", + ) + descriptors.append(descriptor_map) + + descriptors = sorted(descriptors, key=lambda i: i._id) + return (result, descriptors) diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/__init__.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_data.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_data.py new file mode 100644 index 0000000000..7869a2efa1 --- /dev/null +++ b/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_data.py @@ -0,0 +1,606 @@ +import json + +from .....storage.vc_holder.vc_record import VCRecord + +from ..pres_exch import PresentationDefinition + +cred_json_1 = """ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "id": "http://example.edu/credentials/1872", + "type": ["VerifiableCredential", "AlumniCredential"], + "issuer": "https://example.edu/issuers/565049", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "alumniOf": { + "id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": [{ + "value": "Example University", + "lang": "en" + }, { + "value": "Exemple d'Université", + "lang": "fr" + }] + } + }, + "credentialSchema": { + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" + }, + "proof": { + "type": "RsaSignature2018", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..TCYt5XsITJX1CxPCT8yAV-TVkIEq_PbChOMqsLfRoPsnsgw5WEuts01mq-pQy7UJiN5mgRxD-WUcX16dUEMGlv50aqzpqh4Qktb3rk-BuQy72IFLOqV0G_zS245-kronKb78cPN25DGlcTwLtjPAYuNzVBAh4vGHSrQyHUdBBPM" + } +} +""" + +cred_json_2 = """ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "id": "http://example.edu/credentials/1873", + "type": ["VerifiableCredential", "AlumniCredential"], + "issuer": "https://example.edu/issuers/565050", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "alumniOf": { + "id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": [{ + "value": "Example University", + "lang": "en" + }, { + "value": "Exemple d'Université", + "lang": "fr" + }] + } + }, + "credentialSchema": { + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" + }, + "proof": { + "type": "RsaSignature2018", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..TCYt5XsITJX1CxPCT8yAV-TVkIEq_PbChOMqsLfRoPsnsgw5WEuts01mq-pQy7UJiN5mgRxD-WUcX16dUEMGlv50aqzpqh4Qktb3rk-BuQy72IFLOqV0G_zS245-kronKb78cPN25DGlcTwLtjPAYuNzVBAh4vGHSrQyHUdBBPM" + } +} +""" + +cred_json_3 = """ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "id": "http://example.edu/credentials/1874", + "type": ["VerifiableCredential", "AlumniCredential"], + "issuer": "https://example.edu/issuers/565051", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "alumniOf": { + "id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": [{ + "value": "Example University", + "lang": "en" + }, { + "value": "Exemple d'Université", + "lang": "fr" + }] + } + }, + "credentialSchema": { + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" + }, + "proof": { + "type": "RsaSignature2018", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..TCYt5XsITJX1CxPCT8yAV-TVkIEq_PbChOMqsLfRoPsnsgw5WEuts01mq-pQy7UJiN5mgRxD-WUcX16dUEMGlv50aqzpqh4Qktb3rk-BuQy72IFLOqV0G_zS245-kronKb78cPN25DGlcTwLtjPAYuNzVBAh4vGHSrQyHUdBBPM" + } +} +""" +cred_json_4 = """ + { + "vc": { + "@context": "https://www.w3.org/2018/credentials/v1", + "id": "https://eu.com/claims/DriversLicense", + "type": ["EUDriversLicense"], + "issuer": "did:example:123", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSchema": { + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" + }, + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "accounts": [ + { + "id": "1234567890", + "route": "DE-9876543210" + }, + { + "id": "2457913570", + "route": "DE-0753197542" + } + ] + } + } + } +""" + +cred_json_5 = """ + { + "@context": "https://www.w3.org/2018/credentials/v1", + "id": "https://business-standards.org/schemas/employment-history.json", + "type": ["VerifiableCredential", "GenericEmploymentCredential"], + "issuer": "did:foo:123", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "dob": "07/13/80", + "test": 2 + }, + "credentialSchema": { + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" + }, + "proof": { + "type": "EcdsaSecp256k1VerificationKey2019", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "..." + } + } +""" +cred_json_6 = """ + { + "@context": "https://www.w3.org/2018/credentials/v1", + "id": "https://eu.com/claims/DriversLicense2", + "type": ["EUDriversLicense"], + "issuer": "did:foo:123", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "test": 2.0, + "license": { + "number": "34DGE352", + "dob": "07/13/80" + } + }, + "credentialSchema": { + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" + }, + "proof": { + "type": "RsaSignature2018", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "..." + } + } +""" + +pres_exch_nested_srs = """ +{ + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "Citizenship Information", + "rule": "pick", + "count": 1, + "from_nested": [ + { + "name": "United States Citizenship Proofs", + "purpose": "We need you to prove you are a US citizen.", + "rule": "all", + "from": "A" + }, + { + "name": "European Union Citizenship Proofs", + "purpose": "We need you to prove you are a citizen of a EU country.", + "rule": "all", + "from": "B" + } + ] + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.issuer", + "$.vc.issuer", + "$.iss" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "type":"string", + "enum": ["https://example.edu/issuers/565049", "https://example.edu/issuers/565050", "https://example.edu/issuers/565051"] + } + } + ] + } + }, + { + "id":"citizenship_input_2", + "name":"US Passport", + "group":[ + "B" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.dob", + "$.vc.credentialSubject.dob", + "$.credentialSubject.license.dob" + ], + "filter":{ + "type":"string", + "format":"date", + "minimum":"1979-5-16" + } + } + ] + } + } + ] +} +""" + +pres_exch_multiple_srs_not_met = """ +{ + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "Citizenship Information", + "rule": "pick", + "count": 2, + "from": "A" + }, + { + "name": "European Union Citizenship Proofs", + "purpose": "We need you to prove you are a citizen of a EU country.", + "rule": "all", + "from": "B" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.issuer", + "$.vc.issuer", + "$.iss" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "type":"string", + "enum": ["https://example.edu/issuers/565049", "https://example.edu/issuers/565050", "https://example.edu/issuers/565051"] + } + } + ] + } + }, + { + "id":"citizenship_input_2", + "name":"US Passport", + "group":[ + "B" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.dob", + "$.vc.credentialSubject.dob", + "$.credentialSubject.license.dob" + ], + "filter":{ + "type":"string", + "format":"date", + "exclusiveMax":"1999-5-16" + } + } + ] + } + } + ] +} +""" + +pres_exch_multiple_srs_met = """ +{ + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "Citizenship Information", + "rule": "pick", + "min": 1, + "from": "A" + }, + { + "name": "European Union Citizenship Proofs", + "rule": "all", + "from": "B" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.issuer", + "$.vc.issuer", + "$.iss" + ], + "filter":{ + "type":"string", + "pattern": "did:foo:123|did:example:123" + } + } + ] + } + }, + { + "id":"citizenship_input_2", + "name":"US Passport", + "group":[ + "B" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.dob", + "$.vc.credentialSubject.dob", + "$.credentialSubject.license.dob" + ], + "filter":{ + "type":"string", + "format":"date", + "maximum":"1999-5-16" + } + } + ] + } + } + ] +} +""" + +pres_exch_datetime_minimum_not_met = """ +{ + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "pick", + "max": 6, + "from": "B" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.issuer", + "$.vc.issuer", + "$.iss" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "type":"string", + "enum": ["https://example.edu/issuers/565049", "https://example.edu/issuers/565050", "https://example.edu/issuers/565051", "did:foo:123"] + } + } + ] + } + }, + { + "id":"citizenship_input_2", + "name":"US Passport", + "group":[ + "B" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.dob", + "$.vc.credentialSubject.dob", + "$.credentialSubject.license.dob" + ], + "filter":{ + "type":"string", + "format":"date", + "minimum":"1999-5-16" + } + } + ] + } + } + ] +} +""" + +pres_exch_number_const_met = """ +{ + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "pick", + "min": 1, + "from": "A" + } + ], + "format": { + "jwt": { + "alg": ["EdDSA", "ES256K", "ES384"] + }, + "jwt_vc": { + "alg": ["ES256K", "ES384"] + }, + "jwt_vp": { + "alg": ["EdDSA", "ES256K"] + }, + "ldp_vc": { + "proof_type": [ + "JsonWebSignature2020", + "Ed25519Signature2018", + "EcdsaSecp256k1Signature2019", + "RsaSignature2018" + ] + }, + "ldp_vp": { + "proof_type": ["Ed25519Signature2018"] + }, + "ldp": { + "proof_type": ["RsaSignature2018"] + } + }, + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json", + "required": true + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.test", + "$.vc.credentialSubject.test", + "$.test" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "enum": [2, 2.1, 2.2] + } + } + ] + } + } + ] +} +""" + + +def get_test_data(): + creds_json_list = [] + creds_json_list.append(cred_json_1) + creds_json_list.append(cred_json_2) + creds_json_list.append(cred_json_3) + creds_json_list.append(cred_json_4) + creds_json_list.append(cred_json_5) + creds_json_list.append(cred_json_6) + + vc_record_list = [] + for tmp_cred in creds_json_list: + vc_record_list.append(VCRecord.deserialize_jsonld_cred(tmp_cred)) + + pd_json_list = [] + pd_json_list.append((pres_exch_nested_srs, 5)) + pd_json_list.append((pres_exch_multiple_srs_not_met, 0)) + pd_json_list.append((pres_exch_multiple_srs_met, 2)) + pd_json_list.append((pres_exch_datetime_minimum_not_met, 0)) + pd_json_list.append((pres_exch_number_const_met, 2)) + + pd_list = [] + for tmp_pd in pd_json_list: + pd_list.append( + (PresentationDefinition.deserialize(json.loads(tmp_pd[0])), tmp_pd[1]) + ) + return (vc_record_list, pd_list) diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_pres_exch.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_pres_exch.py new file mode 100644 index 0000000000..27291a77c0 --- /dev/null +++ b/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_pres_exch.py @@ -0,0 +1,552 @@ +import pytest +import json + +from asynctest import mock as async_mock +from asynctest import TestCase as AsyncTestCase +from copy import deepcopy +from time import time +from unittest import TestCase +from uuid import uuid4 + +from .....messaging.models.base import BaseModelError +from .....storage.vc_holder.vc_record import VCRecord + +from ..pres_exch import ( + VerifiablePresentation, + ClaimFormat, + SubmissionRequirements, + Holder, + Filter, + Constraints, +) +from ..pres_exch_handler import create_vp + + +class TestPresExchSchemas(TestCase): + """Presentation predicate specification tests""" + + def test_claim_format(self): + submission_req_json = """ + { + "jwt": { + "alg": ["EdDSA", "ES256K", "ES384"] + }, + "jwt_vc": { + "alg": ["ES256K", "ES384"] + }, + "jwt_vp": { + "alg": ["EdDSA", "ES256K"] + }, + "ldp_vc": { + "proof_type": [ + "JsonWebSignature2020", + "Ed25519Signature2018", + "EcdsaSecp256k1Signature2019", + "RsaSignature2018" + ] + }, + "ldp_vp": { + "proof_type": ["Ed25519Signature2018"] + }, + "ldp": { + "proof_type": ["RsaSignature2018"] + } + } + """ + expected_result = json.loads(submission_req_json) + actual_result = (ClaimFormat.deserialize(submission_req_json)).serialize() + assert expected_result == actual_result + + def test_submission_requirements_from(self): + claim_format_json = """ + { + "name": "European Union Citizenship Proofs", + "rule": "pick", + "min": 1, + "from": "A" + } + """ + expected_result = json.loads(claim_format_json) + actual_result = ( + SubmissionRequirements.deserialize(claim_format_json) + ).serialize() + assert expected_result == actual_result + + def test_submission_requirements_from_nested(self): + nested_submission_req_json = """ + { + "name": "Citizenship Information", + "rule": "pick", + "count": 1, + "from_nested": [ + { + "name": "United States Citizenship Proofs", + "purpose": "We need you to prove you are a US citizen.", + "rule": "all", + "from": "A" + }, + { + "name": "European Union Citizenship Proofs", + "purpose": "We need you to prove you are a citizen of a EU country.", + "rule": "all", + "from": "B" + } + ] + } + """ + expected_result = json.loads(nested_submission_req_json) + actual_result = ( + SubmissionRequirements.deserialize(nested_submission_req_json) + ).serialize() + assert expected_result == actual_result + + def test_submission_requirements_from_missing(self): + test_json = """ + { + "name": "Citizenship Information", + "rule": "pick", + "count": 1 + } + """ + with self.assertRaises(BaseModelError) as cm: + (SubmissionRequirements.deserialize(test_json)).serialize() + + def test_submission_requirements_from_both_present(self): + test_json = """ + { + "name": "Citizenship Information", + "rule": "pick", + "count": 1, + "from": "A", + "from_nested": [ + { + "name": "United States Citizenship Proofs", + "purpose": "We need you to prove you are a US citizen.", + "rule": "all", + "from": "A" + }, + { + "name": "European Union Citizenship Proofs", + "purpose": "We need you to prove you are a citizen of a EU country.", + "rule": "all", + "from": "B" + } + ] + } + """ + with self.assertRaises(BaseModelError) as cm: + (SubmissionRequirements.deserialize(test_json)).serialize() + + def test_is_holder(self): + test_json = """ + { + "field_id": [ + "ce66380c-1990-4aec-b8b4-5d532e92a616", + "dd69e8a4-4cc0-4540-b34a-b4aa0e0d2214", + "d15802b4-eec8-45ef-b78f-e35125ac1bb8", + "765f3e09-600c-467f-99eb-ea549c350121" + ], + "directive": "required" + } + """ + expected_result = json.loads(test_json) + actual_result = (Holder.deserialize(test_json)).serialize() + assert expected_result == actual_result + + def test_filter(self): + test_json_list = [] + test_json_string_enum = """ + { + "type":"string", + "enum": ["testa1", "testa2", "testa3"] + } + """ + test_json_list.append(test_json_string_enum) + test_json_number_enum = """ + { + "type":"string", + "enum": ["testb1", "testb2", "testb3"] + } + """ + test_json_list.append(test_json_number_enum) + test_json_not_enum = """ + { + "not": { + "enum": ["testc1", "testc2", "testc3"] + } + } + """ + test_json_list.append(test_json_not_enum) + test_json_format_min = """ + { + "type":"string", + "format": "date", + "minimum": "1980/07/04" + } + """ + test_json_list.append(test_json_format_min) + test_json_exclmax = """ + { + "type":"number", + "exclusiveMaximum": 2 + } + """ + test_json_list.append(test_json_exclmax) + test_json_exclmin = """ + { + "exclusiveMinimum": 2 + } + """ + test_json_list.append(test_json_exclmin) + test_json_const = """ + { + "const": 2.0 + } + """ + test_json_list.append(test_json_const) + test_json_enum_error = """ + { + "enum": 2 + } + """ + test_json_custom_field_error = """ + { + "minimum": [ + "not_valid" + ] + } + """ + + for tmp_test_item in test_json_list: + expected_result = json.loads(tmp_test_item) + actual_result = (Filter.deserialize(tmp_test_item)).serialize() + assert expected_result == actual_result + + with self.assertRaises(BaseModelError) as cm: + (Filter.deserialize(test_json_enum_error)).serialize() + + with self.assertRaises(BaseModelError) as cm: + (Filter.deserialize(test_json_custom_field_error)).serialize() + + def test_constraints(self): + test_json = """ + { + "fields":[ + { + "path":[ + "$.credentialSubject.dob", + "$.vc.credentialSubject.dob", + "$.credentialSubject.license.dob" + ], + "filter":{ + "type":"string", + "format":"date", + "minimum":"1999-5-16" + } + } + ], + "statuses": { + "active": { + "directive": "required" + }, + "suspended": { + "directive": "allowed" + }, + "revoked": { + "directive": "disallowed" + } + } + } + """ + + expected_result = json.loads(test_json) + actual_result = (Constraints.deserialize(test_json)).serialize() + assert expected_result == actual_result + + def test_vp(self): + test_json_valid = """ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + { + "test": "test" + } + ], "type": [ + "VerifiablePresentation", + "PresentationSubmission" + ], + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "id": "http://example.edu/credentials/1872", + "type": ["VerifiableCredential", "AlumniCredential"], + "issuer": "https://example.edu/issuers/565049", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "alumniOf": {"id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": [ + {"value": "Example University", "lang": "en"}, + {"value": "Exemple d\'Universit\\u00e9", "lang": "fr"} + ]} + }, + "credentialSchema": { + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" + }, + "proof": { + "type": "RsaSignature2018", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..TCYt5XsITJX1CxPCT8yAV-TVkIEq_PbChOMqsLfRoPsnsgw5WEuts01mq-pQy7UJiN5mgRxD-WUcX16dUEMGlv50aqzpqh4Qktb3rk-BuQy72IFLOqV0G_zS245-kronKb78cPN25DGlcTwLtjPAYuNzVBAh4vGHSrQyHUdBBPM" + } + }, { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "id": "http://example.edu/credentials/1873", + "type": [ + "VerifiableCredential", + "AlumniCredential" + ], + "issuer": "https://example.edu/issuers/565050", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "alumniOf": { + "id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": [ + {"value": "Example University", "lang": "en"}, + {"value": "Exemple d\'Universit\\u00e9", "lang": "fr"} + ] + } + }, "credentialSchema": { + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" + }, "proof": { + "type": "RsaSignature2018", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..TCYt5XsITJX1CxPCT8yAV-TVkIEq_PbChOMqsLfRoPsnsgw5WEuts01mq-pQy7UJiN5mgRxD-WUcX16dUEMGlv50aqzpqh4Qktb3rk-BuQy72IFLOqV0G_zS245-kronKb78cPN25DGlcTwLtjPAYuNzVBAh4vGHSrQyHUdBBPM" + } + } + ], "presentation_submission": { + "id": "083d9ac9-3e5f-4d46-a19d-358b8d661124", + "definition_id": "32f54163-7166-48f1-93d8-ff217bdb0653", + "descriptor_map": [ + {"id": "citizenship_input_1", "format": "ldp_vp", "path": "$.verifiableCredential[0]"}, + {"id": "citizenship_input_1", "format": "ldp_vp", "path": "$.verifiableCredential[1]"} + ] + } + } + """ + + test_json_context_invalid = """ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + 2 + ], "type": [ + "VerifiablePresentation", + "PresentationSubmission" + ], + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "id": "http://example.edu/credentials/1872", + "type": ["VerifiableCredential", "AlumniCredential"], + "issuer": "https://example.edu/issuers/565049", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "alumniOf": {"id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": [ + {"value": "Example University", "lang": "en"}, + {"value": "Exemple d\'Universit\\u00e9", "lang": "fr"} + ]} + }, + "credentialSchema": { + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" + }, + "proof": { + "type": "RsaSignature2018", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..TCYt5XsITJX1CxPCT8yAV-TVkIEq_PbChOMqsLfRoPsnsgw5WEuts01mq-pQy7UJiN5mgRxD-WUcX16dUEMGlv50aqzpqh4Qktb3rk-BuQy72IFLOqV0G_zS245-kronKb78cPN25DGlcTwLtjPAYuNzVBAh4vGHSrQyHUdBBPM" + } + }, { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "id": "http://example.edu/credentials/1873", + "type": [ + "VerifiableCredential", + "AlumniCredential" + ], + "issuer": "https://example.edu/issuers/565050", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "alumniOf": { + "id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": [ + {"value": "Example University", "lang": "en"}, + {"value": "Exemple d\'Universit\\u00e9", "lang": "fr"} + ] + } + }, "credentialSchema": { + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" + }, "proof": { + "type": "RsaSignature2018", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..TCYt5XsITJX1CxPCT8yAV-TVkIEq_PbChOMqsLfRoPsnsgw5WEuts01mq-pQy7UJiN5mgRxD-WUcX16dUEMGlv50aqzpqh4Qktb3rk-BuQy72IFLOqV0G_zS245-kronKb78cPN25DGlcTwLtjPAYuNzVBAh4vGHSrQyHUdBBPM" + } + } + ], "presentation_submission": { + "id": "083d9ac9-3e5f-4d46-a19d-358b8d661124", + "definition_id": "32f54163-7166-48f1-93d8-ff217bdb0653", + "descriptor_map": [ + {"id": "citizenship_input_1", "format": "ldp_vp", "path": "$.verifiableCredential[0]"}, + {"id": "citizenship_input_1", "format": "ldp_vp", "path": "$.verifiableCredential[1]"} + ] + } + } + """ + + test_vp_json_with_proof = """ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://identity.foundation/presentation-exchange/submission/v1" + ], + "type": [ + "VerifiablePresentation", + "PresentationSubmission" + ], + "presentation_submission": { + "id": "a30e3b91-fb77-4d22-95fa-871689c322e2", + "definition_id": "32f54163-7166-48f1-93d8-ff217bdb0653", + "descriptor_map": [ + { + "id": "banking_input_2", + "format": "jwt_vc", + "path": "$.verifiableCredential[0]" + }, + { + "id": "employment_input", + "format": "ldp_vc", + "path": "$.verifiableCredential[1]" + }, + { + "id": "citizenship_input_1", + "format": "ldp_vc", + "path": "$.verifiableCredential[2]" + } + ] + }, + "verifiableCredential": [ + { + "comment": "IN REALWORLD VPs, THIS WILL BE A BIG UGLY OBJECT INSTEAD OF THE DECODED JWT PAYLOAD THAT FOLLOWS", + "vc": { + "@context": "https://www.w3.org/2018/credentials/v1", + "id": "https://eu.com/claims/DriversLicense", + "type": ["EUDriversLicense"], + "issuer": "did:example:123", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "accounts": [ + { + "id": "1234567890", + "route": "DE-9876543210" + }, + { + "id": "2457913570", + "route": "DE-0753197542" + } + ] + } + } + }, + { + "@context": "https://www.w3.org/2018/credentials/v1", + "id": "https://business-standards.org/schemas/employment-history.json", + "type": ["VerifiableCredential", "GenericEmploymentCredential"], + "issuer": "did:foo:123", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "active": true + }, + "proof": { + "type": "EcdsaSecp256k1VerificationKey2019", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "..." + } + }, + { + "@context": "https://www.w3.org/2018/credentials/v1", + "id": "https://eu.com/claims/DriversLicense", + "type": ["EUDriversLicense"], + "issuer": "did:foo:123", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "license": { + "number": "34DGE352", + "dob": "07/13/80" + } + }, + "proof": { + "type": "RsaSignature2018", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "..." + } + } + ], + "proof": { + "type": "RsaSignature2018", + "created": "2018-09-14T21:19:10Z", + "proofPurpose": "authentication", + "verificationMethod": "did:example:ebfeb1f712ebc6f1c276e12ec21#keys-1", + "challenge": "1f44d55f-f161-4938-a659-f8026467f126", + "domain": "4jt78h47fh47", + "jws": "..." + } + } + """ + + expected_result = json.loads(test_json_valid) + actual_result = ( + VerifiablePresentation.deserialize(test_json_valid) + ).serialize() + assert expected_result == actual_result + + with self.assertRaises(BaseModelError) as cm: + (VerifiablePresentation.deserialize(test_json_context_invalid)).serialize() + + vp_with_proof_dict = ( + VerifiablePresentation.deserialize(test_vp_json_with_proof) + ).serialize() + assert vp_with_proof_dict["proof"][0] == ( + json.loads(test_vp_json_with_proof) + ).get("proof") diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_pres_exch_handler.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_pres_exch_handler.py new file mode 100644 index 0000000000..0b3fa41dcd --- /dev/null +++ b/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_pres_exch_handler.py @@ -0,0 +1,1231 @@ +import json +import pytest + +from asynctest import mock as async_mock +from asynctest import TestCase as AsyncTestCase +from copy import deepcopy +from time import time +from unittest import TestCase +from uuid import uuid4 + + +from .....storage.vc_holder.vc_record import VCRecord + +from ..pres_exch import ( + PresentationDefinition, + Requirement, + Filter, + SchemaInputDescriptor, +) +from ..pres_exch_handler import ( + to_requirement, + make_requirement, + is_len_applicable, + exclusive_maximum_check, + exclusive_minimum_check, + minimum_check, + maximum_check, + length_check, + pattern_check, + subject_is_issuer, + filter_schema, + credential_match_schema, + is_numeric, + merge_nested_results, + create_vp, + PresentationExchError, +) + +from .test_data import get_test_data + + +(cred_list, pd_list) = get_test_data() + + +class TestPresExchEndToEnd(AsyncTestCase): + """Presentation predicate specification tests""" + + @pytest.mark.asyncio + async def test_load_cred_json(self): + """Test deserialization.""" + assert len(cred_list) == 6 + for tmp_pd in pd_list: + # tmp_pd is tuple of presentation_definition and expected number of VCs + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd[0]) + assert len(tmp_vp.credentials) == tmp_pd[1] + + +class TestPresExchRequirement(AsyncTestCase): + """Presentation Exchange Requirment tests""" + + async def test_to_requirement_catch_errors(self): + """Test deserialization.""" + + test_json_pd = """ + { + "submission_requirements": [ + { + "name": "Banking Information", + "purpose": "We need you to prove you currently hold a bank account older than 12months.", + "rule": "pick", + "count": 1, + "from": "A" + } + ], + "id": "32f54163-7166-48f1-93d8-ff217bdb0653", + "input_descriptors": [ + { + "id": "banking_input_1", + "name": "Bank Account Information", + "purpose": "We can only remit payment to a currently-valid bank account.", + "group": [ + "B" + ], + "schema": [ + { + "uri": "https://bank-schemas.org/1.0.0/accounts.json" + }, + { + "uri": "https://bank-schemas.org/2.0.0/accounts.json" + } + ], + "constraints": { + "fields": [ + { + "path": [ + "$.issuer", + "$.vc.issuer", + "$.iss" + ], + "purpose": "We can only verify bank accounts if they are attested by a trusted bank, auditor or regulatory authority.", + "filter": { + "type": "string", + "pattern": "did:example:123|did:example:456" + } + }, + { + "path": [ + "$.credentialSubject.account[*].route", + "$.vc.credentialSubject.account[*].route", + "$.account[*].route" + ], + "purpose": "We can only remit payment to a currently-valid account at a US, Japanese, or German federally-accredited bank, submitted as an ABA RTN or SWIFT code.", + "filter": { + "type": "string", + "pattern": "^[0-9]{9}|^([a-zA-Z]){4}([a-zA-Z]){2}([0-9a-zA-Z]){2}([0-9a-zA-Z]{3})?$" + } + } + ] + } + } + ] + } + """ + + with self.assertRaises(PresentationExchError) as cm: + test_pd = PresentationDefinition.deserialize(test_json_pd) + await make_requirement( + srs=test_pd.submission_requirements, + descriptors=test_pd.input_descriptors, + ) + assert ( + "Error creating requirement inside to_requirement function" + in cm.exception + ) + + test_json_pd_nested_srs = """ + { + "submission_requirements": [ + { + "name": "Citizenship Information", + "rule": "pick", + "max": 3, + "from_nested": [ + { + "name": "United States Citizenship Proofs", + "purpose": "We need you to prove your US citizenship.", + "rule": "all", + "from": "C" + }, + { + "name": "European Union Citizenship Proofs", + "purpose": "We need you to prove you are a citizen of an EU member state.", + "rule": "all", + "from": "D" + } + ] + } + ], + "id": "32f54163-7166-48f1-93d8-ff217bdb0653", + "input_descriptors": [ + { + "id": "banking_input_1", + "name": "Bank Account Information", + "purpose": "We can only remit payment to a currently-valid bank account.", + "group": [ + "B" + ], + "schema": [ + { + "uri": "https://bank-schemas.org/1.0.0/accounts.json" + }, + { + "uri": "https://bank-schemas.org/2.0.0/accounts.json" + } + ], + "constraints": { + "fields": [ + { + "path": [ + "$.issuer", + "$.vc.issuer", + "$.iss" + ], + "purpose": "We can only verify bank accounts if they are attested by a trusted bank, auditor or regulatory authority.", + "filter": { + "type": "string", + "pattern": "did:example:123|did:example:456" + } + }, + { + "path": [ + "$.credentialSubject.account[*].route", + "$.vc.credentialSubject.account[*].route", + "$.account[*].route" + ], + "purpose": "We can only remit payment to a currently-valid account at a US, Japanese, or German federally-accredited bank, submitted as an ABA RTN or SWIFT code.", + "filter": { + "type": "string", + "pattern": "^[0-9]{9}|^([a-zA-Z]){4}([a-zA-Z]){2}([0-9a-zA-Z]){2}([0-9a-zA-Z]{3})?$" + } + } + ] + } + } + ] + } + """ + + with self.assertRaises(PresentationExchError) as cm: + test_pd = PresentationDefinition.deserialize(test_json_pd_nested_srs) + await make_requirement( + srs=test_pd.submission_requirements, + descriptors=test_pd.input_descriptors, + ) + assert ( + "Error creating requirement from nested submission_requirements" + in cm.exception + ) + + async def test_make_requirement_with_none_params(self): + """Test deserialization.""" + + test_json_pd_no_sr = """ + { + "id": "32f54163-7166-48f1-93d8-ff217bdb0653", + "input_descriptors": [ + { + "id": "banking_input_1", + "name": "Bank Account Information", + "purpose": "We can only remit payment to a currently-valid bank account.", + "group": [ + "B" + ], + "schema": [ + { + "uri": "https://bank-schemas.org/1.0.0/accounts.json" + }, + { + "uri": "https://bank-schemas.org/2.0.0/accounts.json" + } + ], + "constraints": { + "fields": [ + { + "path": [ + "$.issuer", + "$.vc.issuer", + "$.iss" + ], + "purpose": "We can only verify bank accounts if they are attested by a trusted bank, auditor or regulatory authority.", + "filter": { + "type": "string", + "pattern": "did:example:123|did:example:456" + } + }, + { + "path": [ + "$.credentialSubject.account[*].route", + "$.vc.credentialSubject.account[*].route", + "$.account[*].route" + ], + "purpose": "We can only remit payment to a currently-valid account at a US, Japanese, or German federally-accredited bank, submitted as an ABA RTN or SWIFT code.", + "filter": { + "type": "string", + "pattern": "^[0-9]{9}|^([a-zA-Z]){4}([a-zA-Z]){2}([0-9a-zA-Z]){2}([0-9a-zA-Z]{3})?$" + } + } + ] + } + } + ] + } + """ + + test_pd = PresentationDefinition.deserialize(test_json_pd_no_sr) + assert test_pd.submission_requirements is None + await make_requirement( + srs=test_pd.submission_requirements, descriptors=test_pd.input_descriptors + ) + + test_json_pd_no_input_desc = """ + { + "submission_requirements": [ + { + "name": "Banking Information", + "purpose": "We need you to prove you currently hold a bank account older than 12months.", + "rule": "pick", + "count": 1, + "from": "A" + } + ], + "id": "32f54163-7166-48f1-93d8-ff217bdb0653" + } + """ + + with self.assertRaises(PresentationExchError) as cm: + test_pd = PresentationDefinition.deserialize(test_json_pd_no_input_desc) + await make_requirement( + srs=test_pd.submission_requirements, + descriptors=test_pd.input_descriptors, + ) + + +class TestPresExchConstraint(AsyncTestCase): + """Presentation predicate specification tests""" + + @pytest.mark.asyncio + async def test_subject_is_issuer_check(self): + """Test deserialization.""" + test_pd = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "Citizenship Information", + "rule": "pick", + "min": 1, + "from": "A" + }, + { + "name": "European Union Citizenship Proofs", + "rule": "all", + "from": "B" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "subject_is_issuer": "required", + "fields":[ + { + "path":[ + "$.issuer", + "$.vc.issuer", + "$.iss" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "type":"string", + "enum": ["https://example.edu/issuers/565049", "https://example.edu/issuers/565050", "https://example.edu/issuers/565051", "did:foo:123"] + } + } + ] + } + }, + { + "id":"citizenship_input_2", + "name":"US Passport", + "group":[ + "B" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.dob", + "$.vc.credentialSubject.dob", + "$.credentialSubject.license.dob" + ], + "filter":{ + "type":"string", + "format":"date", + "maximum":"1999-5-16" + } + } + ] + } + } + ] + } + """ + + tmp_vp = await create_vp( + credentials=cred_list, pd=PresentationDefinition.deserialize(test_pd) + ) + + +class TestPresExchField(AsyncTestCase): + """Presentation predicate specification tests""" + + @pytest.mark.asyncio + async def test_predicate_required_check(self): + """Test deserialization.""" + test_pd = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "Citizenship Information", + "rule": "pick", + "min": 1, + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.issuer", + "$.vc.issuer", + "$.iss" + ], + "predicate": "required", + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "type":"string", + "enum": ["https://example.edu/issuers/565049", "https://example.edu/issuers/565050", "https://example.edu/issuers/565051", "did:foo:123"] + } + } + ] + } + } + ] + } + """ + + with self.assertRaises(PresentationExchError) as cm: + tmp_pd = PresentationDefinition.deserialize(test_pd) + assert ( + tmp_pd.input_descriptors[0].constraint._fields[0].predicate + == "required" + ) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert "Not yet implemented - createNewCredential" in cm.exception + + +class TestPresExchFilter(AsyncTestCase): + """Presentation predicate specification tests""" + + @pytest.mark.asyncio + async def test_filter_number_type_check(self): + """Test deserialization.""" + test_pd_min = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "pick", + "min": 1, + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.test", + "$.vc.credentialSubject.test", + "$.test" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "type": "number", + "minimum": 2 + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_min) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 2 + + test_pd_max = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "pick", + "min": 1, + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.test", + "$.vc.credentialSubject.test", + "$.test" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "type": "number", + "maximum": 2 + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_max) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 2 + + test_pd_excl_min = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "pick", + "min": 1, + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.test", + "$.vc.credentialSubject.test", + "$.test" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "type": "number", + "exclusiveMinimum": 1.5 + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_excl_min) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 2 + + test_pd_excl_max = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "pick", + "min": 1, + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.test", + "$.vc.credentialSubject.test", + "$.test" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "type": "number", + "exclusiveMaximum": 2.5 + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_excl_max) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 2 + + test_pd_const = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "pick", + "min": 1, + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.test", + "$.vc.credentialSubject.test", + "$.test" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "type": "number", + "const": 2 + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_const) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 2 + + test_pd_enum = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "pick", + "min": 1, + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.test", + "$.vc.credentialSubject.test", + "$.test" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "type": "number", + "enum": [2, 2.0 , "test"] + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_enum) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 2 + + test_pd_missing = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "pick", + "min": 1, + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.test", + "$.vc.credentialSubject.test", + "$.test" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "type": "number" + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_missing) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 0 + + @pytest.mark.asyncio + async def test_filter_no_type_check(self): + """Test deserialization.""" + test_pd = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "all", + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.test", + "$.vc.credentialSubject.test", + "$.test" + ], + "purpose":"The claim must be from one of the specified issuers", + "filter":{ + "not": { + "const": 1.5 + } + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 2 + + @pytest.mark.asyncio + async def test_filter_string(self): + """Test deserialization.""" + test_pd_min_length = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "all", + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.vc.issuer", + "$.issuer", + "$.iss" + ], + "filter":{ + "type":"string", + "minLength": 5 + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_min_length) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 6 + + test_pd_max_length = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "all", + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.issuer", + "$.vc.issuer", + "$.iss" + ], + "filter":{ + "type":"string", + "maxLength": 20 + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_max_length) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 3 + + test_pd_pattern_check = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "all", + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.vc.issuer", + "$.issuer", + "$.iss" + ], + "filter":{ + "type":"string", + "pattern": "did:example:123|did:foo:123" + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_pattern_check) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 3 + + test_pd_datetime_exclmax = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "all", + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.dob", + "$.vc.credentialSubject.dob", + "$.credentialSubject.license.dob" + ], + "filter":{ + "type":"string", + "format":"date", + "exclusiveMaximum":"1981-5-16" + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_datetime_exclmax) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 2 + + test_pd_datetime_exclmin = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "all", + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.credentialSubject.dob", + "$.vc.credentialSubject.dob", + "$.credentialSubject.license.dob" + ], + "filter":{ + "type":"string", + "format":"date", + "exclusiveMinimum":"1979-5-16" + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_datetime_exclmin) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 2 + + test_pd_const_check = """ + { + "id":"32f54163-7166-48f1-93d8-ff217bdb0653", + "submission_requirements":[ + { + "name": "European Union Citizenship Proofs", + "rule": "all", + "from": "A" + } + ], + "input_descriptors":[ + { + "id":"citizenship_input_1", + "name":"EU Driver's License", + "group":[ + "A" + ], + "schema":[ + { + "uri":"https://eu.com/claims/DriversLicense.json" + } + ], + "constraints":{ + "fields":[ + { + "path":[ + "$.vc.issuer", + "$.issuer", + "$.iss" + ], + "filter":{ + "type":"string", + "const": "did:foo:123" + } + } + ] + } + } + ] + } + """ + + tmp_pd = PresentationDefinition.deserialize(test_pd_const_check) + tmp_vp = await create_vp(credentials=cred_list, pd=tmp_pd) + assert len(tmp_vp.credentials) == 2 + + @pytest.mark.asyncio + async def test_filter_schema(self): + tmp_schema_list = [ + SchemaInputDescriptor( + uri="test123", + required=True, + ) + ] + assert len(await filter_schema(cred_list, tmp_schema_list)) == 0 + + @pytest.mark.asyncio + async def test_cred_schema_match(self): + tmp_cred = deepcopy(cred_list[0]) + tmp_cred.types = ["test1", "test2"] + tmp_cred.schema_ids = ["test3"] + assert await credential_match_schema(tmp_cred, "test2") is True + + @pytest.mark.asyncio + async def test_merge_nested(self): + test_nested_result = [] + test_dict_1 = {} + test_dict_1["citizenship_input_1"] = [ + cred_list[0], + cred_list[1], + cred_list[2], + cred_list[3], + cred_list[4], + cred_list[5], + ] + test_dict_2 = {} + test_dict_2["citizenship_input_2"] = [ + cred_list[4], + cred_list[5], + ] + test_dict_3 = {} + test_dict_3["citizenship_input_2"] = [ + cred_list[3], + cred_list[2], + ] + test_nested_result.append(test_dict_1) + test_nested_result.append(test_dict_2) + test_nested_result.append(test_dict_3) + + tmp_result = await merge_nested_results(test_nested_result, {}) + + @pytest.mark.asyncio + async def test_subject_is_issuer(self): + tmp_cred = deepcopy(cred_list[0]) + tmp_cred.issuer_id = "4fc82e63-f897-4dad-99cc-f698dff6c425" + tmp_cred.subject_ids.add("4fc82e63-f897-4dad-99cc-f698dff6c425") + assert tmp_cred.subject_ids is not None + assert await subject_is_issuer(tmp_cred) is True + tmp_cred.issuer_id = "19b823fb-55ef-49f4-8caf-2a26b8b9286f" + assert await subject_is_issuer(tmp_cred) is False + + +class UtilityTests(TestCase): + def test_is_numeric(self): + assert is_numeric("test") is False + assert is_numeric(1) is True + assert is_numeric(2 + 3j) is False + + def test_filter_no_match(self): + tmp_filter_excl_min = Filter(exclusive_min=7) + assert exclusive_minimum_check("test", tmp_filter_excl_min) is False + tmp_filter_excl_max = Filter(exclusive_max=10) + assert exclusive_maximum_check("test", tmp_filter_excl_max) is False + tmp_filter_min = Filter(minimum=10) + assert minimum_check("test", tmp_filter_min) is False + tmp_filter_max = Filter(maximum=10) + assert maximum_check("test", tmp_filter_max) is False + + def test_filter_valueerror(self): + tmp_filter_excl_min = Filter(exclusive_min=7, fmt="date") + assert exclusive_minimum_check("test", tmp_filter_excl_min) is False + tmp_filter_excl_max = Filter(exclusive_max=10, fmt="date") + assert exclusive_maximum_check("test", tmp_filter_excl_max) is False + tmp_filter_min = Filter(minimum=10, fmt="date") + assert minimum_check("test", tmp_filter_min) is False + tmp_filter_max = Filter(maximum=10, fmt="date") + assert maximum_check("test", tmp_filter_max) is False + + def test_filter_length_check(self): + tmp_filter_both = Filter(min_length=7, max_length=10) + assert length_check("test12345", tmp_filter_both) is True + tmp_filter_min = Filter(min_length=7) + assert length_check("test123", tmp_filter_min) is True + tmp_filter_max = Filter(max_length=10) + assert length_check("test", tmp_filter_max) is True + assert length_check("test12", tmp_filter_min) is False + + def test_filter_pattern_check(self): + tmp_filter = Filter(pattern="test1|test2") + assert pattern_check("test3", tmp_filter) is False + tmp_filter = Filter(const="test3") + assert pattern_check("test3", tmp_filter) is False + + def test_is_len_applicable(self): + tmp_req_a = Requirement(count=1) + tmp_req_b = Requirement(minimum=3) + tmp_req_c = Requirement(maximum=5) + + assert is_len_applicable(tmp_req_a, 2) is False + assert is_len_applicable(tmp_req_b, 2) is False + assert is_len_applicable(tmp_req_c, 6) is False diff --git a/aries_cloudagent/storage/vc_holder/tests/test_vc_record.py b/aries_cloudagent/storage/vc_holder/tests/test_vc_record.py index cee883ded8..5ada49fa69 100644 --- a/aries_cloudagent/storage/vc_holder/tests/test_vc_record.py +++ b/aries_cloudagent/storage/vc_holder/tests/test_vc_record.py @@ -14,6 +14,99 @@ given_id = "http://example.edu/credentials/3732" tags = {"tag": "value"} value = "{}" +sample_json_cred_1 = """ + { + "vc": { + "@context": "https://www.w3.org/2018/credentials/v1", + "id": "https://eu.com/claims/DriversLicense", + "type": ["EUDriversLicense"], + "issuer": "did:example:123", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSchema": { + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" + }, + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "accounts": [ + { + "id": "1234567890", + "route": "DE-9876543210" + }, + { + "id": "2457913570", + "route": "DE-0753197542" + } + ] + } + } + } +""" +sample_json_cred_2 = """ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "id": "http://example.edu/credentials/1872", + "type": ["VerifiableCredential", "AlumniCredential"], + "issuer": { + "id": "https://example.edu/issuers/565049" + }, + "issuanceDate": "2011-01-01T19:73:24Z", + "credentialSchema": [ + { + "id": "https://example.org/examples/degree.json", + "type": "JsonSchemaValidator2018" + } + ], + "credentialSubject": [ + { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "alumniOf": { + "id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": [{ + "value": "Example University", + "lang": "en" + }, { + "value": "Exemple d'Université", + "lang": "fr" + }] + } + } + ], + "proof": { + "type": "RsaSignature2018", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..TCYt5XsITJX1CxPCT8yAV-TVkIEq_PbChOMqsLfRoPsnsgw5WEuts01mq-pQy7UJiN5mgRxD-WUcX16dUEMGlv50aqzpqh4Qktb3rk-BuQy72IFLOqV0G_zS245-kronKb78cPN25DGlcTwLtjPAYuNzVBAh4vGHSrQyHUdBBPM" + } + } +""" +sample_json_cred_3 = """ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "id": "http://example.edu/credentials/1872", + "type": ["VerifiableCredential", "AlumniCredential"], + "issuer": { + "id": "https://example.edu/issuers/565049" + }, + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSchema": "https://example.org/examples/degree.json", + "credentialSubject": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "proof": { + "type": "RsaSignature2018", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/keys/1", + "jws": "eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..TCYt5XsITJX1CxPCT8yAV-TVkIEq_PbChOMqsLfRoPsnsgw5WEuts01mq-pQy7UJiN5mgRxD-WUcX16dUEMGlv50aqzpqh4Qktb3rk-BuQy72IFLOqV0G_zS245-kronKb78cPN25DGlcTwLtjPAYuNzVBAh4vGHSrQyHUdBBPM" + } + } +""" def test_record() -> VCRecord: @@ -53,3 +146,8 @@ def test_eq(self): assert record_a != object() record_b.contexts.clear() assert record_a != record_b + + def test_deserialize(self): + VCRecord.deserialize_jsonld_cred(sample_json_cred_1) + VCRecord.deserialize_jsonld_cred(sample_json_cred_2) + VCRecord.deserialize_jsonld_cred(sample_json_cred_3) diff --git a/aries_cloudagent/storage/vc_holder/vc_record.py b/aries_cloudagent/storage/vc_holder/vc_record.py index b4424edd14..baf1bd8e25 100644 --- a/aries_cloudagent/storage/vc_holder/vc_record.py +++ b/aries_cloudagent/storage/vc_holder/vc_record.py @@ -1,5 +1,9 @@ """Model for representing a stored verifiable credential.""" +import json + +from pyld import jsonld +from pyld.jsonld import JsonLdProcessor from typing import Sequence from uuid import uuid4 @@ -55,3 +59,83 @@ def __eq__(self, other: object) -> bool: and other.tags == self.tags and other.value == self.value ) + + @classmethod + def deserialize_jsonld_cred(cls, cred_json: str) -> "VCRecord": + """ + Return VCRecord. + + Deserialize JSONLD cred to a VCRecord + + Args: + cred_json: credential json string + Return: + VCRecord + + """ + given_id = None + tags = None + value = "" + record_id = None + subject_ids = set() + issuer_id = "" + contexts = set() + types = set() + schema_ids = set() + cred_dict = json.loads(cred_json) + if "vc" in cred_dict: + cred_dict = cred_dict.get("vc") + if "id" in cred_dict: + given_id = cred_dict.get("id") + if "@context" in cred_dict: + # Should not happen + if type(cred_dict.get("@context")) is not list: + if type(cred_dict.get("@context")) is str: + contexts.add(cred_dict.get("@context")) + else: + for tmp_item in cred_dict.get("@context"): + if type(tmp_item) is str: + contexts.add(tmp_item) + if "issuer" in cred_dict: + if type(cred_dict.get("issuer")) is dict: + issuer_id = cred_dict.get("issuer").get("id") + else: + issuer_id = cred_dict.get("issuer") + if "type" in cred_dict: + expanded = jsonld.expand(cred_dict) + types = JsonLdProcessor.get_values( + expanded[0], + "@type", + ) + if "credentialSubject" in cred_dict: + if type(cred_dict.get("credentialSubject")) is list: + tmp_list = cred_dict.get("credentialSubject") + for tmp_dict in tmp_list: + subject_ids.add(tmp_dict.get("id")) + elif type(cred_dict.get("credentialSubject")) is dict: + tmp_dict = cred_dict.get("credentialSubject") + subject_ids.add(tmp_dict.get("id")) + elif type(cred_dict.get("credentialSubject")) is str: + subject_ids.add(cred_dict.get("credentialSubject")) + if "credentialSchema" in cred_dict: + if type(cred_dict.get("credentialSchema")) is list: + tmp_list = cred_dict.get("credentialSchema") + for tmp_dict in tmp_list: + schema_ids.add(tmp_dict.get("id")) + elif type(cred_dict.get("credentialSchema")) is dict: + tmp_dict = cred_dict.get("credentialSchema") + schema_ids.add(tmp_dict.get("id")) + elif type(cred_dict.get("credentialSchema")) is str: + schema_ids.add(cred_dict.get("credentialSchema")) + value = cred_json + return VCRecord( + contexts=contexts, + types=types, + issuer_id=issuer_id, + subject_ids=subject_ids, + given_id=given_id, + value=value, + tags=tags, + record_id=record_id, + schema_ids=schema_ids, + ) diff --git a/requirements.txt b/requirements.txt index 5ff2b37a62..bbc48fb970 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,6 @@ py_multicodec==0.2.1 pyyaml~=5.3.1 ConfigArgParse~=1.2.3 pyjwt~=1.7.1 +jsonpath_ng==1.5.2 +pytz~=2021.1 +python-dateutil~=2.8.1 From cd13c04907fa16540dc780a26411fb94c6915068 Mon Sep 17 00:00:00 2001 From: Shaanjot Gill Date: Tue, 23 Mar 2021 08:07:45 -0700 Subject: [PATCH 039/138] Requested Changes Signed-off-by: Shaanjot Gill --- .../presentation_exchange/pres_exch.py | 24 +++++++------------ .../pres_exch_handler.py | 12 +++++----- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch.py index 1f56618daf..63f3d5a587 100644 --- a/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch.py +++ b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch.py @@ -579,25 +579,19 @@ def extract_info(self, data, **kwargs): def reformat_data(self, data, **kwargs): """Support serialization of statuses according to DIF spec.""" if "status_active" in data: - tmp_dict = {} - tmp_dict["directive"] = data.get("status_active") - tmp_dict2 = data.get("statuses") or {} - tmp_dict2["active"] = tmp_dict - data["statuses"] = tmp_dict2 + statuses = data.get("statuses", {}) + statuses["active"] = {"directive": data.get("status_active")} + data["statuses"] = statuses del data["status_active"] if "status_suspended" in data: - tmp_dict = {} - tmp_dict["directive"] = data.get("status_suspended") - tmp_dict2 = data.get("statuses") or {} - tmp_dict2["suspended"] = tmp_dict - data["statuses"] = tmp_dict2 + statuses = data.get("statuses", {}) + statuses["suspended"] = {"directive": data.get("status_suspended")} + data["statuses"] = statuses del data["status_suspended"] if "status_revoked" in data: - tmp_dict = {} - tmp_dict["directive"] = data.get("status_revoked") - tmp_dict2 = data.get("statuses") or {} - tmp_dict2["revoked"] = tmp_dict - data["statuses"] = tmp_dict2 + statuses = data.get("statuses", {}) + statuses["revoked"] = {"directive": data.get("status_revoked")} + data["statuses"] = statuses del data["status_revoked"] return data diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py index c9bb0fa893..73ef7c3e1d 100644 --- a/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py +++ b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py @@ -137,7 +137,7 @@ async def make_requirement( ) except Exception as err: raise PresentationExchError( - "Error creating requirement " f"inside to_requirement function, {err}" + f"Error creating requirement inside to_requirement function, {err}" ) return requirement @@ -653,7 +653,7 @@ async def apply_requirements(req: Requirement, credentials: Sequence[VCRecord]) return {} nested_result = [] - tmp_dict = {} + given_id_descriptors = {} # recursion logic for nested requirements for requirement in req.nested_req: # recursive call @@ -670,7 +670,7 @@ async def apply_requirements(req: Requirement, credentials: Sequence[VCRecord]) for descriptor_id in result.keys(): credential_list = result.get(descriptor_id) for credential in credential_list: - if credential.given_id not in tmp_dict: + if credential.given_id not in given_id_descriptors: tmp_dict[credential.given_id] = {} tmp_dict[credential.given_id][descriptor_id] = {} @@ -678,11 +678,11 @@ async def apply_requirements(req: Requirement, credentials: Sequence[VCRecord]) nested_result.append(result) exclude = {} - for k in tmp_dict.keys(): + for given_id in given_id_descriptors.keys(): # Check if number of applicable credentials # does not meet requirement specification - if not is_len_applicable(req, len(tmp_dict[k])): - for descriptor_id in tmp_dict[k]: + if not is_len_applicable(req, len(given_id_descriptors[k])): + for descriptor_id in given_id_descriptors[given_id]: # Add to exclude dict # with cred.given_id + descriptor_id as key exclude[descriptor_id + k] = {} From 4fcde0d463ab9ff56f4daa3092017662f048f451 Mon Sep 17 00:00:00 2001 From: Shaanjot Gill Date: Tue, 23 Mar 2021 08:19:22 -0700 Subject: [PATCH 040/138] Fixed tmp_ Signed-off-by: Shaanjot Gill --- .../presentation_exchange/pres_exch_handler.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py index 73ef7c3e1d..dc9c2a493d 100644 --- a/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py +++ b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py @@ -660,7 +660,7 @@ async def apply_requirements(req: Requirement, credentials: Sequence[VCRecord]) result = await apply_requirements(requirement, credentials) if result == {}: continue - # tmp_dict maps applicable credentials to their respective descriptor. + # given_id_descriptors maps applicable credentials to their respective descriptor. # Structure: {cred.given_id: { # desc_id_1: {} # }, @@ -671,8 +671,8 @@ async def apply_requirements(req: Requirement, credentials: Sequence[VCRecord]) credential_list = result.get(descriptor_id) for credential in credential_list: if credential.given_id not in given_id_descriptors: - tmp_dict[credential.given_id] = {} - tmp_dict[credential.given_id][descriptor_id] = {} + given_id_descriptors[credential.given_id] = {} + given_id_descriptors[credential.given_id][descriptor_id] = {} if len(result.keys()) != 0: nested_result.append(result) @@ -720,20 +720,20 @@ async def merge_nested_results(nested_result: Sequence[dict], exclude: dict) -> for res in nested_result: for key in res.keys(): credentials = res[key] - tmp_dict = {} + given_id_dict = {} merged_credentials = [] if key in result: for credential in result[key]: - if credential.given_id not in tmp_dict: + if credential.given_id not in given_id_dict: merged_credentials.append(credential) - tmp_dict[credential.given_id] = {} + given_id_dict[credential.given_id] = {} for credential in credentials: - if credential.given_id not in tmp_dict: + if credential.given_id not in given_id_dict: if (key + (credential.given_id)) not in exclude: merged_credentials.append(credential) - tmp_dict[credential.given_id] = {} + given_id_dict[credential.given_id] = {} result[key] = merged_credentials return result From 8a205611ff1b157ab6a3a164b081cc14379e76be Mon Sep 17 00:00:00 2001 From: Shaanjot Gill Date: Tue, 23 Mar 2021 08:30:16 -0700 Subject: [PATCH 041/138] Fixes Signed-off-by: Shaanjot Gill --- .../present_proof/presentation_exchange/pres_exch_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py index dc9c2a493d..b18f0c17a2 100644 --- a/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py +++ b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py @@ -681,11 +681,11 @@ async def apply_requirements(req: Requirement, credentials: Sequence[VCRecord]) for given_id in given_id_descriptors.keys(): # Check if number of applicable credentials # does not meet requirement specification - if not is_len_applicable(req, len(given_id_descriptors[k])): + if not is_len_applicable(req, len(given_id_descriptors[given_id])): for descriptor_id in given_id_descriptors[given_id]: # Add to exclude dict # with cred.given_id + descriptor_id as key - exclude[descriptor_id + k] = {} + exclude[descriptor_id + given_id] = {} # merging credentials and excluding credentials that don't satisfy the requirement return await merge_nested_results(nested_result=nested_result, exclude=exclude) From a351c553ca72b26e79158e2d519159e2cead39ba Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Wed, 24 Mar 2021 11:50:00 +0100 Subject: [PATCH 042/138] add test Signed-off-by: Timo Glastra --- aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py | 61 ++++++++++++++++--- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py index 462e966316..f3900dd95b 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py @@ -29,15 +29,19 @@ class TestLinkedDataVerifiableCredential(TestCase): test_seed = "testseed000000000000000000000001" - key_info: KeyInfo = None + test_seed2 = "testseed000000000000000000000002" + issuer_key_info: KeyInfo = None async def setUp(self): self.profile = InMemoryProfile.test_profile() self.wallet = InMemoryWallet(self.profile) - self.key_info = await self.wallet.create_signing_key(self.test_seed) - self.verification_method = DIDKey.from_public_key_b58( - self.key_info.verkey, KeyType.ED25519 + + self.issuer_key_info = await self.wallet.create_signing_key(self.test_seed) + self.issuer_verification_method = DIDKey.from_public_key_b58( + self.issuer_key_info.verkey, KeyType.ED25519 ).key_id + + self.holder_key_info = await self.wallet.create_signing_key(self.test_seed2) self.presentation_challenge = "2b1bbff6-e608-4368-bf84-67471b27e41c" async def test_issue(self): @@ -45,9 +49,9 @@ async def test_issue(self): # as during verification a lot of information can be extracted # from the proof / document suite = Ed25519Signature2018( - verification_method=self.verification_method, + verification_method=self.issuer_verification_method, key_pair=Ed25519WalletKeyPair( - wallet=self.wallet, public_key_base58=self.key_info.verkey + wallet=self.wallet, public_key_base58=self.issuer_key_info.verkey ), date=datetime.strptime("2019-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), ) @@ -81,9 +85,9 @@ async def test_create_presentation(self): ) suite = Ed25519Signature2018( - verification_method=self.verification_method, + verification_method=self.issuer_verification_method, key_pair=Ed25519WalletKeyPair( - wallet=self.wallet, public_key_base58=self.key_info.verkey + wallet=self.wallet, public_key_base58=self.issuer_key_info.verkey ), date=datetime.strptime("2020-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), ) @@ -99,6 +103,47 @@ async def test_create_presentation(self): assert presentation == PRESENTATION_SIGNED + async def test_create_presentation_same_subject(self): + # TODO: subject is holder proof purpose or something? + issuer_suite = Ed25519Signature2018( + verification_method=self.issuer_verification_method, + key_pair=Ed25519WalletKeyPair( + wallet=self.wallet, public_key_base58=self.issuer_key_info.verkey + ), + date=datetime.strptime("2020-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), + ) + + holder_did = DIDKey.from_public_key_b58( + self.holder_key_info.verkey, KeyType.ED25519 + ) + holder_verification_method = holder_did.key_id + + holder_suite = Ed25519Signature2018( + verification_method=holder_verification_method, + key_pair=Ed25519WalletKeyPair( + wallet=self.wallet, public_key_base58=self.holder_key_info.verkey + ), + date=datetime.strptime("2021-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), + ) + + credential_template = CREDENTIAL_TEMPLATE.copy() + credential_template["credentialSubject"]["id"] = holder_did.did + + issued = await issue( + credential=credential_template, + suite=issuer_suite, + document_loader=custom_document_loader, + ) + + unsigned_presentation = await create_presentation(credentials=[issued]) + + presentation = await sign_presentation( + presentation=unsigned_presentation, + suite=holder_suite, + document_loader=custom_document_loader, + challenge=self.presentation_challenge, + ) + async def test_verify_presentation(self): # TODO: verify with multiple suites suite = Ed25519Signature2018( From 34070c6f9f03ba4fd84719246b2160394091f121 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Wed, 24 Mar 2021 11:56:55 +0100 Subject: [PATCH 043/138] fix for vc record api changes Signed-off-by: Timo Glastra --- .../protocols/issue_credential/v2_0/formats/ld_proof/handler.py | 2 +- aries_cloudagent/storage/vc_holder/vc_record.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index 659ba345b3..c3459d3703 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -336,7 +336,7 @@ async def store_credential( issuer_id=credential.issuer_id, subject_ids=credential.credential_subject_ids, schema_ids=[], # Schemas not supported yet - value=json.dumps(credential.serialize()), + cred_value=credential.serialize(), given_id=credential.id, record_id=cred_id, ) diff --git a/aries_cloudagent/storage/vc_holder/vc_record.py b/aries_cloudagent/storage/vc_holder/vc_record.py index 7afda0b85f..d078845fef 100644 --- a/aries_cloudagent/storage/vc_holder/vc_record.py +++ b/aries_cloudagent/storage/vc_holder/vc_record.py @@ -160,7 +160,7 @@ def deserialize_jsonld_cred(cls, cred_json: str) -> "VCRecord": issuer_id=issuer_id, subject_ids=subject_ids, given_id=given_id, - value=value, + cred_value=value, tags=tags, record_id=record_id, schema_ids=schema_ids, From 9f19cffc4f13fb711ba0874c5f6ce57c0c1a726f Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Wed, 24 Mar 2021 23:13:50 +0100 Subject: [PATCH 044/138] add bls1231 g1, g2 and g1g2 did key support Signed-off-by: Timo Glastra --- aries_cloudagent/did/did_key.py | 130 +++++++++++++++- aries_cloudagent/did/tests/test_did_key.py | 94 ------------ .../did/tests/test_did_key_bls12381g1.py | 70 +++++++++ .../did/tests/test_did_key_bls12381g1g2.py | 107 +++++++++++++ .../did/tests/test_did_key_bls12381g2.py | 66 +++++++++ .../did/tests/test_did_key_ed25519.py | 60 ++++++++ .../did/tests/test_did_key_x25519.py | 60 ++++++++ aries_cloudagent/did/tests/test_dids.py | 140 ++++++++++++++++++ aries_cloudagent/wallet/crypto.py | 4 + requirements.txt | 2 +- 10 files changed, 634 insertions(+), 99 deletions(-) delete mode 100644 aries_cloudagent/did/tests/test_did_key.py create mode 100644 aries_cloudagent/did/tests/test_did_key_bls12381g1.py create mode 100644 aries_cloudagent/did/tests/test_did_key_bls12381g1g2.py create mode 100644 aries_cloudagent/did/tests/test_did_key_bls12381g2.py create mode 100644 aries_cloudagent/did/tests/test_did_key_ed25519.py create mode 100644 aries_cloudagent/did/tests/test_did_key_x25519.py create mode 100644 aries_cloudagent/did/tests/test_dids.py diff --git a/aries_cloudagent/did/did_key.py b/aries_cloudagent/did/did_key.py index b60dfb5e8f..505eb16fac 100644 --- a/aries_cloudagent/did/did_key.py +++ b/aries_cloudagent/did/did_key.py @@ -96,6 +96,123 @@ def key_id(self) -> str: return f"{self.did}#{self.fingerprint}" +def construct_did_key_bls12381g2(did_key: "DIDKey") -> dict: + """Construct BLS12381G2 did:key + + Args: + did_key (DIDKey): did key instance to parse bls12381g2 did:key document from + + Returns: + dict: The bls12381g2 did:key did document + """ + + return construct_did_signature_key_base( + id=did_key.did, + key_id=did_key.key_id, + verification_method={ + "id": did_key.key_id, + "type": "Bls12381G2Key2020", + "controller": did_key.did, + "publicKeyBase58": did_key.public_key_b58, + }, + ) + + +def construct_did_key_bls12381g1(did_key: "DIDKey") -> dict: + """Construct BLS12381G1 did:key + + Args: + did_key (DIDKey): did key instance to parse bls12381g1 did:key document from + + Returns: + dict: The bls12381g1 did:key did document + """ + + return construct_did_signature_key_base( + id=did_key.did, + key_id=did_key.key_id, + verification_method={ + "id": did_key.key_id, + "type": "Bls12381G1Key2020", + "controller": did_key.did, + "publicKeyBase58": did_key.public_key_b58, + }, + ) + + +def construct_did_key_bls12381g1g2(did_key: "DIDKey") -> dict: + """Construct BLS12381G1G2 did:key + + Args: + did_key (DIDKey): did key instance to parse bls12381g1g2 did:key document from + + Returns: + dict: The bls12381g1g2 did:key did document + """ + + g1_public_key = did_key.public_key[:48] + g2_public_key = did_key.public_key[48:] + + bls12381g1_key = DIDKey.from_public_key(g1_public_key, KeyType.BLS12381G1) + bls12381g2_key = DIDKey.from_public_key(g2_public_key, KeyType.BLS12381G2) + + bls12381g1_key_id = f"{did_key.did}#{bls12381g1_key.fingerprint}" + bls12381g2_key_id = f"{did_key.did}#{bls12381g2_key.fingerprint}" + + return { + "@context": "https://www.w3.org/ns/did/v1", + "id": did_key.did, + "verificationMethod": [ + { + "id": bls12381g1_key_id, + "type": "Bls12381G1Key2020", + "controller": did_key.did, + "publicKeyBase58": bls12381g1_key.public_key_b58, + }, + { + "id": bls12381g2_key_id, + "type": "Bls12381G2Key2020", + "controller": did_key.did, + "publicKeyBase58": bls12381g2_key.public_key_b58, + }, + ], + "authentication": [bls12381g1_key_id, bls12381g2_key_id], + "assertionMethod": [bls12381g1_key_id, bls12381g2_key_id], + "capabilityDelegation": [bls12381g1_key_id, bls12381g2_key_id], + "capabilityInvocation": [bls12381g1_key_id, bls12381g2_key_id], + "keyAgreement": [], + } + + +def construct_did_key_x25519(did_key: "DIDKey") -> dict: + """Construct X25519 did:key + + Args: + did_key (DIDKey): did key instance to parse x25519 did:key document from + + Returns: + dict: The x25519 did:key did document + """ + + return { + "@context": "https://www.w3.org/ns/did/v1", + "id": did_key.did, + "verificationMethod": [ + { + "id": did_key.key_id, + "type": "X25519KeyAgreementKey2019", + "controller": did_key.did, + "publicKeyBase58": did_key.public_key_b58, + }, + ], + "authentication": [], + "assertionMethod": [], + "capabilityDelegation": [], + "capabilityInvocation": [], + "keyAgreement": [did_key.key_id], + } + + def construct_did_key_ed25519(did_key: "DIDKey") -> dict: """Construct Ed25519 did:key @@ -106,8 +223,7 @@ def construct_did_key_ed25519(did_key: "DIDKey") -> dict: dict: The ed25519 did:key did document """ curve25519 = ed25519_pk_to_curve25519(did_key.public_key) - # TODO: update once https://github.com/multiformats/py-multicodec/pull/14 is merged - curve25519_fingerprint = "z" + bytes_to_b58(b"".join([b"\xec\x01", curve25519])) + x25519 = DIDKey.from_public_key(curve25519, KeyType.X25519) did_doc = construct_did_signature_key_base( id=did_key.did, @@ -123,7 +239,7 @@ def construct_did_key_ed25519(did_key: "DIDKey") -> dict: # Ed25519 has pair with X25519 did_doc["keyAgreement"].append( { - "id": f"{did_key.did}#{curve25519_fingerprint}", + "id": f"{did_key.did}#{x25519.fingerprint}", "type": "X25519KeyAgreementKey2019", "controller": did_key.did, "publicKeyBase58": bytes_to_b58(curve25519), @@ -153,4 +269,10 @@ def construct_did_signature_key_base( } -DID_KEY_RESOLVERS = {KeyType.ED25519: construct_did_key_ed25519} \ No newline at end of file +DID_KEY_RESOLVERS = { + KeyType.ED25519: construct_did_key_ed25519, + KeyType.X25519: construct_did_key_x25519, + KeyType.BLS12381G2: construct_did_key_bls12381g2, + KeyType.BLS12381G1: construct_did_key_bls12381g1, + KeyType.BLS12381G1G2: construct_did_key_bls12381g1g2, +} diff --git a/aries_cloudagent/did/tests/test_did_key.py b/aries_cloudagent/did/tests/test_did_key.py deleted file mode 100644 index 87e4b0c51d..0000000000 --- a/aries_cloudagent/did/tests/test_did_key.py +++ /dev/null @@ -1,94 +0,0 @@ -from unittest import TestCase - -from ...wallet.crypto import KeyType -from ...wallet.util import b58_to_bytes -from ..did_key import DIDKey, DID_KEY_RESOLVERS - -TEST_ED25519_BASE58_KEY = "8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K" -TEST_ED25519_FINGERPRINT = "z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" -TEST_ED25519_DID = f"did:key:{TEST_ED25519_FINGERPRINT}" -TEST_ED25519_KEY_ID = f"{TEST_ED25519_DID}#{TEST_ED25519_FINGERPRINT}" - - -class TestDIDKey(TestCase): - def test_ed25519_from_public_key(self): - key_bytes = b58_to_bytes(TEST_ED25519_BASE58_KEY) - did_key = DIDKey.from_public_key(key_bytes, KeyType.ED25519) - - assert did_key.did == TEST_ED25519_DID - - def test_ed25519_from_public_key_b58(self): - did_key = DIDKey.from_public_key_b58( - "8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K", KeyType.ED25519 - ) - - assert did_key.did == "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" - - def test_ed25519_from_fingerprint(self): - did_key = DIDKey.from_fingerprint(TEST_ED25519_FINGERPRINT) - - assert did_key.did == TEST_ED25519_DID - assert did_key.public_key_b58 == TEST_ED25519_BASE58_KEY - - def test_ed25519_from_did(self): - did_key = DIDKey.from_did(TEST_ED25519_DID) - - assert did_key.public_key_b58 == TEST_ED25519_BASE58_KEY - - def test_ed25519_properties(self): - did_key = DIDKey.from_did(TEST_ED25519_DID) - - assert did_key.fingerprint == TEST_ED25519_FINGERPRINT - assert did_key.did == TEST_ED25519_DID - assert did_key.public_key_b58 == TEST_ED25519_BASE58_KEY - assert did_key.public_key == b58_to_bytes(TEST_ED25519_BASE58_KEY) - assert did_key.key_type == KeyType.ED25519 - assert did_key.key_id == TEST_ED25519_KEY_ID - - def test_ed25519_diddoc(self): - did_key = DIDKey.from_did(TEST_ED25519_DID) - - resolver = DID_KEY_RESOLVERS[KeyType.ED25519] - - assert resolver(did_key) == did_key.did_doc - - def test_ed25519_resolver(self): - did_key = DIDKey.from_did(TEST_ED25519_DID) - resolver = DID_KEY_RESOLVERS[KeyType.ED25519] - did_doc = resolver(did_key) - - # resolved using uniresolver, updated to did v1 - expected = { - "@context": "https://www.w3.org/ns/did/v1", - "id": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", - "verificationMethod": [ - { - "id": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", - "type": "Ed25519VerificationKey2018", - "controller": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", - "publicKeyBase58": "8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K", - } - ], - "authentication": [ - "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" - ], - "assertionMethod": [ - "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" - ], - "capabilityDelegation": [ - "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" - ], - "capabilityInvocation": [ - "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" - ], - "keyAgreement": [ - { - "id": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6LShpNhGwSupbB7zjuivH156vhLJBDDzmQtA4BY9S94pe1K", - "type": "X25519KeyAgreementKey2019", - "controller": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", - "publicKeyBase58": "79CXkde3j8TNuMXxPdV7nLUrT2g7JAEjH5TreyVY7GEZ", - } - ], - } - - assert did_doc == expected diff --git a/aries_cloudagent/did/tests/test_did_key_bls12381g1.py b/aries_cloudagent/did/tests/test_did_key_bls12381g1.py new file mode 100644 index 0000000000..26906d0a98 --- /dev/null +++ b/aries_cloudagent/did/tests/test_did_key_bls12381g1.py @@ -0,0 +1,70 @@ +from unittest import TestCase + +from ...wallet.crypto import KeyType +from ...wallet.util import b58_to_bytes +from ..did_key import DIDKey, DID_KEY_RESOLVERS +from .test_dids import ( + DID_BLS12381G1_z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA, +) + +TEST_BLS12381G1_BASE58_KEY = ( + "6FywSzB5BPd7xehCo1G4nYHAoZPMMP3gd4PLnvgA6SsTsogtz8K7RDznqLpFPLZXAE" +) +TEST_BLS12381G1_FINGERPRINT = ( + "z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA" +) +TEST_BLS12381G1_DID = f"did:key:{TEST_BLS12381G1_FINGERPRINT}" +TEST_BLS12381G1_KEY_ID = f"{TEST_BLS12381G1_DID}#{TEST_BLS12381G1_FINGERPRINT}" + + +class TestDIDKey(TestCase): + def test_bls12381g1_from_public_key(self): + key_bytes = b58_to_bytes(TEST_BLS12381G1_BASE58_KEY) + did_key = DIDKey.from_public_key(key_bytes, KeyType.BLS12381G1) + + assert did_key.did == TEST_BLS12381G1_DID + + def test_bls12381g1_from_public_key_b58(self): + did_key = DIDKey.from_public_key_b58( + TEST_BLS12381G1_BASE58_KEY, KeyType.BLS12381G1 + ) + + assert did_key.did == TEST_BLS12381G1_DID + + def test_bls12381g1_from_fingerprint(self): + did_key = DIDKey.from_fingerprint(TEST_BLS12381G1_FINGERPRINT) + + assert did_key.did == TEST_BLS12381G1_DID + assert did_key.public_key_b58 == TEST_BLS12381G1_BASE58_KEY + + def test_bls12381g1_from_did(self): + did_key = DIDKey.from_did(TEST_BLS12381G1_DID) + + assert did_key.public_key_b58 == TEST_BLS12381G1_BASE58_KEY + + def test_bls12381g1_properties(self): + did_key = DIDKey.from_did(TEST_BLS12381G1_DID) + + assert did_key.fingerprint == TEST_BLS12381G1_FINGERPRINT + assert did_key.did == TEST_BLS12381G1_DID + assert did_key.public_key_b58 == TEST_BLS12381G1_BASE58_KEY + assert did_key.public_key == b58_to_bytes(TEST_BLS12381G1_BASE58_KEY) + assert did_key.key_type == KeyType.BLS12381G1 + assert did_key.key_id == TEST_BLS12381G1_KEY_ID + + def test_bls12381g1_diddoc(self): + did_key = DIDKey.from_did(TEST_BLS12381G1_DID) + + resolver = DID_KEY_RESOLVERS[KeyType.BLS12381G1] + + assert resolver(did_key) == did_key.did_doc + + def test_bls12381g1_resolver(self): + did_key = DIDKey.from_did(TEST_BLS12381G1_DID) + resolver = DID_KEY_RESOLVERS[KeyType.BLS12381G1] + did_doc = resolver(did_key) + + assert ( + did_doc + == DID_BLS12381G1_z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA + ) diff --git a/aries_cloudagent/did/tests/test_did_key_bls12381g1g2.py b/aries_cloudagent/did/tests/test_did_key_bls12381g1g2.py new file mode 100644 index 0000000000..4cee6a88bd --- /dev/null +++ b/aries_cloudagent/did/tests/test_did_key_bls12381g1g2.py @@ -0,0 +1,107 @@ +from unittest import TestCase + +from ...wallet.crypto import KeyType +from ...wallet.util import b58_to_bytes +from ..did_key import DIDKey, DID_KEY_RESOLVERS +from .test_dids import ( + DID_BLS12381G1G2_z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s, +) + +TEST_BLS12381G1G2_BASE58_KEY = "AQ4MiG1JKHmM5N4CgkF9uQ484PHN7gXB3ctF4ayL8hT6FdD6rcfFS3ZnMNntYsyJBckfNPf3HL8VU8jzgyT3qX88Yg3TeF2NkG2aZnJDNnXH1jkJStWMxjLw22LdphqAj1rSorsDhHjE8Rtz61bD6FP9aPokQUDVpZ4zXqsXVcxJ7YEc66TTLTTPwQPS7uNM4u2Fs" +TEST_BLS12381G1G2_FINGERPRINT = "z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s" +TEST_BLS12381G1G2_DID = f"did:key:{TEST_BLS12381G1G2_FINGERPRINT}" + +TEST_BLS12381G1_BASE58_KEY = ( + "7BVES4h78wzabPAfMhchXyH5d8EX78S5TtzePH2YkftWcE6by9yj3NTAv9nsyCeYch" +) +TEST_BLS12381G1_FINGERPRINT = ( + "z3tEG5qmJZX29jJSX5kyhDR5YJNnefJFdwTxRqk6zbEPv4Pf2xF12BpmXv9NExxSRFGfxd" +) +TEST_BLS12381G1_DID = f"did:key:{TEST_BLS12381G1_FINGERPRINT}" + +TEST_BLS12381G2_BASE58_KEY = "26d2BdqELsXg7ZHCWKL2D5Y2S7mYrpkdhJemSEEvokd4qy4TULJeeU44hYPGKo4x4DbBp5ARzkv1D6xuB3bmhpdpKAXuXtode67wzh9PCtW8kTqQhH19VSiFZkLNkhe9rtf3" +TEST_BLS12381G2_FINGERPRINT = "zUC7LTa4hWtaE9YKyDsMVGiRNqPMN3s4rjBdB3MFi6PcVWReNfR72y3oGW2NhNcaKNVhMobh7aHp8oZB3qdJCs7RebM2xsodrSm8MmePbN25NTGcpjkJMwKbcWfYDX7eHCJjPGM" +TEST_BLS12381G2_DID = f"did:key:{TEST_BLS12381G2_FINGERPRINT}" + +# The tests here are a bit quirky because g1g2 is a concatenation of g1 and g2 public key bytes +# but it works with the already existing did key implementation. +class TestDIDKey(TestCase): + def test_bls12381g1g2_from_public_key(self): + key_bytes = b58_to_bytes(TEST_BLS12381G1G2_BASE58_KEY) + did_key = DIDKey.from_public_key(key_bytes, KeyType.BLS12381G1G2) + + assert did_key.did == TEST_BLS12381G1G2_DID + + def test_bls12381g1g2_from_public_key_b58(self): + did_key = DIDKey.from_public_key_b58( + TEST_BLS12381G1G2_BASE58_KEY, KeyType.BLS12381G1G2 + ) + + assert did_key.did == TEST_BLS12381G1G2_DID + + def test_bls12381g1g2_from_fingerprint(self): + did_key = DIDKey.from_fingerprint(TEST_BLS12381G1G2_FINGERPRINT) + + assert did_key.did == TEST_BLS12381G1G2_DID + assert did_key.public_key_b58 == TEST_BLS12381G1G2_BASE58_KEY + + def test_bls12381g1g2_from_did(self): + did_key = DIDKey.from_did(TEST_BLS12381G1G2_DID) + + assert did_key.public_key_b58 == TEST_BLS12381G1G2_BASE58_KEY + + def test_bls12381g1g2_properties(self): + did_key = DIDKey.from_did(TEST_BLS12381G1G2_DID) + + assert did_key.fingerprint == TEST_BLS12381G1G2_FINGERPRINT + assert did_key.did == TEST_BLS12381G1G2_DID + assert did_key.public_key_b58 == TEST_BLS12381G1G2_BASE58_KEY + assert did_key.public_key == b58_to_bytes(TEST_BLS12381G1G2_BASE58_KEY) + assert did_key.key_type == KeyType.BLS12381G1G2 + + def test_bls12381g1g2_diddoc(self): + did_key = DIDKey.from_did(TEST_BLS12381G1G2_DID) + + resolver = DID_KEY_RESOLVERS[KeyType.BLS12381G1G2] + + assert resolver(did_key) == did_key.did_doc + + def test_bls12381g1g2_resolver(self): + did_key = DIDKey.from_did( + "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s" + ) + resolver = DID_KEY_RESOLVERS[KeyType.BLS12381G1G2] + did_doc = resolver(did_key) + + assert ( + did_doc + == DID_BLS12381G1G2_z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s + ) + + def test_bls12381g1g1_to_g1(self): + g1g2_did = DIDKey.from_did(TEST_BLS12381G1G2_DID) + + # TODO: add easier method to go form g1 <- g1g2 -> g2 + # First 48 bytes is g1 key + g1_public_key = g1g2_did.public_key[:48] + g1_did = DIDKey.from_public_key(g1_public_key, KeyType.BLS12381G1) + + assert g1_did.fingerprint == TEST_BLS12381G1_FINGERPRINT + assert g1_did.did == TEST_BLS12381G1_DID + assert g1_did.public_key_b58 == TEST_BLS12381G1_BASE58_KEY + assert g1_did.public_key == b58_to_bytes(TEST_BLS12381G1_BASE58_KEY) + assert g1_did.key_type == KeyType.BLS12381G1 + + def test_bls12381g1g1_to_g2(self): + g1g2_did = DIDKey.from_did(TEST_BLS12381G1G2_DID) + + # TODO: add easier method to go form g1 <- g1g2 -> g2 + # From 48 bytes is g2 key + g2_public_key = g1g2_did.public_key[48:] + g2_did = DIDKey.from_public_key(g2_public_key, KeyType.BLS12381G2) + + assert g2_did.fingerprint == TEST_BLS12381G2_FINGERPRINT + assert g2_did.did == TEST_BLS12381G2_DID + assert g2_did.public_key_b58 == TEST_BLS12381G2_BASE58_KEY + assert g2_did.public_key == b58_to_bytes(TEST_BLS12381G2_BASE58_KEY) + assert g2_did.key_type == KeyType.BLS12381G2 diff --git a/aries_cloudagent/did/tests/test_did_key_bls12381g2.py b/aries_cloudagent/did/tests/test_did_key_bls12381g2.py new file mode 100644 index 0000000000..20d2ede17b --- /dev/null +++ b/aries_cloudagent/did/tests/test_did_key_bls12381g2.py @@ -0,0 +1,66 @@ +from unittest import TestCase + +from ...wallet.crypto import KeyType +from ...wallet.util import b58_to_bytes +from ..did_key import DIDKey, DID_KEY_RESOLVERS +from .test_dids import ( + DID_BLS12381G2_zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT, +) + +TEST_BLS12381G2_BASE58_KEY = "mxE4sHTpbPcmxNviRVR9r7D2taXcNyVJmf9TBUFS1gRt3j3Ej9Seo59GQeCzYwbQgDrfWCwEJvmBwjLvheAky5N2NqFVzk4kuq3S8g4Fmekai4P622vHqWjFrsioYYDqhf9" +TEST_BLS12381G2_FINGERPRINT = "zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT" +TEST_BLS12381G2_DID = f"did:key:{TEST_BLS12381G2_FINGERPRINT}" +TEST_BLS12381G2_KEY_ID = f"{TEST_BLS12381G2_DID}#{TEST_BLS12381G2_FINGERPRINT}" + + +class TestDIDKey(TestCase): + def test_bls12381g2_from_public_key(self): + key_bytes = b58_to_bytes(TEST_BLS12381G2_BASE58_KEY) + did_key = DIDKey.from_public_key(key_bytes, KeyType.BLS12381G2) + + assert did_key.did == TEST_BLS12381G2_DID + + def test_bls12381g2_from_public_key_b58(self): + did_key = DIDKey.from_public_key_b58( + TEST_BLS12381G2_BASE58_KEY, KeyType.BLS12381G2 + ) + + assert did_key.did == TEST_BLS12381G2_DID + + def test_bls12381g2_from_fingerprint(self): + did_key = DIDKey.from_fingerprint(TEST_BLS12381G2_FINGERPRINT) + + assert did_key.did == TEST_BLS12381G2_DID + assert did_key.public_key_b58 == TEST_BLS12381G2_BASE58_KEY + + def test_bls12381g2_from_did(self): + did_key = DIDKey.from_did(TEST_BLS12381G2_DID) + + assert did_key.public_key_b58 == TEST_BLS12381G2_BASE58_KEY + + def test_bls12381g2_properties(self): + did_key = DIDKey.from_did(TEST_BLS12381G2_DID) + + assert did_key.fingerprint == TEST_BLS12381G2_FINGERPRINT + assert did_key.did == TEST_BLS12381G2_DID + assert did_key.public_key_b58 == TEST_BLS12381G2_BASE58_KEY + assert did_key.public_key == b58_to_bytes(TEST_BLS12381G2_BASE58_KEY) + assert did_key.key_type == KeyType.BLS12381G2 + assert did_key.key_id == TEST_BLS12381G2_KEY_ID + + def test_bls12381g2_diddoc(self): + did_key = DIDKey.from_did(TEST_BLS12381G2_DID) + + resolver = DID_KEY_RESOLVERS[KeyType.BLS12381G2] + + assert resolver(did_key) == did_key.did_doc + + def test_bls12381g2_resolver(self): + did_key = DIDKey.from_did(TEST_BLS12381G2_DID) + resolver = DID_KEY_RESOLVERS[KeyType.BLS12381G2] + did_doc = resolver(did_key) + + assert ( + did_doc + == DID_BLS12381G2_zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT + ) diff --git a/aries_cloudagent/did/tests/test_did_key_ed25519.py b/aries_cloudagent/did/tests/test_did_key_ed25519.py new file mode 100644 index 0000000000..6afe0aebb4 --- /dev/null +++ b/aries_cloudagent/did/tests/test_did_key_ed25519.py @@ -0,0 +1,60 @@ +from unittest import TestCase + +from ...wallet.crypto import KeyType +from ...wallet.util import b58_to_bytes +from ..did_key import DIDKey, DID_KEY_RESOLVERS +from .test_dids import DID_ED25519_z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th + +TEST_ED25519_BASE58_KEY = "8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K" +TEST_ED25519_FINGERPRINT = "z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" +TEST_ED25519_DID = f"did:key:{TEST_ED25519_FINGERPRINT}" +TEST_ED25519_KEY_ID = f"{TEST_ED25519_DID}#{TEST_ED25519_FINGERPRINT}" + + +class TestDIDKey(TestCase): + def test_ed25519_from_public_key(self): + key_bytes = b58_to_bytes(TEST_ED25519_BASE58_KEY) + did_key = DIDKey.from_public_key(key_bytes, KeyType.ED25519) + + assert did_key.did == TEST_ED25519_DID + + def test_ed25519_from_public_key_b58(self): + did_key = DIDKey.from_public_key_b58(TEST_ED25519_BASE58_KEY, KeyType.ED25519) + + assert did_key.did == TEST_ED25519_DID + + def test_ed25519_from_fingerprint(self): + did_key = DIDKey.from_fingerprint(TEST_ED25519_FINGERPRINT) + + assert did_key.did == TEST_ED25519_DID + assert did_key.public_key_b58 == TEST_ED25519_BASE58_KEY + + def test_ed25519_from_did(self): + did_key = DIDKey.from_did(TEST_ED25519_DID) + + assert did_key.public_key_b58 == TEST_ED25519_BASE58_KEY + + def test_ed25519_properties(self): + did_key = DIDKey.from_did(TEST_ED25519_DID) + + assert did_key.fingerprint == TEST_ED25519_FINGERPRINT + assert did_key.did == TEST_ED25519_DID + assert did_key.public_key_b58 == TEST_ED25519_BASE58_KEY + assert did_key.public_key == b58_to_bytes(TEST_ED25519_BASE58_KEY) + assert did_key.key_type == KeyType.ED25519 + assert did_key.key_id == TEST_ED25519_KEY_ID + + def test_ed25519_diddoc(self): + did_key = DIDKey.from_did(TEST_ED25519_DID) + + resolver = DID_KEY_RESOLVERS[KeyType.ED25519] + + assert resolver(did_key) == did_key.did_doc + + def test_ed25519_resolver(self): + did_key = DIDKey.from_did(TEST_ED25519_DID) + resolver = DID_KEY_RESOLVERS[KeyType.ED25519] + did_doc = resolver(did_key) + + # resolved using uniresolver, updated to did v1 + assert did_doc == DID_ED25519_z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th diff --git a/aries_cloudagent/did/tests/test_did_key_x25519.py b/aries_cloudagent/did/tests/test_did_key_x25519.py new file mode 100644 index 0000000000..d1ef8363fc --- /dev/null +++ b/aries_cloudagent/did/tests/test_did_key_x25519.py @@ -0,0 +1,60 @@ +from unittest import TestCase + +from ...wallet.crypto import KeyType +from ...wallet.util import b58_to_bytes +from ..did_key import DIDKey, DID_KEY_RESOLVERS +from .test_dids import DID_X25519_z6LShLeXRTzevtwcfehaGEzCMyL3bNsAeKCwcqwJxyCo63yE + +TEST_X25519_BASE58_KEY = "6fUMuABnqSDsaGKojbUF3P7ZkEL3wi2njsDdUWZGNgCU" +TEST_X25519_FINGERPRINT = "z6LShLeXRTzevtwcfehaGEzCMyL3bNsAeKCwcqwJxyCo63yE" +TEST_X25519_DID = f"did:key:{TEST_X25519_FINGERPRINT}" +TEST_X25519_KEY_ID = f"{TEST_X25519_DID}#{TEST_X25519_FINGERPRINT}" + + +class TestDIDKey(TestCase): + def test_x25519_from_public_key(self): + key_bytes = b58_to_bytes(TEST_X25519_BASE58_KEY) + did_key = DIDKey.from_public_key(key_bytes, KeyType.X25519) + + assert did_key.did == TEST_X25519_DID + + def test_x25519_from_public_key_b58(self): + did_key = DIDKey.from_public_key_b58(TEST_X25519_BASE58_KEY, KeyType.X25519) + + assert did_key.did == TEST_X25519_DID + + def test_x25519_from_fingerprint(self): + did_key = DIDKey.from_fingerprint(TEST_X25519_FINGERPRINT) + + assert did_key.did == TEST_X25519_DID + assert did_key.public_key_b58 == TEST_X25519_BASE58_KEY + + def test_x25519_from_did(self): + did_key = DIDKey.from_did(TEST_X25519_DID) + + assert did_key.public_key_b58 == TEST_X25519_BASE58_KEY + + def test_x25519_properties(self): + did_key = DIDKey.from_did(TEST_X25519_DID) + + assert did_key.fingerprint == TEST_X25519_FINGERPRINT + assert did_key.did == TEST_X25519_DID + assert did_key.public_key_b58 == TEST_X25519_BASE58_KEY + assert did_key.public_key == b58_to_bytes(TEST_X25519_BASE58_KEY) + assert did_key.key_type == KeyType.X25519 + assert did_key.key_id == TEST_X25519_KEY_ID + + def test_x25519_diddoc(self): + did_key = DIDKey.from_did(TEST_X25519_DID) + + resolver = DID_KEY_RESOLVERS[KeyType.X25519] + + assert resolver(did_key) == did_key.did_doc + + def test_x25519_resolver(self): + did_key = DIDKey.from_did(TEST_X25519_DID) + resolver = DID_KEY_RESOLVERS[KeyType.X25519] + did_doc = resolver(did_key) + + # resolved using uniresolver, updated to did v1 + assert did_doc == DID_X25519_z6LShLeXRTzevtwcfehaGEzCMyL3bNsAeKCwcqwJxyCo63yE diff --git a/aries_cloudagent/did/tests/test_dids.py b/aries_cloudagent/did/tests/test_dids.py new file mode 100644 index 0000000000..9a1e246564 --- /dev/null +++ b/aries_cloudagent/did/tests/test_dids.py @@ -0,0 +1,140 @@ +DID_ED25519_z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th = { + "@context": "https://www.w3.org/ns/did/v1", + "id": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", + "verificationMethod": [ + { + "id": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", + "type": "Ed25519VerificationKey2018", + "controller": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", + "publicKeyBase58": "8HH5gYEeNc3z7PYXmd54d4x6qAfCNrqQqEB3nS7Zfu7K", + } + ], + "authentication": [ + "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" + ], + "assertionMethod": [ + "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" + ], + "capabilityDelegation": [ + "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" + ], + "capabilityInvocation": [ + "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th" + ], + "keyAgreement": [ + { + "id": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th#z6LShpNhGwSupbB7zjuivH156vhLJBDDzmQtA4BY9S94pe1K", + "type": "X25519KeyAgreementKey2019", + "controller": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", + "publicKeyBase58": "79CXkde3j8TNuMXxPdV7nLUrT2g7JAEjH5TreyVY7GEZ", + } + ], +} + +DID_X25519_z6LShLeXRTzevtwcfehaGEzCMyL3bNsAeKCwcqwJxyCo63yE = { + "@context": "https://www.w3.org/ns/did/v1", + "id": "did:key:z6LShLeXRTzevtwcfehaGEzCMyL3bNsAeKCwcqwJxyCo63yE", + "verificationMethod": [ + { + "id": "did:key:z6LShLeXRTzevtwcfehaGEzCMyL3bNsAeKCwcqwJxyCo63yE#z6LShLeXRTzevtwcfehaGEzCMyL3bNsAeKCwcqwJxyCo63yE", + "type": "X25519KeyAgreementKey2019", + "controller": "did:key:z6LShLeXRTzevtwcfehaGEzCMyL3bNsAeKCwcqwJxyCo63yE", + "publicKeyBase58": "6fUMuABnqSDsaGKojbUF3P7ZkEL3wi2njsDdUWZGNgCU", + } + ], + "authentication": [], + "assertionMethod": [], + "capabilityDelegation": [], + "capabilityInvocation": [], + "keyAgreement": [ + "did:key:z6LShLeXRTzevtwcfehaGEzCMyL3bNsAeKCwcqwJxyCo63yE#z6LShLeXRTzevtwcfehaGEzCMyL3bNsAeKCwcqwJxyCo63yE" + ], +} + +DID_BLS12381G2_zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT = { + "@context": "https://www.w3.org/ns/did/v1", + "id": "did:key:zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT", + "verificationMethod": [ + { + "id": "did:key:zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT#zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT", + "type": "Bls12381G2Key2020", + "controller": "did:key:zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT", + "publicKeyBase58": "mxE4sHTpbPcmxNviRVR9r7D2taXcNyVJmf9TBUFS1gRt3j3Ej9Seo59GQeCzYwbQgDrfWCwEJvmBwjLvheAky5N2NqFVzk4kuq3S8g4Fmekai4P622vHqWjFrsioYYDqhf9", + } + ], + "authentication": [ + "did:key:zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT#zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT" + ], + "assertionMethod": [ + "did:key:zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT#zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT" + ], + "capabilityDelegation": [ + "did:key:zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT#zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT" + ], + "capabilityInvocation": [ + "did:key:zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT#zUC71nmwvy83x1UzNKbZbS7N9QZx8rqpQx3Ee3jGfKiEkZngTKzsRoqobX6wZdZF5F93pSGYYco3gpK9tc53ruWUo2tkBB9bxPCFBUjq2th8FbtT4xih6y6Q1K9EL4Th86NiCGT" + ], + "keyAgreement": [], +} + +DID_BLS12381G1_z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA = { + "@context": "https://www.w3.org/ns/did/v1", + "id": "did:key:z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA", + "verificationMethod": [ + { + "id": "did:key:z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA#z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA", + "type": "Bls12381G1Key2020", + "controller": "did:key:z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA", + "publicKeyBase58": "6FywSzB5BPd7xehCo1G4nYHAoZPMMP3gd4PLnvgA6SsTsogtz8K7RDznqLpFPLZXAE", + } + ], + "authentication": [ + "did:key:z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA#z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA" + ], + "assertionMethod": [ + "did:key:z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA#z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA" + ], + "capabilityDelegation": [ + "did:key:z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA#z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA" + ], + "capabilityInvocation": [ + "did:key:z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA#z3tEFALUKUzzCAvytMHX8X4SnsNsq6T5tC5Zb18oQEt1FqNcJXqJ3AA9umgzA9yoqPBeWA" + ], + "keyAgreement": [], +} + +DID_BLS12381G1G2_z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s = { + "@context": "https://www.w3.org/ns/did/v1", + "id": "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s", + "verificationMethod": [ + { + "id": "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s#z3tEG5qmJZX29jJSX5kyhDR5YJNnefJFdwTxRqk6zbEPv4Pf2xF12BpmXv9NExxSRFGfxd", + "type": "Bls12381G1Key2020", + "controller": "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s", + "publicKeyBase58": "7BVES4h78wzabPAfMhchXyH5d8EX78S5TtzePH2YkftWcE6by9yj3NTAv9nsyCeYch", + }, + { + "id": "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s#zUC7LTa4hWtaE9YKyDsMVGiRNqPMN3s4rjBdB3MFi6PcVWReNfR72y3oGW2NhNcaKNVhMobh7aHp8oZB3qdJCs7RebM2xsodrSm8MmePbN25NTGcpjkJMwKbcWfYDX7eHCJjPGM", + "type": "Bls12381G2Key2020", + "controller": "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s", + "publicKeyBase58": "26d2BdqELsXg7ZHCWKL2D5Y2S7mYrpkdhJemSEEvokd4qy4TULJeeU44hYPGKo4x4DbBp5ARzkv1D6xuB3bmhpdpKAXuXtode67wzh9PCtW8kTqQhH19VSiFZkLNkhe9rtf3", + }, + ], + "authentication": [ + "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s#z3tEG5qmJZX29jJSX5kyhDR5YJNnefJFdwTxRqk6zbEPv4Pf2xF12BpmXv9NExxSRFGfxd", + "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s#zUC7LTa4hWtaE9YKyDsMVGiRNqPMN3s4rjBdB3MFi6PcVWReNfR72y3oGW2NhNcaKNVhMobh7aHp8oZB3qdJCs7RebM2xsodrSm8MmePbN25NTGcpjkJMwKbcWfYDX7eHCJjPGM", + ], + "assertionMethod": [ + "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s#z3tEG5qmJZX29jJSX5kyhDR5YJNnefJFdwTxRqk6zbEPv4Pf2xF12BpmXv9NExxSRFGfxd", + "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s#zUC7LTa4hWtaE9YKyDsMVGiRNqPMN3s4rjBdB3MFi6PcVWReNfR72y3oGW2NhNcaKNVhMobh7aHp8oZB3qdJCs7RebM2xsodrSm8MmePbN25NTGcpjkJMwKbcWfYDX7eHCJjPGM", + ], + "capabilityDelegation": [ + "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s#z3tEG5qmJZX29jJSX5kyhDR5YJNnefJFdwTxRqk6zbEPv4Pf2xF12BpmXv9NExxSRFGfxd", + "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s#zUC7LTa4hWtaE9YKyDsMVGiRNqPMN3s4rjBdB3MFi6PcVWReNfR72y3oGW2NhNcaKNVhMobh7aHp8oZB3qdJCs7RebM2xsodrSm8MmePbN25NTGcpjkJMwKbcWfYDX7eHCJjPGM", + ], + "capabilityInvocation": [ + "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s#z3tEG5qmJZX29jJSX5kyhDR5YJNnefJFdwTxRqk6zbEPv4Pf2xF12BpmXv9NExxSRFGfxd", + "did:key:z5TcESXuYUE9aZWYwSdrUEGK1HNQFHyTt4aVpaCTVZcDXQmUheFwfNZmRksaAbBneNm5KyE52SdJeRCN1g6PJmF31GsHWwFiqUDujvasK3wTiDr3vvkYwEJHt7H5RGEKYEp1ErtQtcEBgsgY2DA9JZkHj1J9HZ8MRDTguAhoFtR4aTBQhgnkP4SwVbxDYMEZoF2TMYn3s#zUC7LTa4hWtaE9YKyDsMVGiRNqPMN3s4rjBdB3MFi6PcVWReNfR72y3oGW2NhNcaKNVhMobh7aHp8oZB3qdJCs7RebM2xsodrSm8MmePbN25NTGcpjkJMwKbcWfYDX7eHCJjPGM", + ], + "keyAgreement": [], +} diff --git a/aries_cloudagent/wallet/crypto.py b/aries_cloudagent/wallet/crypto.py index b44394bbd7..57ca20003c 100644 --- a/aries_cloudagent/wallet/crypto.py +++ b/aries_cloudagent/wallet/crypto.py @@ -30,6 +30,10 @@ class KeyTypeException(BaseException): class KeyType(Enum): ED25519 = KeySpec("ed25519", "ed25519-pub") + X25519 = KeySpec("x25519", "x25519-pub") + BLS12381G1 = KeySpec("bls12381g1", "bls12_381-g1-pub") + BLS12381G2 = KeySpec("bls12381g2", "bls12_381-g2-pub") + BLS12381G1G2 = KeySpec("bls12381g1g2", "bls12_381-g1g2-pub") @property def key_type(self) -> str: diff --git a/requirements.txt b/requirements.txt index bbc48fb970..4d55726df9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ prompt_toolkit~=2.0.9 pynacl~=1.3.0 requests~=2.23.0 pyld==2.0.1 -py_multicodec==0.2.1 +git+git://github.com/TimoGlastra/py-multicodec@update-multicodec-table#egg=py_multicodec pyyaml~=5.3.1 ConfigArgParse~=1.2.3 pyjwt~=1.7.1 From 5ef30c2b88bc607e9a8f627160aefe054f9cffbc Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Thu, 25 Mar 2021 11:00:47 +0100 Subject: [PATCH 045/138] use security context constant without version Signed-off-by: Timo Glastra --- aries_cloudagent/vc/ld_proofs/ProofSet.py | 4 ++-- aries_cloudagent/vc/ld_proofs/constants.py | 9 +++++---- .../ld_proofs/purposes/ControllerProofPurpose.py | 6 +++--- .../vc/ld_proofs/suites/LinkedDataSignature.py | 10 +++++----- aries_cloudagent/vc/tests/document_loader.py | 16 ++++++++-------- aries_cloudagent/vc/vc_ld/models/credential.py | 9 ++++++--- aries_cloudagent/vc/vc_ld/prove.py | 4 ++-- 7 files changed, 31 insertions(+), 27 deletions(-) diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py index b2f8fc3350..e54111626e 100644 --- a/aries_cloudagent/vc/ld_proofs/ProofSet.py +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -5,7 +5,7 @@ from .error import LinkedDataProofException from .validation_result import DocumentVerificationResult, ProofResult -from .constants import SECURITY_V2_URL +from .constants import SECURITY_CONTEXT_URL from .document_loader import DocumentLoader from .purposes.ProofPurpose import ProofPurpose from .suites import LinkedDataProof @@ -100,7 +100,7 @@ async def _get_proofs(document: dict) -> list: # TODO: digitalbazaar changed this to use the document context # in jsonld-signatures. Does that mean we need to provide this ourselves? # https://github.com/digitalbazaar/jsonld-signatures/commit/5046805653ea7db47540e5c9c77578d134a559e1 - proof_set = [{"@context": SECURITY_V2_URL, **proof} for proof in proof_set] + proof_set = [{"@context": SECURITY_CONTEXT_URL, **proof} for proof in proof_set] return proof_set diff --git a/aries_cloudagent/vc/ld_proofs/constants.py b/aries_cloudagent/vc/ld_proofs/constants.py index 94261ef599..6f6f76ce56 100644 --- a/aries_cloudagent/vc/ld_proofs/constants.py +++ b/aries_cloudagent/vc/ld_proofs/constants.py @@ -1,11 +1,12 @@ """JSON-LD, Linked Data Proof and Verifiable Credential constants""" -SECURITY_V1_URL = "https://w3id.org/security/v1" -SECURITY_V2_URL = "https://w3id.org/security/v2" +SECURITY_CONTEXT_V1_URL = "https://w3id.org/security/v1" +SECURITY_CONTEXT_V2_URL = "https://w3id.org/security/v2" +SECURITY_CONTEXT_URL = SECURITY_CONTEXT_V2_URL DID_V1_URL = "https://www.w3.org/ns/did/v1" +CREDENTIALS_CONTEXT_V1_URL = "https://www.w3.org/2018/credentials/v1" +SECURITY_CONTEXT_BBS_URL = "https://w3id.org/security/bbs/v1" CREDENTIALS_ISSUER_URL = "https://www.w3.org/2018/credentials#issuer" -CREDENTIALS_V1_URL = "https://www.w3.org/2018/credentials/v1" VERIFIABLE_CREDENTIAL_TYPE = "VerifiableCredential" SECURITY_PROOF_URL = "https://w3id.org/security#proof" SECURITY_SIGNATURE_URL = "https://w3id.org/security#signature" -SECURITY_BBS_URL = "https://w3id.org/security/bbs/v1" diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py index 85453285b4..23d553aed6 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py @@ -5,7 +5,7 @@ from ..error import LinkedDataProofException from ..validation_result import PurposeResult -from ..constants import SECURITY_V2_URL +from ..constants import SECURITY_CONTEXT_URL from ..suites import LinkedDataProof from ..document_loader import DocumentLoader from .ProofPurpose import ProofPurpose @@ -51,13 +51,13 @@ def validate( result.controller = jsonld.frame( controller_id, frame={ - "@context": SECURITY_V2_URL, + "@context": SECURITY_CONTEXT_URL, "id": controller_id, self.term: {"@embed": "@never", "id": verification_id}, }, options={ "documentLoader": document_loader, - "expandContext": SECURITY_V2_URL, + "expandContext": SECURITY_CONTEXT_URL, # if we don't set base explicitly it will remove the base in returned # document (e.g. use key:z... instead of did:key:z...) # same as compactToRelative in jsonld.js diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py index dc656bef31..9159f0b0d1 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -10,7 +10,7 @@ from ..validation_result import ProofResult from ..document_loader import DocumentLoader from ..purposes import ProofPurpose -from ..constants import SECURITY_V2_URL +from ..constants import SECURITY_CONTEXT_URL from .LinkedDataProof import LinkedDataProof @@ -79,10 +79,10 @@ async def create_proof( # double check to make sure we're doing it correctly # https://github.com/digitalbazaar/jsonld-signatures/commit/2c98a2fb626b85e31d16b16e7ea6a90fd83534c5 proof = jsonld.compact( - self.proof, SECURITY_V2_URL, {"documentLoader": document_loader} + self.proof, SECURITY_CONTEXT_URL, {"documentLoader": document_loader} ) else: - proof = {"@context": SECURITY_V2_URL} + proof = {"@context": SECURITY_CONTEXT_URL} # TODO: validate if verification_method is set? proof["type"] = self.signature_type @@ -162,13 +162,13 @@ def _get_verification_method( framed = jsonld.frame( verification_method, frame={ - "@context": SECURITY_V2_URL, + "@context": SECURITY_CONTEXT_URL, "@embed": "@always", "id": verification_method, }, options={ "documentLoader": document_loader, - "expandContext": SECURITY_V2_URL, + "expandContext": SECURITY_CONTEXT_URL, # if we don't set base explicitly it will remove the base in returned # document (e.g. use key:z... instead of did:key:z...) # same as compactToRelative in jsonld.js diff --git a/aries_cloudagent/vc/tests/document_loader.py b/aries_cloudagent/vc/tests/document_loader.py index 38cbfadaad..c1a3b47165 100644 --- a/aries_cloudagent/vc/tests/document_loader.py +++ b/aries_cloudagent/vc/tests/document_loader.py @@ -9,11 +9,11 @@ ODRL, ) from ..ld_proofs.constants import ( - SECURITY_V2_URL, - SECURITY_V1_URL, + SECURITY_CONTEXT_V2_URL, + SECURITY_CONTEXT_V1_URL, DID_V1_URL, - SECURITY_BBS_URL, - CREDENTIALS_V1_URL, + SECURITY_CONTEXT_BBS_URL, + CREDENTIALS_CONTEXT_V1_URL, ) from .dids import DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL @@ -21,11 +21,11 @@ DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL.get( "id" ): DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL, - SECURITY_V1_URL: SECURITY_V1, - SECURITY_V2_URL: SECURITY_V2, + SECURITY_CONTEXT_V1_URL: SECURITY_V1, + SECURITY_CONTEXT_V2_URL: SECURITY_V2, DID_V1_URL: DID_V1, - CREDENTIALS_V1_URL: CREDENTIALS_V1, - SECURITY_BBS_URL: BBS_V1, + CREDENTIALS_CONTEXT_V1_URL: CREDENTIALS_V1, + SECURITY_CONTEXT_BBS_URL: BBS_V1, "https://www.w3.org/2018/credentials/examples/v1": EXAMPLES_V1, "https://w3id.org/citizenship/v1": CITIZENSHIP_V1, "https://www.w3.org/ns/odrl.jsonld": ODRL, diff --git a/aries_cloudagent/vc/vc_ld/models/credential.py b/aries_cloudagent/vc/vc_ld/models/credential.py index 6e62194bd8..38085775c1 100644 --- a/aries_cloudagent/vc/vc_ld/models/credential.py +++ b/aries_cloudagent/vc/vc_ld/models/credential.py @@ -5,7 +5,10 @@ from datetime import datetime from ....messaging.valid import Uri -from ...ld_proofs.constants import CREDENTIALS_V1_URL, VERIFIABLE_CREDENTIAL_TYPE +from ...ld_proofs.constants import ( + CREDENTIALS_CONTEXT_V1_URL, + VERIFIABLE_CREDENTIAL_TYPE, +) from .credential_schema import ( CredentialSchema, VerifiableCredentialSchema, @@ -84,7 +87,7 @@ def __init__( **kwargs, ) -> None: """Initialize the VerifiableCredential instance.""" - self._context = context or [CREDENTIALS_V1_URL] + self._context = context or [CREDENTIALS_CONTEXT_V1_URL] self._id = id self._type = type or [VERIFIABLE_CREDENTIAL_TYPE] self._issuer = issuer @@ -141,7 +144,7 @@ def context(self, context: List[Union[str, dict]]): First item must be credentials v1 url """ - assert context[0] == CREDENTIALS_V1_URL + assert context[0] == CREDENTIALS_CONTEXT_V1_URL self._context = context diff --git a/aries_cloudagent/vc/vc_ld/prove.py b/aries_cloudagent/vc/vc_ld/prove.py index 74ce7158af..f1f7388bad 100644 --- a/aries_cloudagent/vc/vc_ld/prove.py +++ b/aries_cloudagent/vc/vc_ld/prove.py @@ -9,7 +9,7 @@ LinkedDataProof, LinkedDataProofException, ) -from ..ld_proofs.constants import CREDENTIALS_V1_URL +from ..ld_proofs.constants import CREDENTIALS_CONTEXT_V1_URL from .models.credential_schema import VerifiableCredentialSchema @@ -17,7 +17,7 @@ async def create_presentation( *, credentials: List[dict], presentation_id: str = None ) -> dict: presentation = { - "@context": [CREDENTIALS_V1_URL], + "@context": [CREDENTIALS_CONTEXT_V1_URL], "type": ["VerifiablePresentation"], } From 8177939f1cdff0a3905673051f8883df70b73c57 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Thu, 25 Mar 2021 22:11:50 +0100 Subject: [PATCH 046/138] add initial bbs+ signature suites Signed-off-by: Timo Glastra --- aries_cloudagent/vc/ld_proofs/ProofSet.py | 79 +++- aries_cloudagent/vc/ld_proofs/__init__.py | 6 +- aries_cloudagent/vc/ld_proofs/constants.py | 1 + .../ld_proofs/crypto/Ed25519WalletKeyPair.py | 7 + .../vc/ld_proofs/crypto/KeyPair.py | 15 +- .../vc/ld_proofs/crypto/WalletKeyPair.py | 4 +- aries_cloudagent/vc/ld_proofs/ld_proofs.py | 36 ++ .../ld_proofs/suites/BbsBlsSignature2020.py | 195 ++++++++++ .../suites/BbsBlsSignature2020Base.py | 49 +++ .../suites/BbsBlsSignatureProof2020.py | 339 ++++++++++++++++++ .../suites/JwsLinkedDataSignature.py | 4 +- .../vc/ld_proofs/suites/LinkedDataProof.py | 101 +++++- .../ld_proofs/suites/LinkedDataSignature.py | 46 +-- aries_cloudagent/vc/vc_ld/prove.py | 69 +++- 14 files changed, 895 insertions(+), 56 deletions(-) create mode 100644 aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py create mode 100644 aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py create mode 100644 aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py index e54111626e..20c571038d 100644 --- a/aries_cloudagent/vc/ld_proofs/ProofSet.py +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -1,6 +1,6 @@ """Class to represent a linked data proof set.""" -from typing import List +from typing import List, Union from pyld.jsonld import JsonLdProcessor from .error import LinkedDataProofException @@ -88,10 +88,83 @@ async def verify( ) @staticmethod - async def _get_proofs(document: dict) -> list: - "Get proof set from document" "" + async def derive( + *, + document: dict, + reveal_document: dict, + # TODO: I think this could support multiple suites? + # But then, why do multiple proofs? + suite: LinkedDataProof, + document_loader: DocumentLoader, + nonce: bytes = None, + ) -> dict: + """Derive proof(s), return derived document + + Args: + document (dict): The document to derive the proof for + reveal_document (dict): The JSON-LD frame specifying the revealed attributes + suite (LinkedDataProof): The suite to derive the proof with + document_loader (DocumentLoader): Document loader used for resolving + nonce (bytes, optional): Nonce to use for the proof. Defaults to None. + + Returns: + dict: The document with derived proofs + """ + input = document.copy() + + if not suite.supported_derive_proof_types: + raise LinkedDataProofException( + f"{suite.signature_type} does not support derivation" + ) + + # Get proof set, remove proof from document + proof_set = await ProofSet._get_proofs( + document=input, proof_types=suite.supported_derive_proof_types + ) + input.pop("proof", None) + + # Derive proof, remove context + derived_proof = await suite.derive_proof( + proof=proof_set[0], + document=input, + reveal_document=reveal_document, + document_loader=document_loader, + nonce=nonce, + ) + derived_proof["proof"].pop("@context", None) + + if len(proof_set) > 1: + derived_proof["proof"] = [derived_proof["proof"]] + + proof_set.pop(0) + + for proof in proof_set: + additional_derived_proof = await suite.derive_proof( + proof=proof, + document=input, + reveal_document=reveal_document, + document_loader=document_loader, + ) + additional_derived_proof["proof"].pop("@context", None) + derived_proof["proof"].append(additional_derived_proof["proof"]) + + JsonLdProcessor.add_value( + derived_proof["document"], "proof", derived_proof["proof"] + ) + + return derived_proof["document"] + + @staticmethod + async def _get_proofs( + document: dict, proof_types: Union[List[str], None] = None + ) -> list: + "Get proof set from document, optionally filtered by proof_types" "" proof_set = JsonLdProcessor.get_values(document, "proof") + # If proof_types is present, only take proofs that match + if proof_types: + proof_set = list(filter(lambda _: _ in proof_types, proof_set)) + if len(proof_set) == 0: raise LinkedDataProofException( "No matching proofs found in the given document" diff --git a/aries_cloudagent/vc/ld_proofs/__init__.py b/aries_cloudagent/vc/ld_proofs/__init__.py index abfeca26e2..0519ab5501 100644 --- a/aries_cloudagent/vc/ld_proofs/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/__init__.py @@ -1,4 +1,4 @@ -from .ld_proofs import sign, verify +from .ld_proofs import sign, verify, derive from .ProofSet import ProofSet from .purposes import ( ProofPurpose, @@ -12,6 +12,8 @@ LinkedDataSignature, JwsLinkedDataSignature, Ed25519Signature2018, + BbsBlsSignature2020, + BbsBlsSignatureProof2020, ) from .crypto import KeyPair, Ed25519WalletKeyPair from .document_loader import DocumentLoader, get_default_document_loader @@ -33,6 +35,8 @@ LinkedDataSignature, JwsLinkedDataSignature, Ed25519Signature2018, + BbsBlsSignature2020, + BbsBlsSignatureProof2020, # Key pairs KeyPair, Ed25519WalletKeyPair, diff --git a/aries_cloudagent/vc/ld_proofs/constants.py b/aries_cloudagent/vc/ld_proofs/constants.py index 6f6f76ce56..bd855242da 100644 --- a/aries_cloudagent/vc/ld_proofs/constants.py +++ b/aries_cloudagent/vc/ld_proofs/constants.py @@ -2,6 +2,7 @@ SECURITY_CONTEXT_V1_URL = "https://w3id.org/security/v1" SECURITY_CONTEXT_V2_URL = "https://w3id.org/security/v2" +SECURITY_CONTEXT_V3_URL = "https://w3id.org/security/v3-unstable" SECURITY_CONTEXT_URL = SECURITY_CONTEXT_V2_URL DID_V1_URL = "https://www.w3.org/ns/did/v1" CREDENTIALS_CONTEXT_V1_URL = "https://www.w3.org/2018/credentials/v1" diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py index ca08033398..0eaf519040 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py @@ -2,6 +2,7 @@ from typing import Optional +from ....wallet.util import b58_to_bytes from ....wallet.base import BaseWallet from ..error import LinkedDataProofException from .WalletKeyPair import WalletKeyPair @@ -10,6 +11,8 @@ class Ed25519WalletKeyPair(WalletKeyPair): """Ed25519 wallet key pair""" + # TODO: maybe make public key buffer default input? + # This way we can make it an input on the lower level key pair class def __init__(self, *, wallet: BaseWallet, public_key_base58: Optional[str] = None): """Initialize new Ed25519WalletKeyPair instance.""" super().__init__(wallet=wallet) @@ -51,6 +54,10 @@ def from_verification_method( wallet=self.wallet, public_key_base58=verification_method["publicKeyBase58"] ) + @property + def public_key(self) -> Optional[bytes]: + return b58_to_bytes(self.public_key_base58) + @property def has_public_key(self) -> bool: """Whether key pair has public key""" diff --git a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py index 6d702542b6..55d18c6255 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py @@ -1,17 +1,20 @@ """Base key pair class""" from abc import ABC, abstractmethod, abstractproperty +from typing import List, Optional, Union class KeyPair(ABC): """Base key pair class.""" @abstractmethod - async def sign(self, message: bytes) -> bytes: + async def sign(self, message: Union[List[bytes], bytes]) -> bytes: """Sign message using key pair""" @abstractmethod - async def verify(self, message: bytes, signature: bytes) -> bool: + async def verify( + self, message: Union[List[bytes], bytes], signature: bytes + ) -> bool: """Verify message against signature using key pair""" @abstractproperty @@ -22,6 +25,14 @@ def has_public_key(self) -> bool: in the verification process. """ + @abstractproperty + def public_key(self) -> Optional[bytes]: + """Getter for the public key bytes + + Returns: + bytes: The public key + """ + @abstractmethod def from_verification_method(self, verification_method: dict) -> "KeyPair": """Create new key pair class based on the passed verification method.""" \ No newline at end of file diff --git a/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py index f1e7e1847f..6ae1702c16 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py @@ -1,10 +1,12 @@ """Key pair based on base wallet interface""" +from abc import ABCMeta + from ....wallet.base import BaseWallet from .KeyPair import KeyPair -class WalletKeyPair(KeyPair): +class WalletKeyPair(KeyPair, metaclass=ABCMeta): """Base wallet key pair""" def __init__(self, *, wallet: BaseWallet) -> None: diff --git a/aries_cloudagent/vc/ld_proofs/ld_proofs.py b/aries_cloudagent/vc/ld_proofs/ld_proofs.py index 764e79f9b2..3889108243 100644 --- a/aries_cloudagent/vc/ld_proofs/ld_proofs.py +++ b/aries_cloudagent/vc/ld_proofs/ld_proofs.py @@ -84,3 +84,39 @@ async def verify( ) return result + + +async def derive( + *, + document: dict, + reveal_document: dict, + suite: LinkedDataProof, + document_loader: DocumentLoader, + nonce: bytes = None, +) -> dict: + """Derive proof(s) for document with reveal document. + + All proofs matching the signature suite type will be replaced with a derived + proof. Other proofs will be discarded. + + Args: + document (dict): The document with one or more proofs to be derived + reveal_document (dict): The JSON-LD frame specifying the revealed attributes + suite (LinkedDataProof): The linked data signature cryptographic suite + with which to derive the proof + document_loader (DocumentLoader): The document loader to use. + nonce (bytes, optional): Nonce to use for the proof. Defaults to None. + + Returns: + dict: The document with derived proof(s). + """ + + result = await ProofSet.derive( + document=document, + reveal_document=reveal_document, + suite=suite, + document_loader=document_loader, + nonce=nonce, + ) + + return result \ No newline at end of file diff --git a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py new file mode 100644 index 0000000000..f050f4f4bb --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py @@ -0,0 +1,195 @@ +"""BbsBlsSignature2020 class""" + +from pyld import jsonld +from datetime import datetime +from typing import List, Union + +from .BbsBlsSignature2020Base import BbsBlsSignature2020Base +from ....wallet.util import b64_to_bytes, bytes_to_b64 +from ..crypto import KeyPair +from ..error import LinkedDataProofException +from ..validation_result import ProofResult +from ..document_loader import DocumentLoader +from ..purposes import ProofPurpose +from ..constants import SECURITY_CONTEXT_URL + + +class BbsBlsSignature2020(BbsBlsSignature2020Base): + """BbsBlsSignature2020 class""" + + def __init__( + self, + *, + key_pair: KeyPair, + verification_method: str = None, + proof: dict = None, + date: Union[datetime, None] = None, + ): + """Create new BbsBlsSignature2020 instance""" + super().__init__(signature_type="BbsBlsSignature2020", proof=proof) + self.key_pair = key_pair + self.verification_method = verification_method + self.date = date + + async def create_proof( + self, *, document: dict, purpose: ProofPurpose, document_loader: DocumentLoader + ) -> dict: + """Create proof for document, return proof""" + proof = None + if self.proof: + proof = jsonld.compact( + self.proof, SECURITY_CONTEXT_URL, {"documentLoader": document_loader} + ) + else: + proof = {"@context": SECURITY_CONTEXT_URL} + + proof["type"] = self.signature_type + proof["verificationMethod"] = self.verification_method + + if not self.date: + self.date = datetime.now() + + if not proof.get("created"): + proof["created"] = self.date.isoformat() + + proof = purpose.update(proof) + + verify_data = self._create_verify_data( + proof=proof, document=document, document_loader=document_loader + ) + + verify_data = list(map(lambda item: item.encode("utf-8"), verify_data)) + + proof = await self.sign(verify_data=verify_data, proof=proof) + return proof + + async def verify_proof( + self, + *, + proof: dict, + document: dict, + purpose: ProofPurpose, + document_loader: DocumentLoader, + ) -> ProofResult: + """Verify proof against document and proof purpose.""" + try: + verify_data = self._create_verify_data( + proof=proof, document=document, document_loader=document_loader + ) + verification_method = self._get_verification_method( + proof=proof, document_loader=document_loader + ) + + verified = await self.verify_signature( + verify_data=verify_data, + verification_method=verification_method, + document=document, + proof=proof, + document_loader=document_loader, + ) + + if not verified: + raise LinkedDataProofException( + f"Invalid signature on document {document}" + ) + + purpose_result = purpose.validate( + proof=proof, + document=document, + suite=self, + verification_method=verification_method, + document_loader=document_loader, + ) + + if not purpose_result.valid: + return ProofResult( + verified=False, + purpose_result=purpose_result, + error=purpose_result.error, + ) + + return ProofResult(verified=True, purpose_result=purpose_result) + except Exception as err: + return ProofResult(verified=False, error=err) + + def _create_verify_data( + self, *, proof: dict, document: dict, document_loader: DocumentLoader + ) -> List[str]: + """Create verification data. + + Returns a list of canonized statements + """ + proof_statements = self._create_verify_proof_data( + proof=proof, document_loader=document_loader + ) + document_statements = self._create_verify_document_data( + document=document, document_loader=document_loader + ) + + # TODO: detect any dropped properties using expand/contract step? + + return [*proof_statements, *document_statements] + + def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None): + """Canonize proof dictionary. Removes jws, signature, etc...""" + proof = proof.copy() + + proof.pop("proofValue", None) + + return self._canonize(input=proof, document_loader=document_loader) + + async def sign(self, *, verify_data: List[bytes], proof: dict) -> dict: + """Sign the data and add it to the proof + + Args: + verify_data (List[bytes]): The data to sign. + proof (dict): The proof to add the signature to + + Returns: + dict: The proof object with the added signature + """ + signature = await self.key_pair.sign(verify_data) + + proof["proofValue"] = bytes_to_b64( + signature, urlsafe=False, pad=True, encoding="utf-8" + ) + + return proof + + async def verify_signature( + self, + *, + verify_data: List[bytes], + verification_method: dict, + document: dict, + proof: dict, + document_loader: DocumentLoader, + ) -> bool: + """Verify the data against the proof. + + Args: + verify_data (bytes): The data to check + verification_method (dict): The verification method to use. + document (dict): The document the verify data is derived for as extra context + proof (dict): The proof to check + document_loader (DocumentLoader): Document loader used for resolving + + Returns: + bool: Whether the signature is valid for the data + """ + + if not (isinstance(proof.get("proofValue"), str)): + raise LinkedDataProofException( + 'The proof does not contain a valid "proofValue" property.' + ) + + signature = b64_to_bytes(proof["proofValue"]) + + # If the key pair has no public key yet, create a new key pair + # from the verification method. We don't want to overwrite data + # on the original key pair + key_pair = self.key_pair + if not key_pair.has_public_key: + key_pair = key_pair.from_verification_method(verification_method) + + return await self.key_pair.verify(verify_data, signature) \ No newline at end of file diff --git a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py new file mode 100644 index 0000000000..814bd1ffca --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py @@ -0,0 +1,49 @@ +"""BbsBlsSignature2020Base class""" + +from abc import ABCMeta, abstractmethod +from pyld import jsonld +from typing import List + + +from ..document_loader import DocumentLoader +from .LinkedDataProof import LinkedDataProof + + +class BbsBlsSignature2020Base(LinkedDataProof, metaclass=ABCMeta): + def _create_verify_proof_data( + self, proof: dict, document_loader: DocumentLoader + ) -> List[str]: + """Create proof verification data""" + c14_proof_options = self._canonize_proof( + proof=proof, document_loader=document_loader + ) + + # Return only the lines that have any content in them + # e.g. "aa\nbb\n\n\ncccdkea\n" -> ['aa', 'bb', 'cccdkea'] + list(filter(lambda _: len(_) > 0, c14_proof_options.split("\n"))) + + def _create_verify_document_data( + self, document: dict, document_loader: DocumentLoader + ) -> List[str]: + """Create document verification data""" + c14n_doc = self._canonize(input=document, document_loader=document_loader) + + # Return only the lines that have any content in them + # e.g. "aa\nbb\n\n\ncccdkea\n" -> ['aa', 'bb', 'cccdkea'] + list(filter(lambda _: len(_) > 0, c14n_doc.split("\n"))) + + def _canonize(self, *, input, document_loader: DocumentLoader = None) -> str: + """Canonize input document using URDNA2015 algorithm""" + # application/n-quads format always returns str + return jsonld.normalize( + input, + { + "algorithm": "URDNA2015", + "format": "application/n-quads", + "documentLoader": document_loader, + }, + ) + + @abstractmethod + def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None): + """Canonize proof dictionary. Removes values that are not part of proof""" \ No newline at end of file diff --git a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py new file mode 100644 index 0000000000..c4a57f0489 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py @@ -0,0 +1,339 @@ +"""BbsBlsSignatureProof2020 class""" + +from os import urandom +from pyld import jsonld +from typing import List + + +from .LinkedDataProof import DeriveProofResult +from .BbsBlsSignature2020Base import BbsBlsSignature2020Base +from .BbsBlsSignature2020 import BbsBlsSignature2020 +from ....wallet.util import b64_to_bytes, bytes_to_b64 +from ..crypto import KeyPair +from ..error import LinkedDataProofException +from ..validation_result import ProofResult +from ..document_loader import DocumentLoader +from ..purposes import ProofPurpose +from ..constants import SECURITY_CONTEXT_URL, SECURITY_CONTEXT_V3_URL + + +class BbsBlsSignatureProof2020(BbsBlsSignature2020Base): + """BbsBlsSignatureProof2020 class""" + + def __init__( + self, + *, + key_pair: KeyPair, + ): + """Create new BbsBlsSignatureProof2020 instance""" + super().__init__( + signature_type="BbsBlsSignatureProof2020", + proof={ + "@context": SECURITY_CONTEXT_V3_URL, + "type": "BbsBlsSignatureProof2020", + }, + supported_derive_proof_types=BbsBlsSignatureProof2020.supported_derive_proof_types, + ) + self.key_pair = key_pair + self.mapped_derived_proof_type = "https://w3id.org/security#BbsBlsSignature2020" + + async def derive_proof( + self, + *, + proof: dict, + document: dict, + reveal_document: dict, + document_loader: DocumentLoader, + nonce: bytes = None, + ): + """Derive proof for document, return dict with derived document and proof""" + + # Validate that the input proof document has a proof compatible with this suite + if proof.get("type") not in self.supported_derive_proof_types: + raise LinkedDataProofException( + f"Proof document proof incompatible, expected proof types of {self.supported_derive_proof_types}, received " + + proof["type"] + ) + + # Extract the BBS signature from the input proof + signature = b64_to_bytes(proof["proofValue"]) + + # Initialize the BBS signature suite + # This is used for creating the input document verification data + # NOTE: both suite._create_verify_xxx_data and self._create_verify_xxx_data + # are used in this file. They have small changes in behavior + suite = BbsBlsSignature2020(key_pair=self.key_pair) + + # Initialize the derived proof + derived_proof = None + if self.proof: + # Use proof JSON-LD document passed to API + derived_proof = jsonld.compact( + self.proof, SECURITY_CONTEXT_URL, {"documentLoader": document_loader} + ) + else: + # create proof JSON-LD document + derived_proof = {"@context": SECURITY_CONTEXT_URL} + + derived_proof["type"] = self.signature_type + + # Get the input document and proof statements + document_statements = suite._create_verify_document_data( + document=document, document_loader=document_loader + ) + proof_statements = suite._create_verify_proof_data( + proof=proof, document_loader=document_loader + ) + + # Transform any blank node identifiers for the input + # document statements into actual node identifiers + # e.g _:c14n0 => urn:bnid:_:c14n0 + transformed_input_document_statements = ( + self._transform_blank_node_ids_into_placeholder_node_ids( + document_statements + ) + ) + + # Transform the resulting RDF statements back into JSON-LD + compact_input_proof_document = jsonld.from_rdf( + "\n".join(transformed_input_document_statements) + ) + + # Frame the result to create the reveal document result + reveal_document_result = jsonld.frame( + compact_input_proof_document, + reveal_document, + {"document_loader": document_loader}, + ) + + # Canonicalize the resulting reveal document + reveal_document_statements = suite._create_verify_document_data( + reveal_document_result, {"document_loader": document_loader} + ) + + # Get the indices of the revealed statements from the transformed input document offset + # by the number of proof statements + number_of_proof_statements = len(proof_statements) + + # Always reveal all the statements associated to the original proof + # these are always the first statements in the normalized form + proof_reveal_indices = [indice for indice in range(number_of_proof_statements)] + + # Reveal the statements indicated from the reveal document + document_reveal_indices = list( + map( + lambda reveal_statement: transformed_input_document_statements.index( + reveal_statement + ) + + number_of_proof_statements, + reveal_document_statements, + ) + ) + + # Check there is not a mismatch + if len(document_reveal_indices) != len(reveal_document_statements): + raise LinkedDataProofException( + "Some statements in the reveal document not found in original proof" + ) + + # Combine all indices to get the resulting list of revealed indices + reveal_indices = [*proof_reveal_indices, *document_reveal_indices] + + # Create a nonce if one is not supplied + nonce = nonce or urandom(50) + + derived_proof["nonce"] = bytes_to_b64( + nonce, urlsafe=False, pad=True, encoding="utf-8" + ) + + # Combine all the input statements that + # were originally signed to generate the proof + all_input_statements = list( + map( + lambda item: item.encode("utf-8"), + [*proof_statements, *document_statements], + ) + ) + + verification_method = self._get_verification_method( + proof=proof, document_loader=document_loader + ) + + key_pair = self.key_pair.from_verification_method(verification_method) + + # Compute the proof + # TODO: should this be key_pair.create_proof? + # TODO: add bls_create_proof method + output_proof = await bls_create_proof( + signature=signature, + public_key=key_pair.public_key, + messages=all_input_statements, + nonce=nonce, + revealed=reveal_indices, + ) + + # Set the proof value on the derived proof + derived_proof["proofValue"] = bytes_to_b64( + output_proof, urlsafe=False, pad=True, encoding="utf-8" + ) + + # Set the relevant proof elements on the derived proof from the input proof + derived_proof["verificationMethod"] = proof["verificationMethod"] + derived_proof["proofPurpose"] = proof["proofPurpose"] + derived_proof["created"] = proof["created"] + + return DeriveProofResult( + document={**reveal_document_result}, proof=derived_proof + ) + + async def verify_proof( + self, + *, + proof: dict, + document: dict, + purpose: ProofPurpose, + document_loader: DocumentLoader, + ) -> ProofResult: + """Verify proof against document and proof purpose.""" + try: + + proof["type"] = self.mapped_derived_proof_type + + # Get the proof and document statements + proof_statements = self._create_verify_proof_data( + proof=proof, document_loader=document_loader + ) + document_statements = self._create_verify_document_data( + document=document, document_loader=document_loader + ) + + # Transform the blank node identifier placeholders for the document statements + # back into actual blank node identifiers + transformed_document_statements = ( + self._transform_placeholder_node_ids_into_blank_node_ids( + document_statements + ) + ) + + # Combine all the statements to be verified + statements_to_verify = list( + map( + lambda item: item.encode("utf-8"), + [*proof_statements, *transformed_document_statements], + ) + ) + + # Fetch the verification method + verification_method = self._get_verification_method( + proof=proof, document_loader=document_loader + ) + + key_pair = self.key_pair.from_verification_method(verification_method) + + verified = await bls_verify_proof( + proof=b64_to_bytes(proof["proofValue"]), + public_key=key_pair.public_key, + messages=statements_to_verify, + nonce=b64_to_bytes(proof["nonce"]), + ) + + if not verified: + raise LinkedDataProofException( + f"Invalid signature on document {document}" + ) + + purpose_result = purpose.validate( + proof=proof, + document=document, + suite=self, + verification_method=verification_method, + document_loader=document_loader, + ) + + if not purpose_result.valid: + return ProofResult( + verified=False, + purpose_result=purpose_result, + error=purpose_result.error, + ) + + return ProofResult(verified=True, purpose_result=purpose_result) + except Exception as err: + return ProofResult(verified=False, error=err) + + def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None): + """Canonize proof dictionary. Removes proofValue""" + proof = proof.copy() + + proof.pop("proofValue", None) + proof.pop("nonce", None) + + return self._canonize(input=proof, document_loader=document_loader) + + def _transform_blank_node_ids_into_placeholder_node_ids( + statements: List[str], + ) -> List[str]: + """Transform any blank node identifiers for the input into actual node identifiers + + e.g _:c14n0 => urn:bnid:_:c14n0 + + Args: + statements (List[str]): List with possible blank node identifiers + + Returns: + List[str]: List of transformed output statements + """ + transformed_statements = [] + + for statement in statements: + if "_:c14n" in statement: + prefix_index = statement.index("_:c14n") + space_index = statement.index(" ", prefix_index) + + statement = statement.replace( + statement[prefix_index:space_index], + "".format( + ident=statement[prefix_index:space_index] + ), + ) + + transformed_statements.append(statement) + + return transformed_statements + + def _transform_placeholder_node_ids_into_blank_node_ids( + statements: List[str], + ) -> List[str]: + """Transform the blank node placeholder identifiers back into actual blank nodes + + e.g urn:bnid:_:c14n0 => _:c14n0 + + Args: + statements (List[str]): List with possible placeholder node identifiers + + Returns: + List[str]: List of transformed output statements + """ + transformed_statements = [] + + prefix_string = "", prefix_index) + + statement = statement.replace( + statement[prefix_index : closing_index + 1], + statement[prefix_index + len(prefix_string), closing_index], + ) + + transformed_statements.append(statement) + + return transformed_statements + + supported_derive_proof_types = [ + "BbsBlsSignature2020", + "sec:BbsBlsSignature2020", + "https://w3id.org/security#BbsBlsSignature2020", + ] diff --git a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py index bce2c838ba..8528239c95 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py @@ -91,7 +91,9 @@ async def verify_signature( bool: Whether the signature is valid for the data """ if not (isinstance(proof.get("jws"), str) and (".." in proof.get("jws"))): - raise Exception('The proof does not contain a valid "jws" property.') + raise LinkedDataProofException( + 'The proof does not contain a valid "jws" property.' + ) encoded_header, payload, encoded_signature = proof.get("jws").split(".") diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py index 6626be4d89..af6aad90da 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py @@ -1,8 +1,14 @@ """Abstract base class for linked data proofs.""" -from typing import TYPE_CHECKING -from abc import ABCMeta, abstractmethod +from abc import ABC +from pyld import jsonld +from typing import List, TYPE_CHECKING, Union + +from typing_extensions import TypedDict + +from ..constants import SECURITY_CONTEXT_URL +from ..error import LinkedDataProofException from ..document_loader import DocumentLoader from ..validation_result import ProofResult @@ -11,15 +17,28 @@ from ..purposes.ProofPurpose import ProofPurpose -class LinkedDataProof(metaclass=ABCMeta): +class DeriveProofResult(TypedDict): + """Result dict for deriving a proof""" + + document: dict + proof: Union[dict, List[dict]] + + +class LinkedDataProof(ABC): """Base Linked data proof""" - def __init__(self, *, signature_type: str, proof: dict = None): + def __init__( + self, + *, + signature_type: str, + proof: dict = None, + supported_derive_proof_types: Union[List[str], None] = None, + ): """Initialize new LinkedDataProof instance""" self.signature_type = signature_type self.proof = proof + self.supported_derive_proof_types = supported_derive_proof_types - @abstractmethod async def create_proof( self, *, @@ -37,8 +56,10 @@ async def create_proof( Returns: dict: The proof object """ + raise LinkedDataProofException( + f"{self.signature_type} signature suite does not support creating proofs" + ) - @abstractmethod async def verify_proof( self, *, @@ -58,6 +79,74 @@ async def verify_proof( Returns: ValidationResult: The results of the proof verification """ + raise LinkedDataProofException( + f"{self.signature_type} signature suite does not support verifying proofs" + ) + + async def derive_proof( + self, + *, + proof: dict, + document: dict, + reveal_document: dict, + document_loader: DocumentLoader, + nonce: bytes = None, + ) -> DeriveProofResult: + """Derive proof for document, returning derived document and proof. + + Args: + proof (dict): The proof to derive from + document (dict): The document to derive the proof for + reveal_document (dict): The JSON-LD frame the revealed attributes + document_loader (DocumentLoader): Document loader used for resolving + nonce (bytes, optional): Nonce to use for the proof. Defaults to None. + + Returns: + DeriveProofResult: The derived document and proof + """ + raise LinkedDataProofException( + f"{self.signature_type} signature suite does not support deriving proofs" + ) + + def _get_verification_method( + self, *, proof: dict, document_loader: DocumentLoader + ) -> dict: + """Get verification method for proof""" + + verification_method = proof.get("verificationMethod") + + if isinstance(verification_method, dict): + verification_method: str = verification_method.get("id") + + if not verification_method: + raise LinkedDataProofException('No "verificationMethod" found in proof') + + framed = jsonld.frame( + verification_method, + frame={ + "@context": SECURITY_CONTEXT_URL, + "@embed": "@always", + "id": verification_method, + }, + options={ + "documentLoader": document_loader, + "expandContext": SECURITY_CONTEXT_URL, + # if we don't set base explicitly it will remove the base in returned + # document (e.g. use key:z... instead of did:key:z...) + # same as compactToRelative in jsonld.js + "base": None, + }, + ) + + if not framed: + raise LinkedDataProofException( + f"Verification method {verification_method} not found" + ) + + if framed.get("revoked"): + raise LinkedDataProofException("The verification method has been revoked.") + + return framed def match_proof(self, signature_type: str) -> bool: """Match signature type to signature type of this suite""" diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py index 9159f0b0d1..a2d5fb9892 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -23,7 +23,7 @@ def __init__( signature_type: str, proof: dict = None, verification_method: str = None, - date: Union[str, None] = None, + date: Union[datetime, None] = None, ): """Create new LinkedDataSignature instance""" super().__init__(signature_type=signature_type, proof=proof) @@ -142,50 +142,16 @@ async def verify_proof( ) if not purpose_result.valid: - raise purpose_result.error + return ProofResult( + verified=False, + purpose_result=purpose_result, + error=purpose_result.error, + ) return ProofResult(verified=True, purpose_result=purpose_result) except Exception as err: return ProofResult(verified=False, error=err) - def _get_verification_method( - self, *, proof: dict, document_loader: DocumentLoader - ) -> dict: - verification_method = proof.get("verificationMethod") - - if not verification_method: - raise Exception('No "verificationMethod" found in proof') - - if isinstance(verification_method, dict): - verification_method: str = verification_method.get("id") - - framed = jsonld.frame( - verification_method, - frame={ - "@context": SECURITY_CONTEXT_URL, - "@embed": "@always", - "id": verification_method, - }, - options={ - "documentLoader": document_loader, - "expandContext": SECURITY_CONTEXT_URL, - # if we don't set base explicitly it will remove the base in returned - # document (e.g. use key:z... instead of did:key:z...) - # same as compactToRelative in jsonld.js - "base": None, - }, - ) - - if not framed: - raise LinkedDataProofException( - f"Verification method {verification_method} not found" - ) - - if framed.get("revoked"): - raise LinkedDataProofException("The verification method has been revoked.") - - return framed - def _create_verify_data( self, *, proof: dict, document: dict, document_loader: DocumentLoader ) -> bytes: diff --git a/aries_cloudagent/vc/vc_ld/prove.py b/aries_cloudagent/vc/vc_ld/prove.py index f1f7388bad..4f3cbd8f5b 100644 --- a/aries_cloudagent/vc/vc_ld/prove.py +++ b/aries_cloudagent/vc/vc_ld/prove.py @@ -8,6 +8,7 @@ sign, LinkedDataProof, LinkedDataProofException, + derive, ) from ..ld_proofs.constants import CREDENTIALS_CONTEXT_V1_URL from .models.credential_schema import VerifiableCredentialSchema @@ -16,6 +17,21 @@ async def create_presentation( *, credentials: List[dict], presentation_id: str = None ) -> dict: + """Create presentation and add the credentials to it. + + Will validates the structure off all credentials, but does + not sign the presentation yet. Call sing_presentation to do this. + + Args: + credentials (List[dict]): Credentails to add to the presentation + presentation_id (str, optional): Id of the presentation. Defaults to None. + + Raises: + LinkedDataProofException: When not all credentials have a valid structure + + Returns: + dict: The unsigned presentation object + """ presentation = { "@context": [CREDENTIALS_CONTEXT_V1_URL], "type": ["VerifiablePresentation"], @@ -41,10 +57,29 @@ async def sign_presentation( presentation: dict, suite: LinkedDataProof, document_loader: DocumentLoader, + purpose: ProofPurpose = None, challenge: str = None, domain: str = None, - purpose: ProofPurpose = None, -): +) -> dict: + """Sign the presentation with the passed signature suite. + + Will set a default AuthenticationProofPurpose if no proof purpose is passed. + + Args: + presentation (dict): The presentation to sign + suite (LinkedDataProof): The signature suite to sign the presentation with + document_loader (DocumentLoader): Document loader to use. + purpose (ProofPurpose, optional): Purpose to use. Required if challenge is None + challenge (str, optional): Challenge to use. Required if domain is None. + domain (str, optional): Domain to use. Only used if purpose is None. + + Raises: + LinkedDataProofException: When both purpose and challenge are not provided + And when signing of the presentation fails + + Returns: + dict: A verifiable presentation object + """ if not purpose and not challenge: raise LinkedDataProofException( 'A "challenge" param is required when not providing a "purpose" (for AuthenticationProofPurpose).' @@ -60,3 +95,33 @@ async def sign_presentation( purpose=purpose, document_loader=document_loader, ) + + +async def derive_credential( + *, + credential: dict, + reveal_document: dict, + suite: LinkedDataProof, + document_loader: DocumentLoader, +) -> dict: + """Derive new credential from the existing credential and the reveal document. + + All proofs matching the signature suite type will be replaced with a derived + proof. Other proofs will be discarded. + + Args: + credential (dict): The credential to derive the new credential from. + reveal_document (dict): JSON-LD frame to select which attributes to include. + suite (LinkedDataProof): The signature suite to use for derivation + document_loader (DocumentLoader): The document loader to use. + + Returns: + dict: The derived credential. + """ + + return await derive( + document=credential, + reveal_document=reveal_document, + suite=suite, + document_loader=document_loader, + ) From ce55d040fa145837b8dd176cbcb344c8f246608f Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Thu, 25 Mar 2021 23:42:24 +0100 Subject: [PATCH 047/138] update context url constants Signed-off-by: Timo Glastra --- aries_cloudagent/did/did_key.py | 7 ++++--- .../presentation_exchange/pres_exch_handler.py | 10 ++++++---- aries_cloudagent/storage/vc_holder/vc_record.py | 2 +- aries_cloudagent/vc/ld_proofs/constants.py | 7 +++++-- aries_cloudagent/vc/tests/document_loader.py | 4 ++-- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/aries_cloudagent/did/did_key.py b/aries_cloudagent/did/did_key.py index 505eb16fac..45ac50a4be 100644 --- a/aries_cloudagent/did/did_key.py +++ b/aries_cloudagent/did/did_key.py @@ -2,6 +2,7 @@ from ..wallet.crypto import KeyType, ed25519_pk_to_curve25519 from ..wallet.util import b58_to_bytes, bytes_to_b58 +from ..vc.ld_proofs.constants import DID_V1_CONTEXT_URL class DIDKey: @@ -160,7 +161,7 @@ def construct_did_key_bls12381g1g2(did_key: "DIDKey") -> dict: bls12381g2_key_id = f"{did_key.did}#{bls12381g2_key.fingerprint}" return { - "@context": "https://www.w3.org/ns/did/v1", + "@context": DID_V1_CONTEXT_URL, "id": did_key.did, "verificationMethod": [ { @@ -195,7 +196,7 @@ def construct_did_key_x25519(did_key: "DIDKey") -> dict: """ return { - "@context": "https://www.w3.org/ns/did/v1", + "@context": DID_V1_CONTEXT_URL, "id": did_key.did, "verificationMethod": [ { @@ -258,7 +259,7 @@ def construct_did_signature_key_base( """ return { - "@context": "https://www.w3.org/ns/did/v1", + "@context": DID_V1_CONTEXT_URL, "id": id, "verificationMethod": [verification_method], "authentication": [key_id], diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py index b18f0c17a2..32f5182abc 100644 --- a/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py +++ b/aries_cloudagent/protocols/present_proof/presentation_exchange/pres_exch_handler.py @@ -17,6 +17,10 @@ from typing import Sequence, Optional from uuid import uuid4 +from ....vc.ld_proofs.constants import ( + CREDENTIALS_CONTEXT_V1_URL, + VERIFIABLE_PRESENTATION_TYPE, +) from ....core.error import BaseError from ....storage.vc_holder.vc_record import VCRecord @@ -39,11 +43,9 @@ class PresentationExchError(BaseError): """Base class for DIF Presentation Exchange related errors.""" -CREDENTIAL_JSONLD_CONTEXT = "https://www.w3.org/2018/credentials/v1" PRESENTATION_SUBMISSION_JSONLD_CONTEXT = ( "https://identity.foundation/presentation-exchange/submission/v1" ) -VERIFIABLE_PRESENTATION_JSONLD_TYPE = "VerifiablePresentation" PRESENTATION_SUBMISSION_JSONLD_TYPE = "PresentationSubmission" @@ -766,12 +768,12 @@ async def create_vp( # defaultVPContext default_vp_context = [ - CREDENTIAL_JSONLD_CONTEXT, + CREDENTIALS_CONTEXT_V1_URL, PRESENTATION_SUBMISSION_JSONLD_CONTEXT, ] # defaultVPType default_vp_type = [ - VERIFIABLE_PRESENTATION_JSONLD_TYPE, + VERIFIABLE_PRESENTATION_TYPE, PRESENTATION_SUBMISSION_JSONLD_TYPE, ] diff --git a/aries_cloudagent/storage/vc_holder/vc_record.py b/aries_cloudagent/storage/vc_holder/vc_record.py index d078845fef..53bb650a38 100644 --- a/aries_cloudagent/storage/vc_holder/vc_record.py +++ b/aries_cloudagent/storage/vc_holder/vc_record.py @@ -161,7 +161,7 @@ def deserialize_jsonld_cred(cls, cred_json: str) -> "VCRecord": subject_ids=subject_ids, given_id=given_id, cred_value=value, - tags=tags, + cred_tags=tags, record_id=record_id, schema_ids=schema_ids, ) diff --git a/aries_cloudagent/vc/ld_proofs/constants.py b/aries_cloudagent/vc/ld_proofs/constants.py index bd855242da..48715d076d 100644 --- a/aries_cloudagent/vc/ld_proofs/constants.py +++ b/aries_cloudagent/vc/ld_proofs/constants.py @@ -4,10 +4,13 @@ SECURITY_CONTEXT_V2_URL = "https://w3id.org/security/v2" SECURITY_CONTEXT_V3_URL = "https://w3id.org/security/v3-unstable" SECURITY_CONTEXT_URL = SECURITY_CONTEXT_V2_URL -DID_V1_URL = "https://www.w3.org/ns/did/v1" +DID_V1_CONTEXT_URL = "https://www.w3.org/ns/did/v1" CREDENTIALS_CONTEXT_V1_URL = "https://www.w3.org/2018/credentials/v1" SECURITY_CONTEXT_BBS_URL = "https://w3id.org/security/bbs/v1" + CREDENTIALS_ISSUER_URL = "https://www.w3.org/2018/credentials#issuer" -VERIFIABLE_CREDENTIAL_TYPE = "VerifiableCredential" SECURITY_PROOF_URL = "https://w3id.org/security#proof" SECURITY_SIGNATURE_URL = "https://w3id.org/security#signature" + +VERIFIABLE_CREDENTIAL_TYPE = "VerifiableCredential" +VERIFIABLE_PRESENTATION_TYPE = "VerifiableCredential" \ No newline at end of file diff --git a/aries_cloudagent/vc/tests/document_loader.py b/aries_cloudagent/vc/tests/document_loader.py index c1a3b47165..0ff118040a 100644 --- a/aries_cloudagent/vc/tests/document_loader.py +++ b/aries_cloudagent/vc/tests/document_loader.py @@ -11,7 +11,7 @@ from ..ld_proofs.constants import ( SECURITY_CONTEXT_V2_URL, SECURITY_CONTEXT_V1_URL, - DID_V1_URL, + DID_V1_CONTEXT_URL, SECURITY_CONTEXT_BBS_URL, CREDENTIALS_CONTEXT_V1_URL, ) @@ -23,7 +23,7 @@ ): DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL, SECURITY_CONTEXT_V1_URL: SECURITY_V1, SECURITY_CONTEXT_V2_URL: SECURITY_V2, - DID_V1_URL: DID_V1, + DID_V1_CONTEXT_URL: DID_V1, CREDENTIALS_CONTEXT_V1_URL: CREDENTIALS_V1, SECURITY_CONTEXT_BBS_URL: BBS_V1, "https://www.w3.org/2018/credentials/examples/v1": EXAMPLES_V1, From 0f5c01007ecc9b2e6be5e61326ea07831c9acfdf Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Thu, 25 Mar 2021 23:58:52 +0100 Subject: [PATCH 048/138] update wallet routes allowed key types Signed-off-by: Timo Glastra --- aries_cloudagent/wallet/crypto.py | 4 ++-- aries_cloudagent/wallet/routes.py | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/aries_cloudagent/wallet/crypto.py b/aries_cloudagent/wallet/crypto.py index 57ca20003c..641010cce1 100644 --- a/aries_cloudagent/wallet/crypto.py +++ b/aries_cloudagent/wallet/crypto.py @@ -52,7 +52,7 @@ def from_multicodec_name(cls, multicodec_name: str) -> Optional["KeyType"]: return None @classmethod - def from_key_type(cls, key_type: str) -> Optional["DIDMethod"]: + def from_key_type(cls, key_type: str) -> Optional["KeyType"]: for _key_type in KeyType: if _key_type.key_type == key_type: return _key_type @@ -71,7 +71,7 @@ def from_key_type(cls, key_type: str) -> Optional["DIDMethod"]: class DIDMethod(Enum): SOV = DIDMethodSpec("sov", [KeyType.ED25519]) - KEY = DIDMethodSpec("key", [KeyType.ED25519]) + KEY = DIDMethodSpec("key", [KeyType.ED25519, KeyType.BLS12381G2]) @property def method_name(self) -> str: diff --git a/aries_cloudagent/wallet/routes.py b/aries_cloudagent/wallet/routes.py index 48fef33949..43a77a9650 100644 --- a/aries_cloudagent/wallet/routes.py +++ b/aries_cloudagent/wallet/routes.py @@ -10,6 +10,7 @@ from marshmallow import fields, validate, ValidationError +from ..did.did_key import DIDKey from ..admin.request_context import AdminRequestContext from ..ledger.base import BaseLedger from ..ledger.endpoint_type import EndpointType @@ -47,6 +48,10 @@ class DIDSchema(OpenAPISchema): ), **DID_POSTURE, ) + key_type = fields.Str( + description="Key type associated with the DID", + validate=validate.OneOf([KeyType.ED25519, KeyType.BLS12381G2]), + ) class DIDResultSchema(OpenAPISchema): @@ -125,7 +130,7 @@ class DIDCreateOptionsSchema(OpenAPISchema): key_type = fields.Str( required=True, example=KeyType.ED25519.key_type, - validate=validate.OneOf([key_type.key_type for key_type in KeyType]), + validate=validate.OneOf([KeyType.ED25519, KeyType.BLS12381G2]), ) @@ -148,11 +153,17 @@ class DIDCreateSchema(OpenAPISchema): def format_did_info(info: DIDInfo): """Serialize a DIDInfo object.""" + key_type = KeyType.ED25519 # default from did:sov + did_method = DIDMethod.from_metadata(info.metadata) + + if did_method == DIDMethod.KEY: + key_type = DIDKey.from_did(info.did).key_type if info: return { "did": info.did, "verkey": info.verkey, "posture": DIDPosture.get(info.metadata).moniker, + "key_type": key_type, } From 657da463709933e85366ee0b010eb05c7b165120 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sat, 27 Mar 2021 18:04:15 +0100 Subject: [PATCH 049/138] formatting, docs, flake, black Signed-off-by: Timo Glastra --- aries_cloudagent/did/did_key.py | 6 +- .../out_of_band/v1_0/tests/test_manager.py | 9 +- .../storage/vc_holder/in_memory.py | 1 - .../storage/vc_holder/vc_record.py | 3 +- aries_cloudagent/vc/ld_proofs/constants.py | 4 +- .../ld_proofs/crypto/Ed25519WalletKeyPair.py | 20 +- .../vc/ld_proofs/crypto/KeyPair.py | 11 +- .../vc/ld_proofs/crypto/WalletKeyPair.py | 6 +- .../vc/ld_proofs/document_loader.py | 9 +- aries_cloudagent/vc/ld_proofs/error.py | 4 +- aries_cloudagent/vc/ld_proofs/ld_proofs.py | 21 +- .../purposes/AssertionProofPurpose.py | 4 +- .../purposes/AuthenticationProofPurpose.py | 12 +- .../purposes/ControllerProofPurpose.py | 5 +- .../purposes/CredentialIssuancePurpose.py | 4 +- .../vc/ld_proofs/purposes/ProofPurpose.py | 10 +- .../suites/BbsBlsSignature2020Base.py | 12 +- .../ld_proofs/suites/Ed25519Signature2018.py | 18 +- .../suites/JwsLinkedDataSignature.py | 36 +++- .../vc/ld_proofs/suites/LinkedDataProof.py | 15 +- .../vc/ld_proofs/tests/test_ld_proofs.py | 1 - .../vc/ld_proofs/validation_result.py | 16 +- aries_cloudagent/vc/tests/contexts/bbs_v1.py | 2 +- .../vc/tests/contexts/credentials_v1.py | 2 +- aries_cloudagent/vc/tests/contexts/did_v1.py | 2 +- .../vc/tests/contexts/examples_v1.py | 2 +- aries_cloudagent/vc/tests/contexts/odrl.py | 2 +- .../vc/tests/contexts/security_v1.py | 93 +++++---- .../vc/tests/contexts/security_v2.py | 179 +++++++++--------- aries_cloudagent/vc/vc_ld/issue.py | 25 +++ .../vc/vc_ld/models/credential.py | 12 +- .../vc/vc_ld/models/credential_schema.py | 55 ++++-- aries_cloudagent/vc/vc_ld/prove.py | 2 + .../vc/vc_ld/validation_result.py | 6 +- aries_cloudagent/vc/vc_ld/verify.py | 9 +- aries_cloudagent/wallet/crypto.py | 16 ++ aries_cloudagent/wallet/in_memory.py | 3 +- aries_cloudagent/wallet/indy.py | 3 +- aries_cloudagent/wallet/routes.py | 2 +- aries_cloudagent/wallet/util.py | 2 +- 40 files changed, 384 insertions(+), 260 deletions(-) diff --git a/aries_cloudagent/did/did_key.py b/aries_cloudagent/did/did_key.py index 45ac50a4be..4e84eedc9a 100644 --- a/aries_cloudagent/did/did_key.py +++ b/aries_cloudagent/did/did_key.py @@ -1,8 +1,12 @@ +"""DID Key class and resolver methods.""" + from multicodec.multicodec import add_prefix, get_codec, remove_prefix from ..wallet.crypto import KeyType, ed25519_pk_to_curve25519 from ..wallet.util import b58_to_bytes, bytes_to_b58 -from ..vc.ld_proofs.constants import DID_V1_CONTEXT_URL + +# FIXME: importing this from vc.constants gives circular dependency error +DID_V1_CONTEXT_URL = "https://www.w3.org/ns/did/v1" class DIDKey: 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 b3140f0bf3..9db941a467 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 @@ -658,9 +658,12 @@ async def test_create_invitation_peer_did(self): assert service["id"] == "#inline" assert service["type"] == "did-communication" assert len(service["recipientKeys"]) == 1 - assert service["routingKeys"][0] == DIDKey.from_public_key_b58( - self.test_mediator_routing_keys[0], KeyType.ED25519 - ).did + assert ( + service["routingKeys"][0] + == DIDKey.from_public_key_b58( + self.test_mediator_routing_keys[0], KeyType.ED25519 + ).did + ) assert service["serviceEndpoint"] == self.test_mediator_endpoint async def test_create_invitation_metadata_assigned(self): diff --git a/aries_cloudagent/storage/vc_holder/in_memory.py b/aries_cloudagent/storage/vc_holder/in_memory.py index 8af54c511c..8ca53e2a43 100644 --- a/aries_cloudagent/storage/vc_holder/in_memory.py +++ b/aries_cloudagent/storage/vc_holder/in_memory.py @@ -4,7 +4,6 @@ from ...core.in_memory import InMemoryProfile -from ..record import StorageRecord from ..in_memory import InMemoryStorage, InMemoryStorageSearch from .base import VCHolder, VCRecordSearch diff --git a/aries_cloudagent/storage/vc_holder/vc_record.py b/aries_cloudagent/storage/vc_holder/vc_record.py index 53bb650a38..31984c95bd 100644 --- a/aries_cloudagent/storage/vc_holder/vc_record.py +++ b/aries_cloudagent/storage/vc_holder/vc_record.py @@ -4,7 +4,6 @@ from pyld import jsonld from pyld.jsonld import JsonLdProcessor -from typing import Sequence import logging from typing import Mapping, Sequence, Sequence @@ -209,4 +208,4 @@ class Meta: keys=fields.Str(description="Retrieval tag name"), values=fields.Str(description="Retrieval tag value"), ) - record_id = fields.Str(description="Record identifier", example=UUIDFour.EXAMPLE) \ No newline at end of file + record_id = fields.Str(description="Record identifier", example=UUIDFour.EXAMPLE) diff --git a/aries_cloudagent/vc/ld_proofs/constants.py b/aries_cloudagent/vc/ld_proofs/constants.py index 48715d076d..93613b7a16 100644 --- a/aries_cloudagent/vc/ld_proofs/constants.py +++ b/aries_cloudagent/vc/ld_proofs/constants.py @@ -1,4 +1,4 @@ -"""JSON-LD, Linked Data Proof and Verifiable Credential constants""" +"""JSON-LD, Linked Data Proof and Verifiable Credential constants.""" SECURITY_CONTEXT_V1_URL = "https://w3id.org/security/v1" SECURITY_CONTEXT_V2_URL = "https://w3id.org/security/v2" @@ -13,4 +13,4 @@ SECURITY_SIGNATURE_URL = "https://w3id.org/security#signature" VERIFIABLE_CREDENTIAL_TYPE = "VerifiableCredential" -VERIFIABLE_PRESENTATION_TYPE = "VerifiableCredential" \ No newline at end of file +VERIFIABLE_PRESENTATION_TYPE = "VerifiableCredential" diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py index 0eaf519040..354ec7629b 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py @@ -1,4 +1,4 @@ -"""Ed25519 key pair based on base wallet interface""" +"""Ed25519 key pair based on base wallet interface.""" from typing import Optional @@ -9,7 +9,7 @@ class Ed25519WalletKeyPair(WalletKeyPair): - """Ed25519 wallet key pair""" + """Ed25519 wallet key pair.""" # TODO: maybe make public key buffer default input? # This way we can make it an input on the lower level key pair class @@ -20,7 +20,7 @@ def __init__(self, *, wallet: BaseWallet, public_key_base58: Optional[str] = Non self.public_key_base58 = public_key_base58 async def sign(self, message: bytes) -> bytes: - """Sign message using Ed25519 key""" + """Sign message using Ed25519 key.""" if not self.public_key_base58: raise LinkedDataProofException( "Unable to sign message with Ed25519WalletKeyPair: No key to sign with" @@ -31,10 +31,11 @@ async def sign(self, message: bytes) -> bytes: ) async def verify(self, message: bytes, signature: bytes) -> bool: - """Verify message against signature using Ed25519 key""" + """Verify message against signature using Ed25519 key.""" if not self.public_key_base58: raise LinkedDataProofException( - "Unable to verify message with Ed25519WalletKeyPair: No key to sign verify with" + "Unable to verify message with Ed25519WalletKeyPair" + ": No key to verify with" ) return await self.wallet.verify_message( @@ -44,10 +45,10 @@ async def verify(self, message: bytes, signature: bytes) -> bool: def from_verification_method( self, verification_method: dict ) -> "Ed25519WalletKeyPair": - """Create new Ed25519WalletKeyPair from public key in verification method""" + """Create new Ed25519WalletKeyPair from public key in verification method.""" if "publicKeyBase58" not in verification_method: raise LinkedDataProofException( - "Cannot set public key from verification method: publicKeyBase58 not found" + "Unable to set public key from verification method: no publicKeyBase58" ) return Ed25519WalletKeyPair( @@ -56,9 +57,10 @@ def from_verification_method( @property def public_key(self) -> Optional[bytes]: + """Getter for public key.""" return b58_to_bytes(self.public_key_base58) @property def has_public_key(self) -> bool: - """Whether key pair has public key""" - return self.public_key_base58 != None + """Whether key pair has public key.""" + return self.public_key_base58 is not None diff --git a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py index 55d18c6255..2e7e218304 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py @@ -1,4 +1,4 @@ -"""Base key pair class""" +"""Base key pair class.""" from abc import ABC, abstractmethod, abstractproperty from typing import List, Optional, Union @@ -9,13 +9,13 @@ class KeyPair(ABC): @abstractmethod async def sign(self, message: Union[List[bytes], bytes]) -> bytes: - """Sign message using key pair""" + """Sign message using key pair.""" @abstractmethod async def verify( self, message: Union[List[bytes], bytes], signature: bytes ) -> bool: - """Verify message against signature using key pair""" + """Verify message against signature using key pair.""" @abstractproperty def has_public_key(self) -> bool: @@ -27,12 +27,13 @@ def has_public_key(self) -> bool: @abstractproperty def public_key(self) -> Optional[bytes]: - """Getter for the public key bytes + """Getter for the public key bytes. Returns: bytes: The public key + """ @abstractmethod def from_verification_method(self, verification_method: dict) -> "KeyPair": - """Create new key pair class based on the passed verification method.""" \ No newline at end of file + """Create new key pair class based on the passed verification method.""" diff --git a/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py index 6ae1702c16..efb4d17507 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py @@ -1,4 +1,4 @@ -"""Key pair based on base wallet interface""" +"""Key pair based on base wallet interface.""" from abc import ABCMeta @@ -7,8 +7,8 @@ class WalletKeyPair(KeyPair, metaclass=ABCMeta): - """Base wallet key pair""" + """Base wallet key pair.""" def __init__(self, *, wallet: BaseWallet) -> None: """Initialize new WalletKeyPair instance.""" - self.wallet = wallet \ No newline at end of file + self.wallet = wallet diff --git a/aries_cloudagent/vc/ld_proofs/document_loader.py b/aries_cloudagent/vc/ld_proofs/document_loader.py index 5034614e4e..893c15f7b1 100644 --- a/aries_cloudagent/vc/ld_proofs/document_loader.py +++ b/aries_cloudagent/vc/ld_proofs/document_loader.py @@ -1,4 +1,4 @@ -"""JSON-LD document loader methods""" +"""JSON-LD document loader methods.""" from pyld.documentloader import requests from typing import Callable @@ -9,10 +9,10 @@ def get_default_document_loader(profile: Profile) -> "DocumentLoader": - """Return the default document loader""" + """Return the default document loader.""" def default_document_loader(url: str, options: dict): - """Default document loader implementation""" + """Retrieve http(s) or did:key document.""" # TODO: integrate with did resolver interface # https://github.com/hyperledger/aries-cloudagent-python/pull/1033 if url.startswith("did:key:"): @@ -29,7 +29,8 @@ def default_document_loader(url: str, options: dict): return loader(url, options) else: raise LinkedDataProofException( - "Unrecognized url format. Must start with 'did:key:', 'http://' or 'https://'" + "Unrecognized url format. Must start with " + "'did:key:', 'http://' or 'https://'" ) return default_document_loader diff --git a/aries_cloudagent/vc/ld_proofs/error.py b/aries_cloudagent/vc/ld_proofs/error.py index 5a19b6228e..0496ed58fd 100644 --- a/aries_cloudagent/vc/ld_proofs/error.py +++ b/aries_cloudagent/vc/ld_proofs/error.py @@ -1,5 +1,5 @@ -"""Linked data proof exception classes""" +"""Linked data proof exception classes.""" class LinkedDataProofException(Exception): - "Base exception for linked data proof module." \ No newline at end of file + """Base exception for linked data proof module.""" diff --git a/aries_cloudagent/vc/ld_proofs/ld_proofs.py b/aries_cloudagent/vc/ld_proofs/ld_proofs.py index 3889108243..69f1189c13 100644 --- a/aries_cloudagent/vc/ld_proofs/ld_proofs.py +++ b/aries_cloudagent/vc/ld_proofs/ld_proofs.py @@ -23,7 +23,7 @@ async def sign( Proof is added based on the provided suite and proof purpose Args: - document (dict): The document to be signed. + document (dict): JSON-LD document to be signed. suite (LinkedDataProof): The linked data signature cryptographic suite with which to sign the document purpose (ProofPurpose): A proof purpose instance that will match proofs to be @@ -34,6 +34,7 @@ async def sign( LinkedDataProofException: When a jsonld url cannot be resolved, OR signing fails. Returns: dict: Signed document. + """ try: return await ProofSet.add( @@ -46,7 +47,9 @@ async def sign( except JsonLdError as e: if e.type == "jsonld.InvalidUrl": raise LinkedDataProofException( - f'A URL "{e.details}" could not be fetched; you need to pass a DocumentLoader function that can resolve this URL, or resolve the URL before calling "sign".' + f'A URL "{e.details}" could not be fetched; you need to pass a ' + "DocumentLoader function that can resolve this URL, or resolve" + ' the URL before calling "sign".' ) raise e @@ -58,7 +61,7 @@ async def verify( purpose: ProofPurpose, document_loader: DocumentLoader, ) -> DocumentVerificationResult: - """Verifies the linked data signature on the provided document. + """Verify the linked data signature on the provided document. Args: document (dict): The document with one or more proofs to be verified. @@ -69,11 +72,12 @@ async def verify( document_loader (DocumentLoader): The document loader to use. Returns: - DocumentVerificationResult: Object with a `verified` boolean property that is `True` if at least one - proof matching the given purpose and suite verifies and `False` otherwise. - a `results` property with an array of detailed results. - if `False` an `errors` property will be present, with a list + DocumentVerificationResult: Object with a `verified` boolean property that is + `True` if at least one proof matching the given purpose and suite verifies + and `False` otherwise. a `results` property with an array of detailed + results. if `False` an `errors` property will be present, with a list containing all of the errors that occurred during the verification process. + """ result = await ProofSet.verify( @@ -109,6 +113,7 @@ async def derive( Returns: dict: The document with derived proof(s). + """ result = await ProofSet.derive( @@ -119,4 +124,4 @@ async def derive( nonce=nonce, ) - return result \ No newline at end of file + return result diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py index d5e59606e8..798c54ff3e 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py @@ -1,4 +1,4 @@ -"""Assertion proof purpose class""" +"""Assertion proof purpose class.""" from datetime import datetime, timedelta @@ -6,7 +6,7 @@ class AssertionProofPurpose(ControllerProofPurpose): - """Assertion proof purpose class""" + """Assertion proof purpose class.""" def __init__(self, *, date: datetime = None, max_timestamp_delta: timedelta = None): """Initialize new instance of AssertionProofPurpose.""" diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py index a253714eac..36390016c9 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py @@ -1,4 +1,4 @@ -"""Authentication proof purpose class""" +"""Authentication proof purpose class.""" from datetime import datetime, timedelta @@ -37,16 +37,18 @@ def validate( verification_method: dict, document_loader: DocumentLoader, ) -> PurposeResult: - """Validate whether challenge and domain are valid""" + """Validate whether challenge and domain are valid.""" try: if proof.get("challenge") != self.challenge: raise LinkedDataProofException( - f'The challenge is not as expected; challenge={proof.get("challenge")}, expected={self.challenge}' + f"The challenge is not as expected; challenge=" + f'{proof.get("challenge")}, expected={self.challenge}' ) if self.domain and (proof.get("domain") != self.domain): raise LinkedDataProofException( - f'The domain is not as expected; domain={proof.get("domain")}, expected={self.domain}' + f"The domain is not as expected; " + f'domain={proof.get("domain")}, expected={self.domain}' ) return super().validate( @@ -60,7 +62,7 @@ def validate( PurposeResult(valid=False, error=e) def update(self, proof: dict) -> dict: - """Update poof purpose, challenge and domain on proof""" + """Update poof purpose, challenge and domain on proof.""" proof = super().update(proof) proof["challenge"] = self.challenge diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py index 23d553aed6..10e90fdd80 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ControllerProofPurpose.py @@ -1,4 +1,4 @@ -"""Controller proof purpose class""" +"""Controller proof purpose class.""" from pyld.jsonld import JsonLdProcessor from pyld import jsonld @@ -75,7 +75,8 @@ def validate( if not result.valid: raise LinkedDataProofException( - f"Verification method {verification_id} not authorized by controller for proof purpose {self.term}" + f"Verification method {verification_id} not authorized" + f" by controller for proof purpose {self.term}" ) return result diff --git a/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py index 73f7d6d057..7c345f953c 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/CredentialIssuancePurpose.py @@ -1,4 +1,4 @@ -"""Credential Issuance proof purpose class""" +"""Credential Issuance proof purpose class.""" from typing import List from pyld.jsonld import JsonLdProcessor @@ -24,7 +24,7 @@ def validate( verification_method: dict, document_loader: DocumentLoader, ) -> PurposeResult: - """Checks whether the issuer matches the controller of the verification method.""" + """Validate if the issuer matches the controller of the verification method.""" try: result = super().validate( proof=proof, diff --git a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py index f15ad9ecc5..68eac33bcd 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/ProofPurpose.py @@ -1,4 +1,4 @@ -"""Base Proof Purpose class""" +"""Base Proof Purpose class.""" from datetime import datetime, timedelta @@ -9,12 +9,12 @@ class ProofPurpose: - """Base proof purpose class""" + """Base proof purpose class.""" def __init__( self, *, term: str, date: datetime = None, max_timestamp_delta: timedelta = None ): - """Initialize new proof purpose instance""" + """Initialize new proof purpose instance.""" self.term = term self.date = date or datetime.now() self.max_timestamp_delta = max_timestamp_delta @@ -46,10 +46,10 @@ def validate( return PurposeResult(valid=False, error=err) def update(self, proof: dict) -> dict: - """Update proof purpose on proof""" + """Update proof purpose on proof.""" proof["proofPurpose"] = self.term return proof def match(self, proof: dict) -> bool: - """Check whether the passed proof matches with the term of this proof purpose""" + """Check whether the passed proof matches with the term of this proof purpose.""" return proof.get("proofPurpose") == self.term diff --git a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py index 814bd1ffca..9fa4e1c060 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py +++ b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py @@ -1,4 +1,4 @@ -"""BbsBlsSignature2020Base class""" +"""BbsBlsSignature2020Base class.""" from abc import ABCMeta, abstractmethod from pyld import jsonld @@ -10,10 +10,12 @@ class BbsBlsSignature2020Base(LinkedDataProof, metaclass=ABCMeta): + """Base class for BbsBlsSignature suites.""" + def _create_verify_proof_data( self, proof: dict, document_loader: DocumentLoader ) -> List[str]: - """Create proof verification data""" + """Create proof verification data.""" c14_proof_options = self._canonize_proof( proof=proof, document_loader=document_loader ) @@ -25,7 +27,7 @@ def _create_verify_proof_data( def _create_verify_document_data( self, document: dict, document_loader: DocumentLoader ) -> List[str]: - """Create document verification data""" + """Create document verification data.""" c14n_doc = self._canonize(input=document, document_loader=document_loader) # Return only the lines that have any content in them @@ -33,7 +35,7 @@ def _create_verify_document_data( list(filter(lambda _: len(_) > 0, c14n_doc.split("\n"))) def _canonize(self, *, input, document_loader: DocumentLoader = None) -> str: - """Canonize input document using URDNA2015 algorithm""" + """Canonize input document using URDNA2015 algorithm.""" # application/n-quads format always returns str return jsonld.normalize( input, @@ -46,4 +48,4 @@ def _canonize(self, *, input, document_loader: DocumentLoader = None) -> str: @abstractmethod def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None): - """Canonize proof dictionary. Removes values that are not part of proof""" \ No newline at end of file + """Canonize proof dictionary. Removes values that are not part of proof.""" diff --git a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py index 49d81e452a..62e1854fba 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py +++ b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py @@ -1,4 +1,4 @@ -"""Ed25519Signature2018 suite""" +"""Ed25519Signature2018 suite.""" from datetime import datetime from typing import Union @@ -8,16 +8,26 @@ class Ed25519Signature2018(JwsLinkedDataSignature): - """Ed25519Signature2018 suite""" + """Ed25519Signature2018 suite.""" def __init__( self, + *, key_pair: Ed25519WalletKeyPair, - verification_method: str = None, proof: dict = None, + verification_method: str = None, date: Union[datetime, str] = None, ): - """Create new Ed25519Signature2018 instance""" + """Create new Ed25519Signature2018 instance. + + Args: + key_pair (KeyPair): Key pair to use. Must provide EdDSA signatures + proof (dict, optional): A JSON-LD document with options to use for the + `proof` node (e.g. any other custom fields can be provided here + using a context different from security-v2). + verification_method (str, optional): A key id URL to the paired public key. + date (datetime, optional): Signing date to use. + """ super().__init__( signature_type="Ed25519Signature2018", algorithm="EdDSA", diff --git a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py index 8528239c95..39534f81ff 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py @@ -1,4 +1,4 @@ -"""JWS Linked Data class""" +"""JWS Linked Data class.""" from pyld.jsonld import JsonLdProcessor from datetime import datetime @@ -13,7 +13,7 @@ class JwsLinkedDataSignature(LinkedDataSignature): - """JWS Linked Data class""" + """JWS Linked Data class.""" def __init__( self, @@ -22,11 +22,25 @@ def __init__( algorithm: str, required_key_type: str, key_pair: KeyPair, - verification_method: str = None, proof: dict = None, + verification_method: str = None, date: Union[datetime, str] = None, ): - """Create new JwsLinkedDataSignature instance""" + """Create new JwsLinkedDataSignature instance. + + Must be subclassed, not initialized directly. + + Args: + signature_type (str): Signature type for the proof, provided by subclass + algorithm (str): JWS alg to use, provided by subclass + required_key_type (str): Required key type in verification method. + key_pair (KeyPair): Key pair to use, provided by subclass + proof (dict, optional): A JSON-LD document with options to use for the + `proof` node (e.g. any other custom fields can be provided here + using a context different from security-v2). + verification_method (str, optional): A key id URL to the paired public key. + date (datetime, optional): Signing date to use. Defaults to now + """ super().__init__( signature_type=signature_type, @@ -40,7 +54,7 @@ def __init__( self.required_key_type = required_key_type async def sign(self, *, verify_data: bytes, proof: dict) -> dict: - """Sign the data and add it to the proof + """Sign the data and add it to the proof. Adds a jws to the proof that can be used for multiple signature algorithms. @@ -51,6 +65,7 @@ async def sign(self, *, verify_data: bytes, proof: dict) -> dict: Returns: dict: The proof object with the added signature + """ header = {"alg": self.algorithm, "b64": False, "crit": ["b64"]} @@ -89,6 +104,7 @@ async def verify_signature( Returns: bool: Whether the signature is valid for the data + """ if not (isinstance(proof.get("jws"), str) and (".." in proof.get("jws"))): raise LinkedDataProofException( @@ -113,7 +129,7 @@ async def verify_signature( return await key_pair.verify(data, signature) def _decode_header(self, encoded_header: str) -> dict: - """Decode header""" + """Decode header.""" header = None try: header = json.loads(b64_to_str(encoded_header, urlsafe=True)) @@ -122,7 +138,7 @@ def _decode_header(self, encoded_header: str) -> dict: return header def _encode_header(self, header: dict) -> str: - """Encode header""" + """Encode header.""" return str_to_b64(json.dumps(header), urlsafe=True, pad=False) def _create_jws(self, *, encoded_header: str, verify_data: bytes) -> bytes: @@ -130,7 +146,7 @@ def _create_jws(self, *, encoded_header: str, verify_data: bytes) -> bytes: return (encoded_header + ".").encode("utf-8") + verify_data def _validate_header(self, header: dict): - """ Validates the JWS header, throws if not ok """ + """Validate the JWS header, throws if not ok.""" if not (header and isinstance(header, dict)): raise LinkedDataProofException("Invalid JWS header.") @@ -147,7 +163,7 @@ def _validate_header(self, header: dict): ) def _assert_verification_method(self, verification_method: dict): - """Assert verification method. Throws if not ok""" + """Assert verification method. Throws if not ok.""" if not JsonLdProcessor.has_value( verification_method, "type", self.required_key_type ): @@ -156,7 +172,7 @@ def _assert_verification_method(self, verification_method: dict): ) def _get_verification_method(self, *, proof: dict, document_loader: DocumentLoader): - """Get verification method""" + """Get verification method.""" verification_method = super()._get_verification_method( proof=proof, document_loader=document_loader ) diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py index af6aad90da..990383a720 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py @@ -18,14 +18,14 @@ class DeriveProofResult(TypedDict): - """Result dict for deriving a proof""" + """Result dict for deriving a proof.""" document: dict proof: Union[dict, List[dict]] class LinkedDataProof(ABC): - """Base Linked data proof""" + """Base Linked data proof.""" def __init__( self, @@ -34,7 +34,7 @@ def __init__( proof: dict = None, supported_derive_proof_types: Union[List[str], None] = None, ): - """Initialize new LinkedDataProof instance""" + """Initialize new LinkedDataProof instance.""" self.signature_type = signature_type self.proof = proof self.supported_derive_proof_types = supported_derive_proof_types @@ -46,7 +46,7 @@ async def create_proof( purpose: "ProofPurpose", document_loader: DocumentLoader, ) -> dict: - """Create proof for document + """Create proof for document. Args: document (dict): The document to create the proof for @@ -55,6 +55,7 @@ async def create_proof( Returns: dict: The proof object + """ raise LinkedDataProofException( f"{self.signature_type} signature suite does not support creating proofs" @@ -78,6 +79,7 @@ async def verify_proof( Returns: ValidationResult: The results of the proof verification + """ raise LinkedDataProofException( f"{self.signature_type} signature suite does not support verifying proofs" @@ -103,6 +105,7 @@ async def derive_proof( Returns: DeriveProofResult: The derived document and proof + """ raise LinkedDataProofException( f"{self.signature_type} signature suite does not support deriving proofs" @@ -111,7 +114,7 @@ async def derive_proof( def _get_verification_method( self, *, proof: dict, document_loader: DocumentLoader ) -> dict: - """Get verification method for proof""" + """Get verification method for proof.""" verification_method = proof.get("verificationMethod") @@ -149,5 +152,5 @@ def _get_verification_method( return framed def match_proof(self, signature_type: str) -> bool: - """Match signature type to signature type of this suite""" + """Match signature type to signature type of this suite.""" return signature_type == self.signature_type diff --git a/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py b/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py index d6f67af0af..5405c6e873 100644 --- a/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py +++ b/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py @@ -1,5 +1,4 @@ from asynctest import TestCase -import traceback from datetime import datetime diff --git a/aries_cloudagent/vc/ld_proofs/validation_result.py b/aries_cloudagent/vc/ld_proofs/validation_result.py index 25a3474648..869f11dfb9 100644 --- a/aries_cloudagent/vc/ld_proofs/validation_result.py +++ b/aries_cloudagent/vc/ld_proofs/validation_result.py @@ -1,15 +1,15 @@ -"""Proof verification and validation result classes""" +"""Proof verification and validation result classes.""" from typing import List class PurposeResult: - """Proof purpose result class""" + """Proof purpose result class.""" def __init__( self, *, valid: bool, error: Exception = None, controller: dict = None ) -> None: - """Create new PurposeResult instance""" + """Create new PurposeResult instance.""" self.valid = valid self.error = error self.controller = controller @@ -26,7 +26,7 @@ def __eq__(self, other: object) -> bool: class ProofResult: - """Proof result class""" + """Proof result class.""" def __init__( self, @@ -36,7 +36,7 @@ def __init__( error: Exception = None, purpose_result: PurposeResult = None, ) -> None: - """Create new ProofResult instance""" + """Create new ProofResult instance.""" self.verified = verified self.proof = proof self.error = error @@ -55,7 +55,7 @@ def __eq__(self, other: object) -> bool: class DocumentVerificationResult: - """Domain verification result class""" + """Domain verification result class.""" def __init__( self, @@ -65,7 +65,7 @@ def __init__( results: List[ProofResult] = None, errors: List[Exception] = None, ) -> None: - """Create new DocumentVerificationResult instance""" + """Create new DocumentVerificationResult instance.""" self.verified = verified self.document = document self.results = results @@ -110,4 +110,4 @@ def __eq__(self, other: object) -> bool: ) ) ) - return False \ No newline at end of file + return False diff --git a/aries_cloudagent/vc/tests/contexts/bbs_v1.py b/aries_cloudagent/vc/tests/contexts/bbs_v1.py index 6fd62b2881..af4eca9069 100644 --- a/aries_cloudagent/vc/tests/contexts/bbs_v1.py +++ b/aries_cloudagent/vc/tests/contexts/bbs_v1.py @@ -88,4 +88,4 @@ }, "Bls12381G2Key2020": "https://w3id.org/security#Bls12381G2Key2020", } -} \ No newline at end of file +} diff --git a/aries_cloudagent/vc/tests/contexts/credentials_v1.py b/aries_cloudagent/vc/tests/contexts/credentials_v1.py index b1b6928f88..98d18bf921 100644 --- a/aries_cloudagent/vc/tests/contexts/credentials_v1.py +++ b/aries_cloudagent/vc/tests/contexts/credentials_v1.py @@ -247,4 +247,4 @@ "@container": "@graph", }, } -} \ No newline at end of file +} diff --git a/aries_cloudagent/vc/tests/contexts/did_v1.py b/aries_cloudagent/vc/tests/contexts/did_v1.py index ae65529c10..9b105062ec 100644 --- a/aries_cloudagent/vc/tests/contexts/did_v1.py +++ b/aries_cloudagent/vc/tests/contexts/did_v1.py @@ -61,4 +61,4 @@ "updated": {"@id": "dc:modified", "@type": "xsd:dateTime"}, "verificationMethod": {"@id": "sec:verificationMethod", "@type": "@id"}, } -} \ No newline at end of file +} diff --git a/aries_cloudagent/vc/tests/contexts/examples_v1.py b/aries_cloudagent/vc/tests/contexts/examples_v1.py index 089bda63d4..c16a8d1b86 100644 --- a/aries_cloudagent/vc/tests/contexts/examples_v1.py +++ b/aries_cloudagent/vc/tests/contexts/examples_v1.py @@ -43,4 +43,4 @@ "verifier": {"@id": "ex:verifier", "@type": "@id"}, }, ] -} \ No newline at end of file +} diff --git a/aries_cloudagent/vc/tests/contexts/odrl.py b/aries_cloudagent/vc/tests/contexts/odrl.py index c7ed5bfc3d..d1ddf0749d 100644 --- a/aries_cloudagent/vc/tests/contexts/odrl.py +++ b/aries_cloudagent/vc/tests/contexts/odrl.py @@ -178,4 +178,4 @@ "andSequence": "odrl:andSequence", "policyUsage": "odrl:policyUsage", } -} \ No newline at end of file +} diff --git a/aries_cloudagent/vc/tests/contexts/security_v1.py b/aries_cloudagent/vc/tests/contexts/security_v1.py index ae16742868..926d200799 100644 --- a/aries_cloudagent/vc/tests/contexts/security_v1.py +++ b/aries_cloudagent/vc/tests/contexts/security_v1.py @@ -1,50 +1,47 @@ SECURITY_V1 = { - "@context": { - "id": "@id", - "type": "@type", - - "dc": "http://purl.org/dc/terms/", - "sec": "https://w3id.org/security#", - "xsd": "http://www.w3.org/2001/XMLSchema#", - - "EcdsaKoblitzSignature2016": "sec:EcdsaKoblitzSignature2016", - "Ed25519Signature2018": "sec:Ed25519Signature2018", - "EncryptedMessage": "sec:EncryptedMessage", - "GraphSignature2012": "sec:GraphSignature2012", - "LinkedDataSignature2015": "sec:LinkedDataSignature2015", - "LinkedDataSignature2016": "sec:LinkedDataSignature2016", - "CryptographicKey": "sec:Key", - - "authenticationTag": "sec:authenticationTag", - "canonicalizationAlgorithm": "sec:canonicalizationAlgorithm", - "cipherAlgorithm": "sec:cipherAlgorithm", - "cipherData": "sec:cipherData", - "cipherKey": "sec:cipherKey", - "created": { "@id": "dc:created", "@type": "xsd:dateTime" }, - "creator": { "@id": "dc:creator", "@type": "@id" }, - "digestAlgorithm": "sec:digestAlgorithm", - "digestValue": "sec:digestValue", - "domain": "sec:domain", - "encryptionKey": "sec:encryptionKey", - "expiration": { "@id": "sec:expiration", "@type": "xsd:dateTime" }, - "expires": { "@id": "sec:expiration", "@type": "xsd:dateTime" }, - "initializationVector": "sec:initializationVector", - "iterationCount": "sec:iterationCount", - "nonce": "sec:nonce", - "normalizationAlgorithm": "sec:normalizationAlgorithm", - "owner": { "@id": "sec:owner", "@type": "@id" }, - "password": "sec:password", - "privateKey": { "@id": "sec:privateKey", "@type": "@id" }, - "privateKeyPem": "sec:privateKeyPem", - "publicKey": { "@id": "sec:publicKey", "@type": "@id" }, - "publicKeyBase58": "sec:publicKeyBase58", - "publicKeyPem": "sec:publicKeyPem", - "publicKeyWif": "sec:publicKeyWif", - "publicKeyService": { "@id": "sec:publicKeyService", "@type": "@id" }, - "revoked": { "@id": "sec:revoked", "@type": "xsd:dateTime" }, - "salt": "sec:salt", - "signature": "sec:signature", - "signatureAlgorithm": "sec:signingAlgorithm", - "signatureValue": "sec:signatureValue" - } + "@context": { + "id": "@id", + "type": "@type", + "dc": "http://purl.org/dc/terms/", + "sec": "https://w3id.org/security#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "EcdsaKoblitzSignature2016": "sec:EcdsaKoblitzSignature2016", + "Ed25519Signature2018": "sec:Ed25519Signature2018", + "EncryptedMessage": "sec:EncryptedMessage", + "GraphSignature2012": "sec:GraphSignature2012", + "LinkedDataSignature2015": "sec:LinkedDataSignature2015", + "LinkedDataSignature2016": "sec:LinkedDataSignature2016", + "CryptographicKey": "sec:Key", + "authenticationTag": "sec:authenticationTag", + "canonicalizationAlgorithm": "sec:canonicalizationAlgorithm", + "cipherAlgorithm": "sec:cipherAlgorithm", + "cipherData": "sec:cipherData", + "cipherKey": "sec:cipherKey", + "created": {"@id": "dc:created", "@type": "xsd:dateTime"}, + "creator": {"@id": "dc:creator", "@type": "@id"}, + "digestAlgorithm": "sec:digestAlgorithm", + "digestValue": "sec:digestValue", + "domain": "sec:domain", + "encryptionKey": "sec:encryptionKey", + "expiration": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, + "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, + "initializationVector": "sec:initializationVector", + "iterationCount": "sec:iterationCount", + "nonce": "sec:nonce", + "normalizationAlgorithm": "sec:normalizationAlgorithm", + "owner": {"@id": "sec:owner", "@type": "@id"}, + "password": "sec:password", + "privateKey": {"@id": "sec:privateKey", "@type": "@id"}, + "privateKeyPem": "sec:privateKeyPem", + "publicKey": {"@id": "sec:publicKey", "@type": "@id"}, + "publicKeyBase58": "sec:publicKeyBase58", + "publicKeyPem": "sec:publicKeyPem", + "publicKeyWif": "sec:publicKeyWif", + "publicKeyService": {"@id": "sec:publicKeyService", "@type": "@id"}, + "revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"}, + "salt": "sec:salt", + "signature": "sec:signature", + "signatureAlgorithm": "sec:signingAlgorithm", + "signatureValue": "sec:signatureValue", + } } diff --git a/aries_cloudagent/vc/tests/contexts/security_v2.py b/aries_cloudagent/vc/tests/contexts/security_v2.py index e66faea8f4..99e9348a06 100644 --- a/aries_cloudagent/vc/tests/contexts/security_v2.py +++ b/aries_cloudagent/vc/tests/contexts/security_v2.py @@ -1,93 +1,90 @@ SECURITY_V2 = { - "@context": [ - { - "@version": 1.1 - }, - "https://w3id.org/security/v1", - { - "AesKeyWrappingKey2019": "sec:AesKeyWrappingKey2019", - "DeleteKeyOperation": "sec:DeleteKeyOperation", - "DeriveSecretOperation": "sec:DeriveSecretOperation", - "EcdsaSecp256k1Signature2019": "sec:EcdsaSecp256k1Signature2019", - "EcdsaSecp256r1Signature2019": "sec:EcdsaSecp256r1Signature2019", - "EcdsaSecp256k1VerificationKey2019": "sec:EcdsaSecp256k1VerificationKey2019", - "EcdsaSecp256r1VerificationKey2019": "sec:EcdsaSecp256r1VerificationKey2019", - "Ed25519Signature2018": "sec:Ed25519Signature2018", - "Ed25519VerificationKey2018": "sec:Ed25519VerificationKey2018", - "EquihashProof2018": "sec:EquihashProof2018", - "ExportKeyOperation": "sec:ExportKeyOperation", - "GenerateKeyOperation": "sec:GenerateKeyOperation", - "KmsOperation": "sec:KmsOperation", - "RevokeKeyOperation": "sec:RevokeKeyOperation", - "RsaSignature2018": "sec:RsaSignature2018", - "RsaVerificationKey2018": "sec:RsaVerificationKey2018", - "Sha256HmacKey2019": "sec:Sha256HmacKey2019", - "SignOperation": "sec:SignOperation", - "UnwrapKeyOperation": "sec:UnwrapKeyOperation", - "VerifyOperation": "sec:VerifyOperation", - "WrapKeyOperation": "sec:WrapKeyOperation", - "X25519KeyAgreementKey2019": "sec:X25519KeyAgreementKey2019", - - "allowedAction": "sec:allowedAction", - "assertionMethod": { - "@id": "sec:assertionMethod", - "@type": "@id", - "@container": "@set" - }, - "authentication": { - "@id": "sec:authenticationMethod", - "@type": "@id", - "@container": "@set" - }, - "capability": { "@id": "sec:capability", "@type": "@id" }, - "capabilityAction": "sec:capabilityAction", - "capabilityChain": { - "@id": "sec:capabilityChain", - "@type": "@id", - "@container": "@list" - }, - "capabilityDelegation": { - "@id": "sec:capabilityDelegationMethod", - "@type": "@id", - "@container": "@set" - }, - "capabilityInvocation": { - "@id": "sec:capabilityInvocationMethod", - "@type": "@id", - "@container": "@set" - }, - "caveat": { "@id": "sec:caveat", "@type": "@id", "@container": "@set" }, - "challenge": "sec:challenge", - "ciphertext": "sec:ciphertext", - "controller": { "@id": "sec:controller", "@type": "@id" }, - "delegator": { "@id": "sec:delegator", "@type": "@id" }, - "equihashParameterK": { - "@id": "sec:equihashParameterK", - "@type": "xsd:integer" - }, - "equihashParameterN": { - "@id": "sec:equihashParameterN", - "@type": "xsd:integer" - }, - "invocationTarget": { "@id": "sec:invocationTarget", "@type": "@id" }, - "invoker": { "@id": "sec:invoker", "@type": "@id" }, - "jws": "sec:jws", - "keyAgreement": { - "@id": "sec:keyAgreementMethod", - "@type": "@id", - "@container": "@set" - }, - "kmsModule": { "@id": "sec:kmsModule" }, - "parentCapability": { "@id": "sec:parentCapability", "@type": "@id" }, - "plaintext": "sec:plaintext", - "proof": { "@id": "sec:proof", "@type": "@id", "@container": "@graph" }, - "proofPurpose": { "@id": "sec:proofPurpose", "@type": "@vocab" }, - "proofValue": "sec:proofValue", - "referenceId": "sec:referenceId", - "unwrappedKey": "sec:unwrappedKey", - "verificationMethod": { "@id": "sec:verificationMethod", "@type": "@id" }, - "verifyData": "sec:verifyData", - "wrappedKey": "sec:wrappedKey" - } - ] + "@context": [ + {"@version": 1.1}, + "https://w3id.org/security/v1", + { + "AesKeyWrappingKey2019": "sec:AesKeyWrappingKey2019", + "DeleteKeyOperation": "sec:DeleteKeyOperation", + "DeriveSecretOperation": "sec:DeriveSecretOperation", + "EcdsaSecp256k1Signature2019": "sec:EcdsaSecp256k1Signature2019", + "EcdsaSecp256r1Signature2019": "sec:EcdsaSecp256r1Signature2019", + "EcdsaSecp256k1VerificationKey2019": "sec:EcdsaSecp256k1VerificationKey2019", + "EcdsaSecp256r1VerificationKey2019": "sec:EcdsaSecp256r1VerificationKey2019", + "Ed25519Signature2018": "sec:Ed25519Signature2018", + "Ed25519VerificationKey2018": "sec:Ed25519VerificationKey2018", + "EquihashProof2018": "sec:EquihashProof2018", + "ExportKeyOperation": "sec:ExportKeyOperation", + "GenerateKeyOperation": "sec:GenerateKeyOperation", + "KmsOperation": "sec:KmsOperation", + "RevokeKeyOperation": "sec:RevokeKeyOperation", + "RsaSignature2018": "sec:RsaSignature2018", + "RsaVerificationKey2018": "sec:RsaVerificationKey2018", + "Sha256HmacKey2019": "sec:Sha256HmacKey2019", + "SignOperation": "sec:SignOperation", + "UnwrapKeyOperation": "sec:UnwrapKeyOperation", + "VerifyOperation": "sec:VerifyOperation", + "WrapKeyOperation": "sec:WrapKeyOperation", + "X25519KeyAgreementKey2019": "sec:X25519KeyAgreementKey2019", + "allowedAction": "sec:allowedAction", + "assertionMethod": { + "@id": "sec:assertionMethod", + "@type": "@id", + "@container": "@set", + }, + "authentication": { + "@id": "sec:authenticationMethod", + "@type": "@id", + "@container": "@set", + }, + "capability": {"@id": "sec:capability", "@type": "@id"}, + "capabilityAction": "sec:capabilityAction", + "capabilityChain": { + "@id": "sec:capabilityChain", + "@type": "@id", + "@container": "@list", + }, + "capabilityDelegation": { + "@id": "sec:capabilityDelegationMethod", + "@type": "@id", + "@container": "@set", + }, + "capabilityInvocation": { + "@id": "sec:capabilityInvocationMethod", + "@type": "@id", + "@container": "@set", + }, + "caveat": {"@id": "sec:caveat", "@type": "@id", "@container": "@set"}, + "challenge": "sec:challenge", + "ciphertext": "sec:ciphertext", + "controller": {"@id": "sec:controller", "@type": "@id"}, + "delegator": {"@id": "sec:delegator", "@type": "@id"}, + "equihashParameterK": { + "@id": "sec:equihashParameterK", + "@type": "xsd:integer", + }, + "equihashParameterN": { + "@id": "sec:equihashParameterN", + "@type": "xsd:integer", + }, + "invocationTarget": {"@id": "sec:invocationTarget", "@type": "@id"}, + "invoker": {"@id": "sec:invoker", "@type": "@id"}, + "jws": "sec:jws", + "keyAgreement": { + "@id": "sec:keyAgreementMethod", + "@type": "@id", + "@container": "@set", + }, + "kmsModule": {"@id": "sec:kmsModule"}, + "parentCapability": {"@id": "sec:parentCapability", "@type": "@id"}, + "plaintext": "sec:plaintext", + "proof": {"@id": "sec:proof", "@type": "@id", "@container": "@graph"}, + "proofPurpose": {"@id": "sec:proofPurpose", "@type": "@vocab"}, + "proofValue": "sec:proofValue", + "referenceId": "sec:referenceId", + "unwrappedKey": "sec:unwrappedKey", + "verificationMethod": {"@id": "sec:verificationMethod", "@type": "@id"}, + "verifyData": "sec:verifyData", + "wrappedKey": "sec:wrappedKey", + }, + ] } diff --git a/aries_cloudagent/vc/vc_ld/issue.py b/aries_cloudagent/vc/vc_ld/issue.py index a394a24bc4..5060c2df44 100644 --- a/aries_cloudagent/vc/vc_ld/issue.py +++ b/aries_cloudagent/vc/vc_ld/issue.py @@ -1,3 +1,5 @@ +"""Verifiable Credential issuance methods.""" + from ..ld_proofs import ( LinkedDataProof, ProofPurpose, @@ -16,6 +18,27 @@ async def issue( document_loader: DocumentLoader, purpose: ProofPurpose = None, ) -> dict: + """Issue a verifiable credential. + + Takes the base credentail document, verifies it, and adds + a digital signature to it. + + Args: + credential (dict): Base credential document. + suite (LinkedDataProof): Signature suite to sign the credential with. + document_loader (DocumentLoader): Document loader to use + purpose (ProofPurpose, optional): A proof purpose instance that will match + proofs to be verified and ensure they were created according to the + appropriate purpose. Default to CredentialIssuancePurpose + + Raises: + LinkedDataProofException: When the credential has an invalid structure + OR signing fails + + Returns: + dict: The signed verifiable credential + + """ # Validate credential errors = CredentialSchema().validate(credential) if len(errors) > 0: @@ -23,9 +46,11 @@ async def issue( f"Credential contains invalid structure: {errors}" ) + # Set default proof purpose if not set if not purpose: purpose = CredentialIssuancePurpose() + # Sign the credential with LD proof signed_credential = await sign( document=credential, suite=suite, diff --git a/aries_cloudagent/vc/vc_ld/models/credential.py b/aries_cloudagent/vc/vc_ld/models/credential.py index 38085775c1..b43ffc7147 100644 --- a/aries_cloudagent/vc/vc_ld/models/credential.py +++ b/aries_cloudagent/vc/vc_ld/models/credential.py @@ -1,3 +1,5 @@ +"""Verifiable Credential model classes""" + from marshmallow import ValidationError import copy import json @@ -17,7 +19,7 @@ class LDProof: - """Linked Data Proof model""" + """Linked Data Proof model.""" def __init__( self, @@ -52,6 +54,7 @@ def deserialize(cls, proof: Union[dict, str]) -> "LDProof": Returns: LDProof: The deserialized LDProof object + """ if isinstance(proof, str): proof = json.loads(proof) @@ -64,6 +67,7 @@ def serialize(self) -> dict: Returns: dict: The LDProof serialized as dict. + """ schema = LinkedDataProofSchema() proof: dict = schema.dump(copy.deepcopy(self)) @@ -72,7 +76,7 @@ def serialize(self) -> dict: class VerifiableCredential: - """Verifiable Credential model""" + """Verifiable Credential model.""" def __init__( self, @@ -115,6 +119,7 @@ def deserialize( Returns: VerifiableCredential: The deserialized VerifiableCredential object + """ if isinstance(credential, str): credential = json.loads(credential) @@ -127,6 +132,7 @@ def serialize(self) -> dict: Returns: dict: The VerifiableCredential serialized as dict. + """ schema = VerifiableCredentialSchema() credential: dict = schema.dump(copy.deepcopy(self)) @@ -304,4 +310,4 @@ def proof(self): @proof.setter def proof(self, proof: LDProof): """Setter for proof.""" - self._proof = proof \ No newline at end of file + self._proof = proof diff --git a/aries_cloudagent/vc/vc_ld/models/credential_schema.py b/aries_cloudagent/vc/vc_ld/models/credential_schema.py index 119621ccad..464d40bd00 100644 --- a/aries_cloudagent/vc/vc_ld/models/credential_schema.py +++ b/aries_cloudagent/vc/vc_ld/models/credential_schema.py @@ -1,3 +1,5 @@ +"""Verifiable Credential marshmallow schema classes.""" + from marshmallow import INCLUDE, fields, post_load, post_dump from ....messaging.models.base import Schema @@ -17,7 +19,7 @@ class LinkedDataProofSchema(Schema): - """Linked data proof schema + """Linked data proof schema. Based on https://w3c-ccg.github.io/ld-proofs @@ -30,7 +32,10 @@ class Meta: type = fields.Str( required=True, - description="Identifies the digital signature suite that was used to create the signature", + description=( + "Identifies the digital signature suite" + " that was used to create the signature" + ), example="Ed25519Signature2018", ) @@ -45,13 +50,19 @@ class Meta: data_key="verificationMethod", required=True, description="Information used for proof verification", - example="did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + example=( + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + "#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ), validate=Uri(), ) created = fields.Str( required=True, - description="The string value of an ISO8601 combined date and time string generated by the Signature Algorithm", + description=( + "The string value of an ISO8601 combined date" + " and time string generated by the Signature Algorithm" + ), **INDY_ISO8601_DATETIME, ) @@ -64,35 +75,46 @@ class Meta: challenge = fields.Str( required=False, - description="Associates a challenge with a proof, for use with a proofPurpose such as authentication", + description=( + "Associates a challenge with a proof, for use" + " with a proofPurpose such as authentication" + ), example=UUIDFour.EXAMPLE, ) jws = fields.Str( required=False, description="Associates a Detached Json Web Signature with a proof", - example="eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..lKJU0Df_keblRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQKBhQDxvXNo7nvtUBb_Eq1Ch6YBKY7UBAjg6iBX5qBQ", + example=( + "eyJhbGciOiAiRWREUc2UsICJjcml0IjogWyJiNjQiXX0..lKJU0Df_ke" + "blRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQ1Ch6YBKY7UBAjg6iBX5qBQ" + ), ) proofValue = fields.Str( required=False, description="The proof value of a proof", - example="z76WGJzY2rXtSiZ8BDwU4VgcLqcMEm2dXdgVVS1QCZQUptZ5P8n5YCcnbuMUASYhVNihae7m8VeYvfViYf2KqTMVEH1BKNF6Xc5S2kPpBwsNos6egnrmDMxhtQppZjb47Mi2xG89jZm654uZUatDvfTCoDWuethfRHPSk81qn6od9zGxBxxAYyUPnY9Fs9QEQETm53AN9uk6erSAhJ2R3K8rosrBkSZbVhbzUJTPg22wpddVY8Xu3vhRVNpzyUvCEedg5EM6i7wE4G1CYsz7tbaApEF9aFRB92v4DoiY5GXGjwH5PhhGstJB9ySh9FyDfSYN8qRVVR7i5No2eBi3AjQ7cqaBiWkoSrCoQK7jJ4PyFsu3ZaAuUx8LAtkhaChmwfxH8E25LcTENJhFxqVnPd7f7Q3cUrFciYRqmg8eJsy1AahqbzJQ63n9RtekmwzqnMYrTwft6cLJJGeTSSxCCJ6HKnRtwE7jjDh6sB2ZeVj494VppdAVJBz2AAiZY9BBnCD8wUVgwqH3qchGRCuC2RugA4eQ9fUrR4Yuycac3caiaaay", + example=( + "sy1AahqbzJQ63n9RtekmwzqZeVj494VppdAVJBnMYrTwft6cLJJGeTSSxCCJ6HKnRtwE7" + "jjDh6sB2z2AAiZY9BBnCD8wUVgwqH3qchGRCuC2RugA4eQ9fUrR4Yuycac3caiaaay" + ), ) @post_load def make_proof(self, data, **kwargs): + """Create proof model from dict using schema.""" from .credential import LDProof return LDProof(**data) @post_dump def remove_none_values(self, data, **kwargs): + """Remove None values from dict before outputting.""" return {key: value for key, value in data.items() if value} class CredentialSchema(Schema): - """Linked data credential schema + """Linked data credential schema. Does not include proof. Based on https://www.w3.org/TR/vc-data-model @@ -153,19 +175,22 @@ class Meta: **CREDENTIAL_SUBJECT, ) + # TODO: this is probably not necessary if we use the `BaseModel` @post_load def make_credential(self, data, **kwargs): + """Remove None values from dict before outputting.""" from .credential import VerifiableCredential return VerifiableCredential(**data) @post_dump def remove_none_values(self, data, **kwargs): + """Remove None values from dict before outputting.""" return {key: value for key, value in data.items() if value} class VerifiableCredentialSchema(CredentialSchema): - """Linked data verifiable credential schema + """Linked data verifiable credential schema. Based on https://www.w3.org/TR/vc-data-model @@ -177,9 +202,15 @@ class VerifiableCredentialSchema(CredentialSchema): description="The proof of the credential", example={ "type": "Ed25519Signature2018", - "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + "verificationMethod": ( + "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyG" + "o38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + ), "created": "2019-12-11T03:50:55", "proofPurpose": "assertionMethod", - "jws": "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0IjogWyJiNjQiXX0..lKJU0Df_keblRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQKBhQDxvXNo7nvtUBb_Eq1Ch6YBKY7UBAjg6iBX5qBQ", + "jws": ( + "eyJhbGciOiAiRWREU0EiLCAiYjY0IjogZmFsc2UsICJjcml0JiNjQiXX0..lKJU0Df" + "_keblRKhZAS9Qq6zybm-HqUXNVZ8vgEPNTAjQKBhQDxvXNo7nvtUBb_Eq1Ch6YBKY5qBQ" + ), }, - ) \ No newline at end of file + ) diff --git a/aries_cloudagent/vc/vc_ld/prove.py b/aries_cloudagent/vc/vc_ld/prove.py index 4f3cbd8f5b..71a45dc1fe 100644 --- a/aries_cloudagent/vc/vc_ld/prove.py +++ b/aries_cloudagent/vc/vc_ld/prove.py @@ -1,3 +1,5 @@ +"""Verifiable Credential and Presentation proving methods.""" + from typing import List diff --git a/aries_cloudagent/vc/vc_ld/validation_result.py b/aries_cloudagent/vc/vc_ld/validation_result.py index 427cfa5d49..9857ba94b8 100644 --- a/aries_cloudagent/vc/vc_ld/validation_result.py +++ b/aries_cloudagent/vc/vc_ld/validation_result.py @@ -6,7 +6,7 @@ class PresentationVerificationResult: - """Presentation verification result class""" + """Presentation verification result class.""" def __init__( self, @@ -16,7 +16,7 @@ def __init__( credential_results: List[DocumentVerificationResult] = None, errors: List[Exception] = None, ) -> None: - """Create new PresentationVerificationResult instance""" + """Create new PresentationVerificationResult instance.""" self.verified = verified self.presentation_result = presentation_result self.credential_results = credential_results @@ -61,4 +61,4 @@ def __eq__(self, other: object) -> bool: ) ) ) - return False \ No newline at end of file + return False diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index 57fc13950d..6f6e277f24 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -25,7 +25,7 @@ async def _verify_credential( document_loader: DocumentLoader, purpose: ProofPurpose = None, ) -> DocumentVerificationResult: - """Verify credential structure, proof purpose and signature""" + """Verify credential structure, proof purpose and signature.""" # Validate credential structure errors = VerifiableCredentialSchema().validate(credential) @@ -54,7 +54,7 @@ async def verify_credential( document_loader: DocumentLoader, purpose: ProofPurpose = None, ) -> DocumentVerificationResult: - """Verify credential structure, proof purpose and signature + """Verify credential structure, proof purpose and signature. Args: credential (dict): The credential to verify @@ -89,7 +89,7 @@ async def _verify_presentation( domain: str = None, purpose: ProofPurpose = None, ): - """Verify presentation structure, credentials, proof purpose and signature""" + """Verify presentation structure, credentials, proof purpose and signature.""" if not purpose and not challenge: raise LinkedDataProofException( @@ -148,7 +148,7 @@ async def verify_presentation( challenge: str = None, domain: str = None, ) -> PresentationVerificationResult: - """Verify presentation structure, credentials, proof purpose and signature + """Verify presentation structure, credentials, proof purpose and signature. Args: presentation (dict): The presentation to verify @@ -164,6 +164,7 @@ async def verify_presentation( Returns: PresentationVerificationResult: The result of the verification. Verified property indicates whether the verification was successful + """ # TODO: I think we should add some sort of options to authenticate the subject id diff --git a/aries_cloudagent/wallet/crypto.py b/aries_cloudagent/wallet/crypto.py index 641010cce1..5acba82acd 100644 --- a/aries_cloudagent/wallet/crypto.py +++ b/aries_cloudagent/wallet/crypto.py @@ -29,6 +29,8 @@ class KeyTypeException(BaseException): class KeyType(Enum): + """KeyType Enum specifying key types with multicodec name""" + ED25519 = KeySpec("ed25519", "ed25519-pub") X25519 = KeySpec("x25519", "x25519-pub") BLS12381G1 = KeySpec("bls12381g1", "bls12_381-g1-pub") @@ -37,14 +39,17 @@ class KeyType(Enum): @property def key_type(self) -> str: + """Getter for key type identifier""" return self.value.key_type @property def multicodec_name(self) -> str: + """Getter for multicodec name""" return self.value.multicodec_name @classmethod def from_multicodec_name(cls, multicodec_name: str) -> Optional["KeyType"]: + """Get KeyType instance based on multicoded name. Returns None if not found.""" for key_type in KeyType: if key_type.multicodec_name == multicodec_name: return key_type @@ -53,6 +58,7 @@ def from_multicodec_name(cls, multicodec_name: str) -> Optional["KeyType"]: @classmethod def from_key_type(cls, key_type: str) -> Optional["KeyType"]: + """Get KeyType instance from the key type identifier.""" for _key_type in KeyType: if _key_type.key_type == key_type: return _key_type @@ -70,21 +76,30 @@ def from_key_type(cls, key_type: str) -> Optional["KeyType"]: class DIDMethod(Enum): + """DID Method class specifying DID methods with supported key types.""" + SOV = DIDMethodSpec("sov", [KeyType.ED25519]) KEY = DIDMethodSpec("key", [KeyType.ED25519, KeyType.BLS12381G2]) @property def method_name(self) -> str: + """Getter for did method name. e.g. sov or key""" return self.value.method_name @property def supported_key_types(self) -> List[KeyType]: + """Getter for supported key types of method""" return self.value.supported_key_types def supports_key_type(self, key_type: KeyType) -> bool: + """Check whether the current method supports the key type""" return key_type in self.supported_key_types def from_metadata(metadata: Mapping) -> "DIDMethod": + """Get DID method instance from metadata object. + + Returns SOV if no metadata was found for backwards compatability. + """ method = metadata.get("method") # extract from metadata object @@ -97,6 +112,7 @@ def from_metadata(metadata: Mapping) -> "DIDMethod": return DIDMethod.SOV def from_method(method: str) -> Optional["DIDMethod"]: + """Get DID method instance from the method name""" for did_method in DIDMethod: if method == did_method.method_name: return did_method diff --git a/aries_cloudagent/wallet/in_memory.py b/aries_cloudagent/wallet/in_memory.py index d5791399fd..239ed29c79 100644 --- a/aries_cloudagent/wallet/in_memory.py +++ b/aries_cloudagent/wallet/in_memory.py @@ -185,7 +185,8 @@ async def create_local_did( # validate key_type if not method.supports_key_type(key_type): raise WalletError( - f"Invalid key type {key_type.key_type} for did method f{method.method_name}" + f"Invalid key type {key_type.key_type}" + f" for did method {method.method_name}" ) verkey, secret = create_keypair(seed) diff --git a/aries_cloudagent/wallet/indy.py b/aries_cloudagent/wallet/indy.py index ed9482ad15..56f5f1939f 100644 --- a/aries_cloudagent/wallet/indy.py +++ b/aries_cloudagent/wallet/indy.py @@ -209,7 +209,8 @@ async def create_local_did( # validate key_type if not method.supports_key_type(key_type): raise WalletError( - f"Invalid key type {key_type.key_type} for did method f{method.method_name}" + f"Invalid key type {key_type.key_type}" + f" for did method {method.method_name}" ) cfg = {} diff --git a/aries_cloudagent/wallet/routes.py b/aries_cloudagent/wallet/routes.py index 43a77a9650..9357071878 100644 --- a/aries_cloudagent/wallet/routes.py +++ b/aries_cloudagent/wallet/routes.py @@ -303,7 +303,7 @@ async def wallet_create_did(request: web.BaseRequest): try: body = await request.json() - except: + except Exception: body = {} key_type = ( diff --git a/aries_cloudagent/wallet/util.py b/aries_cloudagent/wallet/util.py index ed13c9c8f9..1963242331 100644 --- a/aries_cloudagent/wallet/util.py +++ b/aries_cloudagent/wallet/util.py @@ -65,4 +65,4 @@ def full_verkey(did: str, abbr_verkey: str) -> str: bytes_to_b58(b58_to_bytes(did.split(":")[-1]) + b58_to_bytes(abbr_verkey[1:])) if abbr_verkey.startswith("~") else abbr_verkey - ) \ No newline at end of file + ) From 46a8e8ba3aff488c06061473edca64fe11f10580 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sat, 27 Mar 2021 18:33:42 +0100 Subject: [PATCH 050/138] more formatting, docs, flake, black Signed-off-by: Timo Glastra --- aries_cloudagent/vc/ld_proofs/ProofSet.py | 95 +++++++++++++++---- aries_cloudagent/vc/ld_proofs/__init__.py | 1 + .../ld_proofs/suites/BbsBlsSignature2020.py | 61 ++++++++---- .../suites/BbsBlsSignatureProof2020.py | 59 +++++++----- .../ld_proofs/suites/LinkedDataSignature.py | 69 ++++++++------ 5 files changed, 195 insertions(+), 90 deletions(-) diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py index 20c571038d..ca7cd65416 100644 --- a/aries_cloudagent/vc/ld_proofs/ProofSet.py +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -1,4 +1,4 @@ -"""Class to represent a linked data proof set.""" +"""Class to represent a Linked Data proof set.""" from typing import List, Union from pyld.jsonld import JsonLdProcessor @@ -12,6 +12,8 @@ class ProofSet: + """Class for managing proof sets on a JSON-LD document.""" + @staticmethod async def add( *, @@ -19,18 +21,41 @@ async def add( suite: LinkedDataProof, purpose: ProofPurpose, document_loader: DocumentLoader, + # TODO: add expandContext property ) -> dict: - """Add a proof to the document.""" + """Add a Linked Data proof to the document. + + If the document contains other proofs, the proof will be appended + to the existing set of proofs. + + Important note: This method assumes that the term `proof` in the given + document has the same definition as the `https://w3id.org/security/v2` + JSON-LD @context. + + Args: + document (dict): JSON-LD document to be signed. + suite (LinkedDataProof): A signature suite instance that will create the proof + purpose (ProofPurpose): A proof purpose instance that will augment the proof + with information describing its intended purpose. + document_loader (DocumentLoader): Document loader to use. + Returns: + dict: The signed document, with the signature in the top-level + `proof` property. + + """ + # Shallow copy document to allow removal of existing proofs input = document.copy() input.pop("proof", None) + # create the new proof, suites MUST output a proof using security-v2 `@context` proof = await suite.create_proof( document=input, purpose=purpose, document_loader=document_loader ) # remove context from proof, if it exists - proof.pop("@context", None) + # TODO: not needed anymore? + # proof.pop("@context", None) JsonLdProcessor.add_value(document, "proof", proof) return document @@ -43,14 +68,39 @@ async def verify( purpose: ProofPurpose, document_loader: DocumentLoader, ) -> DocumentVerificationResult: - """Verify proof on the document""" + """Verify Linked Data proof(s) on a document. + + The proofs to be verified must match the given proof purse. + + Important note: This method assumes that the term `proof` in the given + document has the same definition as the `https://w3id.org/security/v2` + JSON-LD @context. + + Args: + document (dict): JSON-LD document with one or more proofs to be verified. + suites (List[LinkedDataProof]): Acceptable signature suite instances + for verifying the proof(s). + purpose (ProofPurpose): A proof purpose instance that will match proofs + to be verified and ensure they were created according to the + appropriate purpose. + document_loader (DocumentLoader): Document loader to use. + + Returns: + DocumentVerificationResult: Object with a `verified` property that is `true` + if at least one proof matching the given purpose and suite verifies + and `false` otherwise. Also contains `errors` and `results` properties + with extra data. + + """ try: + # Shallow copy document to allow removal of proof property without + # modifying external document input = document.copy() if len(suites) == 0: raise LinkedDataProofException("At least one suite is required.") - # Get proof set, remove proof from document + # Get proofs from document, remove proof property proof_set = await ProofSet._get_proofs(document=input) input.pop("proof", None) @@ -62,10 +112,13 @@ async def verify( document_loader=document_loader, ) + # If no proofs were verified because of no matching suites and purposes + # throw an error if len(results) == 0: suite_names = ", ".join([suite.signature_type for suite in suites]) raise LinkedDataProofException( - f"Could not verify any proofs; no proofs matched the required suites ({suite_names}) and purpose ({purpose.term})" + f"Could not verify any proofs; no proofs matched the required" + f" suites ({suite_names}) and purpose ({purpose.term})" ) # check if all results are valid, create result @@ -98,26 +151,34 @@ async def derive( document_loader: DocumentLoader, nonce: bytes = None, ) -> dict: - """Derive proof(s), return derived document + """Create new derived Linked Data proof(s) on document using the reveal document. + + Important note: This method assumes that the term `proof` in the given + document has the same definition as the `https://w3id.org/security/v2` + JSON-LD @context. (v3 because BBS?) Args: - document (dict): The document to derive the proof for - reveal_document (dict): The JSON-LD frame specifying the revealed attributes - suite (LinkedDataProof): The suite to derive the proof with - document_loader (DocumentLoader): Document loader used for resolving + document (dict): JSON-LD document with one or more proofs to be derived. + reveal_document (dict): JSON-LD frame specifying the attributes to reveal. + suite (LinkedDataProof): A signature suite instance to derive the proof. + document_loader (DocumentLoader): Document loader to use. nonce (bytes, optional): Nonce to use for the proof. Defaults to None. Returns: - dict: The document with derived proofs + dict: The derived document with the derived proof(s) in the top-level + `proof` property. + """ + # Shallow copy document to allow removal of existing proofs input = document.copy() + # Check if suite supports derivation if not suite.supported_derive_proof_types: raise LinkedDataProofException( f"{suite.signature_type} does not support derivation" ) - # Get proof set, remove proof from document + # Get proofs, remove proof from document proof_set = await ProofSet._get_proofs( document=input, proof_types=suite.supported_derive_proof_types ) @@ -131,6 +192,7 @@ async def derive( document_loader=document_loader, nonce=nonce, ) + # TODO: I think this is also not needed anymore then? derived_proof["proof"].pop("@context", None) if len(proof_set) > 1: @@ -145,7 +207,8 @@ async def derive( reveal_document=reveal_document, document_loader=document_loader, ) - additional_derived_proof["proof"].pop("@context", None) + # TODO: also not needed anymore? + # additional_derived_proof["proof"].pop("@context", None) derived_proof["proof"].append(additional_derived_proof["proof"]) JsonLdProcessor.add_value( @@ -158,7 +221,7 @@ async def derive( async def _get_proofs( document: dict, proof_types: Union[List[str], None] = None ) -> list: - "Get proof set from document, optionally filtered by proof_types" "" + """Get proof set from document, optionally filtered by proof_types.""" proof_set = JsonLdProcessor.get_values(document, "proof") # If proof_types is present, only take proofs that match @@ -196,7 +259,7 @@ async def _verify( # Only proofs with a `proofPurpose` that match the purpose are verified # e.g.: # purpose = {term = 'assertionMethod'} - # proof_set = [ { proofPurpose: 'assertionMethod' }, { proofPurpose: 'anotherPurpose' }] + # proof_set = [{proofPurpose:'assertionMethod'},{proofPurpose: 'another'}] # return = [ { proofPurpose: 'assertionMethod' } ] matches = [proof for proof in proof_set if purpose.match(proof)] diff --git a/aries_cloudagent/vc/ld_proofs/__init__.py b/aries_cloudagent/vc/ld_proofs/__init__.py index 0519ab5501..2d0ca2ce7d 100644 --- a/aries_cloudagent/vc/ld_proofs/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/__init__.py @@ -23,6 +23,7 @@ __all__ = [ sign, verify, + derive, ProofSet, # Proof purposes ProofPurpose, diff --git a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py index f050f4f4bb..ff8fa652bc 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py +++ b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py @@ -1,6 +1,5 @@ -"""BbsBlsSignature2020 class""" +"""BbsBlsSignature2020 class.""" -from pyld import jsonld from datetime import datetime from typing import List, Union @@ -11,21 +10,30 @@ from ..validation_result import ProofResult from ..document_loader import DocumentLoader from ..purposes import ProofPurpose -from ..constants import SECURITY_CONTEXT_URL class BbsBlsSignature2020(BbsBlsSignature2020Base): - """BbsBlsSignature2020 class""" + """BbsBlsSignature2020 class.""" def __init__( self, *, key_pair: KeyPair, - verification_method: str = None, proof: dict = None, + verification_method: str = None, date: Union[datetime, None] = None, ): - """Create new BbsBlsSignature2020 instance""" + """Create new BbsBlsSignature2020 instance. + + Args: + key_pair (KeyPair): Key pair to use. Must provide BBS signatures + proof (dict, optional): A JSON-LD document with options to use for the + `proof` node (e.g. any other custom fields can be provided here + using a context different from security-v2). + verification_method (str, optional): A key id URL to the paired public key. + date (datetime, optional): Signing date to use. Defaults to now + + """ super().__init__(signature_type="BbsBlsSignature2020", proof=proof) self.key_pair = key_pair self.verification_method = verification_method @@ -34,33 +42,34 @@ def __init__( async def create_proof( self, *, document: dict, purpose: ProofPurpose, document_loader: DocumentLoader ) -> dict: - """Create proof for document, return proof""" - proof = None - if self.proof: - proof = jsonld.compact( - self.proof, SECURITY_CONTEXT_URL, {"documentLoader": document_loader} - ) - else: - proof = {"@context": SECURITY_CONTEXT_URL} + """Create proof for document, return proof.""" + proof = self.proof.copy() if self.proof else {} proof["type"] = self.signature_type proof["verificationMethod"] = self.verification_method - if not self.date: - self.date = datetime.now() - + # Set created if not already set if not proof.get("created"): - proof["created"] = self.date.isoformat() + # Use class date, or now + date = self.date or datetime.now() + proof["created"] = date.isoformat() + # Allow purpose to update the proof; the `proof` is in the + # SECURITY_CONTEXT_URL `@context` -- therefore the `purpose` must + # ensure any added fields are also represented in that same `@context` proof = purpose.update(proof) + # Create statements to sign verify_data = self._create_verify_data( proof=proof, document=document, document_loader=document_loader ) + # Encode statements as bytes verify_data = list(map(lambda item: item.encode("utf-8"), verify_data)) + # Sign statements proof = await self.sign(verify_data=verify_data, proof=proof) + return proof async def verify_proof( @@ -73,13 +82,20 @@ async def verify_proof( ) -> ProofResult: """Verify proof against document and proof purpose.""" try: + # Create statements to verify verify_data = self._create_verify_data( proof=proof, document=document, document_loader=document_loader ) + + # Encode statements as bytes + verify_data = list(map(lambda item: item.encode("utf-8"), verify_data)) + + # Fetch verification method verification_method = self._get_verification_method( proof=proof, document_loader=document_loader ) + # Verify signature on data verified = await self.verify_signature( verify_data=verify_data, verification_method=verification_method, @@ -87,12 +103,12 @@ async def verify_proof( proof=proof, document_loader=document_loader, ) - if not verified: raise LinkedDataProofException( f"Invalid signature on document {document}" ) + # Ensure proof was performed for a valid purpose purpose_result = purpose.validate( proof=proof, document=document, @@ -118,6 +134,7 @@ def _create_verify_data( """Create verification data. Returns a list of canonized statements + """ proof_statements = self._create_verify_proof_data( proof=proof, document_loader=document_loader @@ -139,7 +156,7 @@ def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None return self._canonize(input=proof, document_loader=document_loader) async def sign(self, *, verify_data: List[bytes], proof: dict) -> dict: - """Sign the data and add it to the proof + """Sign the data and add it to the proof. Args: verify_data (List[bytes]): The data to sign. @@ -147,6 +164,7 @@ async def sign(self, *, verify_data: List[bytes], proof: dict) -> dict: Returns: dict: The proof object with the added signature + """ signature = await self.key_pair.sign(verify_data) @@ -176,6 +194,7 @@ async def verify_signature( Returns: bool: Whether the signature is valid for the data + """ if not (isinstance(proof.get("proofValue"), str)): @@ -192,4 +211,4 @@ async def verify_signature( if not key_pair.has_public_key: key_pair = key_pair.from_verification_method(verification_method) - return await self.key_pair.verify(verify_data, signature) \ No newline at end of file + return await self.key_pair.verify(verify_data, signature) diff --git a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py index c4a57f0489..6fa38792a7 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py +++ b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py @@ -1,4 +1,4 @@ -"""BbsBlsSignatureProof2020 class""" +"""BbsBlsSignatureProof2020 class.""" from os import urandom from pyld import jsonld @@ -14,25 +14,32 @@ from ..validation_result import ProofResult from ..document_loader import DocumentLoader from ..purposes import ProofPurpose -from ..constants import SECURITY_CONTEXT_URL, SECURITY_CONTEXT_V3_URL +from ..constants import SECURITY_CONTEXT_V3_URL class BbsBlsSignatureProof2020(BbsBlsSignature2020Base): - """BbsBlsSignatureProof2020 class""" + """BbsBlsSignatureProof2020 class.""" def __init__( self, *, key_pair: KeyPair, ): - """Create new BbsBlsSignatureProof2020 instance""" + """Create new BbsBlsSignatureProof2020 instance. + + Args: + key_pair (KeyPair): Key pair to use. Must provide BBS signatures + + """ super().__init__( signature_type="BbsBlsSignatureProof2020", proof={ "@context": SECURITY_CONTEXT_V3_URL, "type": "BbsBlsSignatureProof2020", }, - supported_derive_proof_types=BbsBlsSignatureProof2020.supported_derive_proof_types, + supported_derive_proof_types=( + BbsBlsSignatureProof2020.supported_derive_proof_types + ), ) self.key_pair = key_pair self.mapped_derived_proof_type = "https://w3id.org/security#BbsBlsSignature2020" @@ -46,13 +53,13 @@ async def derive_proof( document_loader: DocumentLoader, nonce: bytes = None, ): - """Derive proof for document, return dict with derived document and proof""" + """Derive proof for document, return dict with derived document and proof.""" # Validate that the input proof document has a proof compatible with this suite if proof.get("type") not in self.supported_derive_proof_types: raise LinkedDataProofException( - f"Proof document proof incompatible, expected proof types of {self.supported_derive_proof_types}, received " - + proof["type"] + f"Proof document proof incompatible, expected proof types of" + f" {self.supported_derive_proof_types}, received " + proof["type"] ) # Extract the BBS signature from the input proof @@ -65,16 +72,9 @@ async def derive_proof( suite = BbsBlsSignature2020(key_pair=self.key_pair) # Initialize the derived proof - derived_proof = None - if self.proof: - # Use proof JSON-LD document passed to API - derived_proof = jsonld.compact( - self.proof, SECURITY_CONTEXT_URL, {"documentLoader": document_loader} - ) - else: - # create proof JSON-LD document - derived_proof = {"@context": SECURITY_CONTEXT_URL} + derived_proof = self.proof.copy() or {} + # Ensure proof type is set derived_proof["type"] = self.signature_type # Get the input document and proof statements @@ -108,11 +108,11 @@ async def derive_proof( # Canonicalize the resulting reveal document reveal_document_statements = suite._create_verify_document_data( - reveal_document_result, {"document_loader": document_loader} + document=reveal_document_result, document_loader=document_loader ) - # Get the indices of the revealed statements from the transformed input document offset - # by the number of proof statements + # Get the indices of the revealed statements from the transformed input document + # offset by the number of proof statements number_of_proof_statements = len(proof_statements) # Always reveal all the statements associated to the original proof @@ -155,10 +155,12 @@ async def derive_proof( ) ) + # Fetch the verification method verification_method = self._get_verification_method( proof=proof, document_loader=document_loader ) + # Create key pair from public key in verification method key_pair = self.key_pair.from_verification_method(verification_method) # Compute the proof @@ -196,7 +198,7 @@ async def verify_proof( ) -> ProofResult: """Verify proof against document and proof purpose.""" try: - + # TODO: I'm not sure why we use the base signature (BbsBlsSignature2020) here proof["type"] = self.mapped_derived_proof_type # Get the proof and document statements @@ -262,7 +264,7 @@ async def verify_proof( return ProofResult(verified=False, error=err) def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None): - """Canonize proof dictionary. Removes proofValue""" + """Canonize proof dictionary. Removes proofValue.""" proof = proof.copy() proof.pop("proofValue", None) @@ -273,7 +275,7 @@ def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None def _transform_blank_node_ids_into_placeholder_node_ids( statements: List[str], ) -> List[str]: - """Transform any blank node identifiers for the input into actual node identifiers + """Transform blank node identifiers for the input into actual node identifiers. e.g _:c14n0 => urn:bnid:_:c14n0 @@ -282,6 +284,7 @@ def _transform_blank_node_ids_into_placeholder_node_ids( Returns: List[str]: List of transformed output statements + """ transformed_statements = [] @@ -304,7 +307,7 @@ def _transform_blank_node_ids_into_placeholder_node_ids( def _transform_placeholder_node_ids_into_blank_node_ids( statements: List[str], ) -> List[str]: - """Transform the blank node placeholder identifiers back into actual blank nodes + """Transform the blank node placeholder identifiers back into actual blank nodes. e.g urn:bnid:_:c14n0 => _:c14n0 @@ -313,6 +316,7 @@ def _transform_placeholder_node_ids_into_blank_node_ids( Returns: List[str]: List of transformed output statements + """ transformed_statements = [] @@ -323,9 +327,12 @@ def _transform_placeholder_node_ids_into_blank_node_ids( prefix_index = statement.index(prefix_string) closing_index = statement.index(">", prefix_index) + urn_id_close = closing_index + 1 # > + urn_id_prefix_end = prefix_index + len(prefix_string) # dict: - """Sign the data and add it to the proof + """Sign the data and add it to the proof. Args: verify_data (bytes): The data to sign. @@ -40,6 +52,7 @@ async def sign(self, *, verify_data: bytes, proof: dict) -> dict: Returns: dict: The proof object with the added signature + """ pass @@ -64,43 +77,38 @@ async def verify_signature( Returns: bool: Whether the signature is valid for the data + """ async def create_proof( self, *, document: dict, purpose: ProofPurpose, document_loader: DocumentLoader ) -> dict: - """Create proof for document, return proof""" - proof = None - if self.proof: - # TODO remove hardcoded security context - # TODO verify if the other optional params shown in jsonld-signatures are - # required - # TODO: digitalbazaar changed this implementation after we wrote it. Should - # double check to make sure we're doing it correctly - # https://github.com/digitalbazaar/jsonld-signatures/commit/2c98a2fb626b85e31d16b16e7ea6a90fd83534c5 - proof = jsonld.compact( - self.proof, SECURITY_CONTEXT_URL, {"documentLoader": document_loader} - ) - else: - proof = {"@context": SECURITY_CONTEXT_URL} + """Create proof for document, return proof.""" + proof = self.proof.copy() if self.proof else {} # TODO: validate if verification_method is set? proof["type"] = self.signature_type proof["verificationMethod"] = self.verification_method - if not self.date: - self.date = datetime.now() - + # Set created if not already set if not proof.get("created"): - proof["created"] = self.date.isoformat() + # Use class date, or now + date = self.date or datetime.now() + proof["created"] = date.isoformat() + # Allow purpose to update the proof; the `proof` is in the + # SECURITY_CONTEXT_URL `@context` -- therefore the `purpose` must + # ensure any added fields are also represented in that same `@context` proof = purpose.update(proof) + # Create data to sign verify_data = self._create_verify_data( proof=proof, document=document, document_loader=document_loader ) + # Sign data proof = await self.sign(verify_data=verify_data, proof=proof) + return proof async def verify_proof( @@ -113,13 +121,17 @@ async def verify_proof( ) -> ProofResult: """Verify proof against document and proof purpose.""" try: + # Create data to verify verify_data = self._create_verify_data( proof=proof, document=document, document_loader=document_loader ) + + # Fetch verification method verification_method = self._get_verification_method( proof=proof, document_loader=document_loader ) + # Verify signature on data verified = await self.verify_signature( verify_data=verify_data, verification_method=verification_method, @@ -127,12 +139,12 @@ async def verify_proof( proof=proof, document_loader=document_loader, ) - if not verified: raise LinkedDataProofException( f"Invalid signature on document {document}" ) + # Ensure proof was performed for a valid purpose purpose_result = purpose.validate( proof=proof, document=document, @@ -155,8 +167,9 @@ async def verify_proof( def _create_verify_data( self, *, proof: dict, document: dict, document_loader: DocumentLoader ) -> bytes: + """Create signing or verification data.""" c14n_proof_options = self._canonize_proof( - proof=proof, document_loader=document_loader + proof=proof, document=document, document_loader=document_loader ) c14n_doc = self._canonize(input=document, document_loader=document_loader) @@ -168,7 +181,7 @@ def _create_verify_data( ) def _canonize(self, *, input, document_loader: DocumentLoader = None) -> str: - """Canonize input document using URDNA2015 algorithm""" + """Canonize input document using URDNA2015 algorithm.""" # application/n-quads format always returns str return jsonld.normalize( input, @@ -179,9 +192,11 @@ def _canonize(self, *, input, document_loader: DocumentLoader = None) -> str: }, ) - def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None): + def _canonize_proof( + self, *, proof: dict, document: dict, document_loader: DocumentLoader = None + ): """Canonize proof dictionary. Removes jws, signature, etc...""" - proof = proof.copy() + proof = {"@context": proof.get("@context") or SECURITY_CONTEXT_URL, **proof} proof.pop("jws", None) proof.pop("signatureValue", None) From 088da2ac0ca302ebd09122fe54c3304558b89ea2 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sat, 27 Mar 2021 18:35:50 +0100 Subject: [PATCH 051/138] Use the document's context for proof before security context This means the document must either not have any context (so it will be automatically set), or it must include the appropriate context Signed-off-by: Timo Glastra --- aries_cloudagent/vc/ld_proofs/ProofSet.py | 7 +-- .../vc/ld_proofs/tests/test_doc.py | 59 +++++++++++-------- .../vc/vc_ld/tests/test_credential.py | 5 +- aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py | 41 ------------- requirements.dev.txt | 2 +- 5 files changed, 44 insertions(+), 70 deletions(-) diff --git a/aries_cloudagent/vc/ld_proofs/ProofSet.py b/aries_cloudagent/vc/ld_proofs/ProofSet.py index ca7cd65416..c076a2f691 100644 --- a/aries_cloudagent/vc/ld_proofs/ProofSet.py +++ b/aries_cloudagent/vc/ld_proofs/ProofSet.py @@ -233,10 +233,9 @@ async def _get_proofs( "No matching proofs found in the given document" ) - # TODO: digitalbazaar changed this to use the document context - # in jsonld-signatures. Does that mean we need to provide this ourselves? - # https://github.com/digitalbazaar/jsonld-signatures/commit/5046805653ea7db47540e5c9c77578d134a559e1 - proof_set = [{"@context": SECURITY_CONTEXT_URL, **proof} for proof in proof_set] + # Shallow copy proofs and add document context or SECURITY_CONTEXT_URL + context = document.get("@context") or SECURITY_CONTEXT_URL + proof_set = [{"@context": context, **proof} for proof in proof_set] return proof_set diff --git a/aries_cloudagent/vc/ld_proofs/tests/test_doc.py b/aries_cloudagent/vc/ld_proofs/tests/test_doc.py index a43d22d18b..f67a6da58a 100644 --- a/aries_cloudagent/vc/ld_proofs/tests/test_doc.py +++ b/aries_cloudagent/vc/ld_proofs/tests/test_doc.py @@ -1,24 +1,30 @@ from ..validation_result import DocumentVerificationResult, ProofResult, PurposeResult DOC_TEMPLATE = { - "@context": { - "schema": "http://schema.org/", - "name": "schema:name", - "homepage": "schema:url", - "image": "schema:image", - }, + "@context": [ + "https://w3id.org/security/v2", + { + "schema": "http://schema.org/", + "name": "schema:name", + "homepage": "schema:url", + "image": "schema:image", + }, + ], "name": "Manu Sporny", "homepage": "https://manu.sporny.org/", "image": "https://manu.sporny.org/images/manu.png", } DOC_SIGNED = { - "@context": { - "schema": "http://schema.org/", - "name": "schema:name", - "homepage": "schema:url", - "image": "schema:image", - }, + "@context": [ + "https://w3id.org/security/v2", + { + "schema": "http://schema.org/", + "name": "schema:name", + "homepage": "schema:url", + "image": "schema:image", + }, + ], "name": "Manu Sporny", "homepage": "https://manu.sporny.org/", "image": "https://manu.sporny.org/images/manu.png", @@ -34,12 +40,15 @@ DOC_VERIFIED = DocumentVerificationResult( verified=True, document={ - "@context": { - "schema": "http://schema.org/", - "name": "schema:name", - "homepage": "schema:url", - "image": "schema:image", - }, + "@context": [ + "https://w3id.org/security/v2", + { + "schema": "http://schema.org/", + "name": "schema:name", + "homepage": "schema:url", + "image": "schema:image", + }, + ], "name": "Manu Sporny", "homepage": "https://manu.sporny.org/", "image": "https://manu.sporny.org/images/manu.png", @@ -55,7 +64,15 @@ ProofResult( verified=True, proof={ - "@context": "https://w3id.org/security/v2", + "@context": [ + "https://w3id.org/security/v2", + { + "schema": "http://schema.org/", + "name": "schema:name", + "homepage": "schema:url", + "image": "schema:image", + }, + ], "proofPurpose": "assertionMethod", "created": "2019-12-11T03:50:55", "type": "Ed25519Signature2018", @@ -70,10 +87,6 @@ "assertionMethod": [ "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" ], - # FIXME: this should be authentication instead of sec:authenticationMethod - # SEE: https://github.com/w3c/did-spec-registries/issues/235 - # SEE: https://github.com/w3c-ccg/security-vocab/issues/91 - # "sec:authenticationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", "authentication": [ { "id": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", diff --git a/aries_cloudagent/vc/vc_ld/tests/test_credential.py b/aries_cloudagent/vc/vc_ld/tests/test_credential.py index 4d2daa831a..4aad268aad 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_credential.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_credential.py @@ -74,7 +74,10 @@ ProofResult( verified=True, proof={ - "@context": "https://w3id.org/security/v2", + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1", + ], "type": "Ed25519Signature2018", "verificationMethod": "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", "created": "2019-12-11T03:50:55", diff --git a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py index f3900dd95b..ffee319e37 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py @@ -103,47 +103,6 @@ async def test_create_presentation(self): assert presentation == PRESENTATION_SIGNED - async def test_create_presentation_same_subject(self): - # TODO: subject is holder proof purpose or something? - issuer_suite = Ed25519Signature2018( - verification_method=self.issuer_verification_method, - key_pair=Ed25519WalletKeyPair( - wallet=self.wallet, public_key_base58=self.issuer_key_info.verkey - ), - date=datetime.strptime("2020-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), - ) - - holder_did = DIDKey.from_public_key_b58( - self.holder_key_info.verkey, KeyType.ED25519 - ) - holder_verification_method = holder_did.key_id - - holder_suite = Ed25519Signature2018( - verification_method=holder_verification_method, - key_pair=Ed25519WalletKeyPair( - wallet=self.wallet, public_key_base58=self.holder_key_info.verkey - ), - date=datetime.strptime("2021-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), - ) - - credential_template = CREDENTIAL_TEMPLATE.copy() - credential_template["credentialSubject"]["id"] = holder_did.did - - issued = await issue( - credential=credential_template, - suite=issuer_suite, - document_loader=custom_document_loader, - ) - - unsigned_presentation = await create_presentation(credentials=[issued]) - - presentation = await sign_presentation( - presentation=unsigned_presentation, - suite=holder_suite, - document_loader=custom_document_loader, - challenge=self.presentation_challenge, - ) - async def test_verify_presentation(self): # TODO: verify with multiple suites suite = Ed25519Signature2018( diff --git a/requirements.dev.txt b/requirements.dev.txt index ea336a3551..792ac2d13b 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -4,7 +4,7 @@ pytest-asyncio==0.14.0 pytest-cov==2.10.1 pytest-flake8==1.0.6 -flake8==3.8.4 +flake8==3.9.0 # flake8-rst-docstrings==0.0.8 flake8-docstrings==1.5.0 flake8-rst==0.7.2 From 37c7100ec28d2eba5c0215f6b55eab55412fbade Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sun, 28 Mar 2021 00:04:42 +0100 Subject: [PATCH 052/138] error checks, docs, formatting, ... Signed-off-by: Timo Glastra --- .../issue_credential/v2_0/formats/handler.py | 23 +- .../v2_0/formats/indy/handler.py | 55 ++- .../v2_0/formats/ld_proof/handler.py | 405 +++++++++++------- .../formats/ld_proof/models/cred_detail.py | 7 +- .../v2_0/handlers/cred_request_handler.py | 10 +- .../issue_credential/v2_0/manager.py | 124 ++++-- .../v2_0/messages/cred_format.py | 18 +- .../v2_0/models/cred_ex_record.py | 2 +- .../v2_0/models/detail/indy.py | 8 + .../v2_0/models/detail/ld_proof.py | 19 +- .../protocols/issue_credential/v2_0/routes.py | 96 ++++- .../storage/vc_holder/vc_record.py | 98 ++--- .../purposes/AssertionProofPurpose.py | 6 +- .../purposes/AuthenticationProofPurpose.py | 6 +- .../ld_proofs/suites/Ed25519Signature2018.py | 4 +- aries_cloudagent/vc/vc_ld/__init__.py | 12 + aries_cloudagent/vc/vc_ld/models/__init__.py | 14 + 17 files changed, 573 insertions(+), 334 deletions(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py index 1e30a0a1d6..ae95a061cb 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py @@ -72,6 +72,7 @@ def get_format_identifier(self, message_type: str) -> str: Returns: str: Issue credential attachment format identifier + """ return ATTACHMENT_FORMAT[message_type][self.format.api] @@ -89,6 +90,7 @@ def get_format_data(self, message_type: str, data: dict) -> CredFormatAttachment Returns: CredFormatAttachment: Credential format and attachment data objects + """ return ( V20CredFormat( @@ -100,59 +102,58 @@ def get_format_data(self, message_type: str, data: dict) -> CredFormatAttachment @abstractclassmethod def validate_fields(cls, message_type: str, attachment_data: dict) -> None: - """Validate attachment data for specific message type and format""" + """Validate attachment data for specific message type and format.""" @abstractmethod async def create_proposal( self, cred_ex_record: V20CredExRecord, proposal_data: Mapping ) -> CredFormatAttachment: - """Format specific handler for creating credential proposal attachment format data""" + """Create format specific credential proposal attachment data.""" @abstractmethod async def receive_proposal( self, cred_ex_record: V20CredExRecord, cred_proposal_message: V20CredProposal ) -> None: - """Format specific handler for receiving credential proposal message""" + """Receive format specific credential proposal message.""" @abstractmethod async def create_offer( self, cred_ex_record: V20CredExRecord, offer_data: Mapping = None ) -> CredFormatAttachment: - """Format specific handler for creating credential offer attachment format data""" + """Create format specific credential offer attachment data.""" @abstractmethod async def receive_offer( self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer ) -> None: - """Format specific handler for receiving credential offer message""" + """Receive foramt specific credential offer message.""" @abstractmethod async def create_request( self, cred_ex_record: V20CredExRecord, request_data: Mapping = None ) -> CredFormatAttachment: - """Format specific handler for creating credential request attachment format data""" + """Create format specific credential request attachment data.""" @abstractmethod async def receive_request( self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest ) -> None: - """Format specific handler for receiving credential request message""" + """Receive format specific credential request message.""" @abstractmethod - # TODO: add issue_data or is this never needed? async def issue_credential( self, cred_ex_record: V20CredExRecord, retries: int = 5 ) -> CredFormatAttachment: - """Format specific handler for creating issue credential attachment format data""" + """Create format specific issue credential attachment data.""" @abstractmethod async def receive_credential( self, cred_ex_record: V20CredExRecord, cred_issue_message: V20CredIssue ) -> None: - """Format specific handler for receiving issue credential message""" + """Create format specific issue credential message.""" @abstractmethod async def store_credential( self, cred_ex_record: V20CredExRecord, cred_id: str = None ) -> None: - """Format specific handler for storing credential from issue credential message""" + """Store format specific credential from issue credential message.""" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py index 686694f2c4..682c0b8ea8 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py @@ -52,6 +52,23 @@ class IndyCredFormatHandler(V20CredFormatHandler): @classmethod def validate_fields(cls, message_type: str, attachment_data: Mapping): + """Validate attachment data for a specific message type. + + Uses marshmallow schemas to validate if format specific attachment data + is valid for the specified message type. Only does structural and type + checks, does not validate if .e.g. the issuer value is valid. + + + Args: + message_type (str): The message type to validate the attachment data for. + Should be one of the message types as defined in message_types.py + attachment_data (Mapping): [description] + The attachment data to valide + + Raises: + Exception: When the data is not valid. + + """ mapping = { CRED_20_PROPOSAL: CredDefQueryStringSchema, CRED_20_OFFER: IndyCredAbstractSchema, @@ -82,22 +99,29 @@ async def _match_sent_cred_def_id(self, tag_query: Mapping[str, str]) -> str: async def create_proposal( self, cred_ex_record: V20CredExRecord, filter: Mapping[str, str] ) -> Tuple[V20CredFormat, AttachDecorator]: + """Create indy credential proposal.""" return self.get_format_data(CRED_20_PROPOSAL, filter) async def receive_proposal( self, cred_ex_record: V20CredExRecord, cred_proposal_message: V20CredProposal ) -> None: + """Receive indy credential proposal. + + No custom handling is required for this step. + + """ pass async def create_offer( self, cred_ex_record: V20CredExRecord ) -> CredFormatAttachment: + """Create indy credential offer.""" + issuer = self.profile.inject(IndyIssuer) ledger = self.profile.inject(BaseLedger) cache = self.profile.inject(BaseCache, required=False) - # TODO: can't we move cred_def_id and schema_id to detail record? cred_proposal_message = V20CredProposal.deserialize( cred_ex_record.cred_proposal ) @@ -141,14 +165,13 @@ async def _create(): async def receive_offer( self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer ) -> None: - # TODO: Why move from offer to proposal? + """Receive indy credential offer.""" offer = cred_offer_message.attachment(self.format) schema_id = offer["schema_id"] cred_def_id = offer["cred_def_id"] - # TODO: this could overwrite proposal for other formats. We should append or something - # TODO: move schema_id and cred_def_id to indy record - cred_proposal_ser = V20CredProposal( + # FIXME: this will overwrite proposal for other formats. + cred_ex_record.cred_proposal = V20CredProposal( comment=cred_offer_message.comment, credential_preview=cred_offer_message.credential_preview, formats=[ @@ -168,14 +191,10 @@ async def receive_offer( ], ).serialize() # proposal houses filters, preview (possibly with MIME types) - async with self.profile.session() as session: - # TODO: we should probably not modify cred_ex_record here - cred_ex_record.cred_proposal = cred_proposal_ser - await cred_ex_record.save(session, reason="receive v2.0 credential offer") - async def create_request( self, cred_ex_record: V20CredExRecord, request_data: Mapping = None ) -> CredFormatAttachment: + """Create indy credential request.""" holder_did = request_data.get("holder_did") if request_data else None cred_offer = V20CredOffer.deserialize(cred_ex_record.cred_offer).attachment( self.format @@ -228,11 +247,16 @@ async def _create(): async def receive_request( self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest ) -> None: - assert cred_ex_record.cred_offer + """Receive indy credential request.""" + if not cred_ex_record.cred_offer: + raise V20CredFormatError( + "Indy issue credential format cannot start from credential offer" + ) async def issue_credential( self, cred_ex_record: V20CredExRecord, retries: int = 5 ) -> CredFormatAttachment: + """Issue indy credential.""" cred_offer = V20CredOffer.deserialize(cred_ex_record.cred_offer).attachment( self.format ) @@ -383,12 +407,16 @@ async def issue_credential( async def receive_credential( self, cred_ex_record: V20CredExRecord, cred_issue_message: V20CredIssue ) -> None: - # TODO: validate + """Receive indy credential. + + Validation is done in the store credential step. + """ pass async def store_credential( self, cred_ex_record: V20CredExRecord, cred_id: str = None ) -> None: + """Store indy credential.""" cred = V20CredIssue.deserialize(cred_ex_record.cred_issue).attachment( self.format ) @@ -427,8 +455,7 @@ async def store_credential( rev_reg_def=rev_reg_def, ) - # TODO: doesn't work with multiple attachments - cred_ex_record.cred_id_stored = cred_id_stored + detail_record.cred_id_stored = cred_id_stored detail_record.rev_reg_id = cred.get("rev_reg_id", None) detail_record.cred_rev_id = cred.get("cred_rev_id", None) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index c3459d3703..954f44fec0 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -1,15 +1,17 @@ """V2.0 issue-credential linked data proof credential format handler.""" - import logging -import json from typing import Mapping from marshmallow import RAISE -from ......vc.vc_ld.models.credential_schema import VerifiableCredentialSchema -from ......vc.vc_ld.models.credential import LDProof, VerifiableCredential -from ......vc.vc_ld import issue, verify_credential +from ......vc.vc_ld import ( + issue, + verify_credential, + VerifiableCredentialSchema, + LDProof, + VerifiableCredential, +) from ......vc.ld_proofs import ( Ed25519Signature2018, Ed25519WalletKeyPair, @@ -17,9 +19,10 @@ CredentialIssuancePurpose, ProofPurpose, get_default_document_loader, + AuthenticationProofPurpose, ) from ......wallet.error import WalletNotFoundError -from ......wallet.base import BaseWallet +from ......wallet.base import BaseWallet, DIDInfo from ......did.did_key import DIDKey from ......storage.vc_holder.base import VCHolder from ......storage.vc_holder.vc_record import VCRecord @@ -39,11 +42,15 @@ from ..handler import CredFormatAttachment, V20CredFormatError, V20CredFormatHandler from .models.cred_detail_schema import LDProofVCDetailSchema from .models.cred_detail import LDProofVCDetail +from ...models.detail.ld_proof import V20CredExRecordLDProof LOGGER = logging.getLogger(__name__) -# TODO: move to vc/proof library -SUPPORTED_PROOF_TYPES = {"Ed25519Signature2018"} +SUPPORTED_ISSUANCE_PROOF_TYPES = {Ed25519Signature2018.signature_type} +SUPPORTED_ISSUANCE_PROOF_PURPOSES = { + CredentialIssuancePurpose.term, + AuthenticationProofPurpose.term, +} class LDProofCredFormatHandler(V20CredFormatHandler): @@ -53,6 +60,23 @@ class LDProofCredFormatHandler(V20CredFormatHandler): @classmethod def validate_fields(cls, message_type: str, attachment_data: Mapping) -> None: + """Validate attachment data for a specific message type. + + Uses marshmallow schemas to validate if format specific attachment data + is valid for the specified message type. Only does structural and type + checks, does not validate if .e.g. the issuer value is valid. + + + Args: + message_type (str): The message type to validate the attachment data for. + Should be one of the message types as defined in message_types.py + attachment_data (Mapping): [description] + The attachment data to valide + + Raises: + Exception: When the data is not valid. + + """ mapping = { CRED_20_PROPOSAL: LDProofVCDetailSchema, CRED_20_OFFER: LDProofVCDetailSchema, @@ -64,29 +88,71 @@ def validate_fields(cls, message_type: str, attachment_data: Mapping) -> None: Schema = mapping[message_type] # Validate, throw if not valid - # TODO: unknown should not raise Schema(unknown=RAISE).load(attachment_data) - async def _assert_can_issue_with_did_and_proof_type( - self, did: str, proof_type: str + async def _assert_can_issue_with_id_and_proof_type( + self, issuer_id: str, proof_type: str ): + """Assert that it is possible to issue using the specified id and proof type. + + Args: + issuer_id (str): The issuer id + proof_type (str): the signature suite proof type + + Raises: + V20CredFormatError: + - If the proof type is not supported + - If the issuer id is not a did + - If the did is not found in th wallet + - If the did does not support to create signatures for the proof type + + """ try: - # We only support ed signatures at the moment - if proof_type != "Ed25519Signature2018": + # Check if it is a proof type we can issue with + if proof_type not in SUPPORTED_ISSUANCE_PROOF_TYPES: + raise V20CredFormatError( + f"Unable to sign credential with unsupported proof type {proof_type}" + f". Supported proof types: {SUPPORTED_ISSUANCE_PROOF_TYPES}" + ) + + # TODO: use proper did (regex) validator + # Assert issuer id is a did + if not issuer_id.startswith("did:"): raise V20CredFormatError( - f"Unable to sign credential with proof type {proof_type}" + f"Unable to issue credential with issuer id: {issuer_id}." + " Only issuance with DIDs is supported" ) - await self._did_info_for_did(did) + + # TODO: check if did supports the proof type + # Retrieve did from wallet. Will throw if not found + await self._did_info_for_did(issuer_id) except WalletNotFoundError: raise V20CredFormatError( - f"Issuer did {did} not found. Unable to issue credential with this DID." + f"Issuer did {issuer_id} not found." + " Unable to issue credential with this DID." ) - async def _did_info_for_did(self, did: str): + async def _did_info_for_did(self, did: str) -> DIDInfo: + """Get the did info for specified did. + + If the did starts with did:sov it will remove the prefix for + backwards compatibility with not fully qualified did. + + Args: + did (str): The did to retrieve from the wallet. + + Raises: + WalletNotFoundError: If the did is not found in the wallet. + + Returns: + DIDInfo: did information + + """ async with self.profile.session() as session: wallet = session.inject(BaseWallet) # If the did starts with did:sov we need to query without + # FIXME: could be that the did is actually stored as did:sov in the wallet if did.startswith("did:sov:"): return await wallet.get_local_did(did.replace("did:sov:", "")) # All other methods we can just query @@ -94,41 +160,60 @@ async def _did_info_for_did(self, did: str): return await wallet.get_local_did(did) async def _get_suite_for_detail(self, detail: LDProofVCDetail) -> LinkedDataProof: - did = detail.credential.issuer_id + issuer_id = detail.credential.issuer_id proof_type = detail.options.proof_type - await self._assert_can_issue_with_did_and_proof_type( - did, detail.options.proof_type + # Assert we can issue the credential based on issuer + proof_type + await self._assert_can_issue_with_id_and_proof_type( + issuer_id, detail.options.proof_type ) + # Create base proof object with options from detail proof = LDProof( created=detail.options.created, domain=detail.options.domain, challenge=detail.options.challenge, ) - did_info = await self._did_info_for_did(did) - verification_method = self._get_verification_method(did) + did_info = await self._did_info_for_did(issuer_id) + verification_method = self._get_verification_method(issuer_id) + + suite = await self._get_suite( + proof_type=proof_type, + verification_method=verification_method, + proof=proof.serialize(), + did_info=did_info, + ) + + return suite + async def _get_suite( + self, + *, + proof_type: str, + verification_method: str = None, + proof: dict = None, + did_info: DIDInfo = None, + ): + """Get signature suite for issuance of verification.""" async with self.profile.session() as session: - # TODO: maybe keypair should start session and inject wallet (for shorter sessions) + # TODO: maybe keypair should start session and inject wallet + # for shorter sessions wallet = session.inject(BaseWallet) - # TODO: make enum or something? - # TODO: how to abstract keypair from this step? - if proof_type == "Ed25519Signature2018": + if proof_type == Ed25519Signature2018.signature_type: return Ed25519Signature2018( verification_method=verification_method, - proof=proof.serialize(), + proof=proof, key_pair=Ed25519WalletKeyPair( - wallet=wallet, public_key_base58=did_info.verkey + wallet=wallet, + public_key_base58=did_info.verkey if did_info else None, ), ) else: raise V20CredFormatError(f"Unsupported proof type {proof_type}") - # TODO: move to better place - # TODO: integrate with did resolver classes (did) + # TODO: this should be integrated with the SICPA universal resolver work def _get_verification_method(self, did: str): if did.startswith("did:sov:"): # TODO: is this correct? uniresolver uses #key-1, SICPA uses #1 @@ -140,58 +225,74 @@ def _get_verification_method(self, did: str): f"Unable to get retrieve verification method for did {did}" ) - # TODO: move to better place - # TODO: probably needs more input parameters - def _get_proof_purpose(self, proof_purpose: str = None) -> ProofPurpose: - PROOF_PURPOSE_MAP = { - "assertionMethod": CredentialIssuancePurpose, - # TODO: authentication - # "authentication": AuthenticationProofPurpose, - } + def _get_proof_purpose(self, detail: LDProofVCDetail) -> ProofPurpose: + """Get the proof purpose for a credential detail. + + Args: + detail (LDProofVCDetail): The credential detail to extract the purpose from - # assertionMethod is default - if not proof_purpose: - proof_purpose = "assertionMethod" + Raises: + V20CredFormatError: + - If the proof purpose is not supported. + - [authentication] If challenge is missing. - if proof_purpose not in PROOF_PURPOSE_MAP: - raise V20CredFormatError(f"Unsupported proof purpose {proof_purpose}") + Returns: + ProofPurpose: Proof purpose instance that can be used for issuance. - # TODO: constructor parameters - return PROOF_PURPOSE_MAP[proof_purpose]() + """ + # TODO: add date to proof purposes. Not really needed now but this will allow + # other checks in the future + + # Default proof purpose is assertionMethod + proof_purpose = detail.options.proof_purpose or CredentialIssuancePurpose.term + + if proof_purpose == CredentialIssuancePurpose.term: + return CredentialIssuancePurpose() + elif proof_purpose == AuthenticationProofPurpose.term: + # assert challenge is present for authentication proof purpose + if not detail.options.challenge: + raise V20CredFormatError( + f"Challenge is required for '{proof_purpose}' proof purpose." + ) + + return AuthenticationProofPurpose( + challenge=detail.options.challenge, domain=detail.options.domain + ) + else: + raise V20CredFormatError( + f"Unsupported proof purse: {proof_purpose}. " + f"Supported proof types are: {SUPPORTED_ISSUANCE_PROOF_PURPOSES}" + ) async def create_proposal( self, cred_ex_record: V20CredExRecord, proposal_data: Mapping ) -> CredFormatAttachment: + """Create linked data proof credential proposal.""" return self.get_format_data(CRED_20_PROPOSAL, proposal_data) async def receive_proposal( self, cred_ex_record: V20CredExRecord, cred_proposal_message: V20CredProposal ) -> None: - """Receive linked data proof credential proposal""" - # Structure validation is already done when message is received - # no additional checking is required here - pass + """Receive linked data proof credential proposal.""" async def create_offer( self, cred_ex_record: V20CredExRecord, offer_data: Mapping = None ) -> CredFormatAttachment: - # TODO: - # - Check if all fields in credentialSubject are present in context (dropped attributes) - # - offer data is not passed at the moment - - # use proposal data otherwise - if not offer_data and cred_ex_record.cred_proposal: - offer_data = V20CredProposal.deserialize( - cred_ex_record.cred_proposal - ).attachment(self.format) - else: + """Create linked data proof credential offer.""" + if not cred_ex_record.cred_proposal: raise V20CredFormatError( "Cannot create linked data proof offer without proposal or input data" ) + # Parse proposal. Data is stored in proposal if we received a proposal + # but also when we create an offer (manager does some weird stuff) + offer_data = V20CredProposal.deserialize( + cred_ex_record.cred_proposal + ).attachment(self.format) detail = LDProofVCDetail.deserialize(offer_data) - await self._assert_can_issue_with_did_and_proof_type( + # Make sure we can issue with the did and proof type + await self._assert_can_issue_with_id_and_proof_type( detail.credential.issuer_id, detail.options.proof_type ) @@ -200,21 +301,12 @@ async def create_offer( async def receive_offer( self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer ) -> None: - # TODO: anything to validate here? - pass + """Receive linked data proof credential offer.""" async def create_request( self, cred_ex_record: V20CredExRecord, request_data: Mapping = None ) -> CredFormatAttachment: - # holder_did = request_data.get("holder_did") if request_data else None - # TODO: add build_detail method that takes the record - # and looks for the best detail to build (dependant on messages and role) - - # TODO: request data now contains holder did. This should - # contain the data from the API (if starting from request) - # if request_data: - # detail = LDProofVCDetail.deserialize(request_data) - # Otherwise use offer if possible + """Create linked data proof credential request.""" if cred_ex_record.cred_offer: request_data = V20CredOffer.deserialize( cred_ex_record.cred_offer @@ -225,65 +317,33 @@ async def create_request( request_data = V20CredProposal.deserialize( cred_ex_record.cred_proposal ).attachment(self.format) - - # TODO: do we want to set the credential subject id? - # if ( - # not cred_detail["credential"]["credentialSubject"].get("id") - # and holder_did - # ): - # async with self.profile.session() as session: - # wallet = session.inject(BaseWallet) - - # did_info = await wallet.get_local_did(holder_did) - # did_key = DIDKey.from_public_key_b58( - # did_info.verkey, KeyType.ED25519 - # ) - - # cred_detail["credential"]["credentialSubject"]["id"] = did_key.did else: raise V20CredFormatError( "Cannot create linked data proof request without offer or input data" ) - detail = LDProofVCDetail.deserialize(request_data) - - return self.get_format_data(CRED_20_REQUEST, detail.serialize()) + return self.get_format_data(CRED_20_REQUEST, request_data) async def receive_request( self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest ) -> None: - # If we sent an offer, check if request matches this - if cred_ex_record.cred_offer: - request_detail = cred_request_message.attachment(self.format) - offer_detail = V20CredOffer.deserialize( - cred_ex_record.cred_offer - ).attachment(self.format) - - # TODO: probably some fields can be different - # so maybe do partial check? - # e.g. options.challenge may be filled in request - # OR credentialSubject.id - # TODO: Send problem report if no match? - # TODO: implement __eq__ for all descendant classes - # assert cred_offer_detail == cred_request_detail - assert offer_detail == request_detail + """Receive linked data proof request.""" async def issue_credential( self, cred_ex_record: V20CredExRecord, retries: int = 5 ) -> CredFormatAttachment: - # TODO: we need to be sure the request is matched against the offer - # and only fields that are allowed to change can change + """Issue linked data proof credential.""" detail_dict = V20CredRequest.deserialize( cred_ex_record.cred_request ).attachment(self.format) - detail = LDProofVCDetail.deserialize(detail_dict) + # Get signature suite, proof purpose and document loader suite = await self._get_suite_for_detail(detail) - proof_purpose = self._get_proof_purpose(detail.options.proof_purpose) - - # best to pass profile, session, ...? + proof_purpose = self._get_proof_purpose(detail) document_loader = get_default_document_loader(profile=self.profile) + + # issue the credential vc = await issue( credential=detail.credential.serialize(), suite=suite, @@ -296,51 +356,108 @@ async def issue_credential( async def receive_credential( self, cred_ex_record: V20CredExRecord, cred_issue_message: V20CredIssue ) -> None: - # TODO: validate? I think structure is already validated on a higher lever - # And crypto stuff is better handled in store_credential - pass + """Receive linked data proof credential.""" + cred_dict = cred_issue_message.attachment(self.format) + detail_dict = V20CredRequest.deserialize( + cred_ex_record.cred_request + ).attachment(self.format) + + vc = VerifiableCredential.deserialize(cred_dict) + detail = LDProofVCDetail.deserialize(detail_dict) + + # Remove values from cred that are not part of detail + cred_dict.pop("proof") + credential_status = cred_dict.pop("credentialStatus", None) + detail_status = detail.options.credential_status + + if cred_dict != detail_dict["credential"]: + raise V20CredFormatError( + f"Received credential for cred_ex_id {cred_ex_record.cred_ex_id} does not" + " match requested credential" + ) + + # both credential and detail contain status. Check for equalness + if credential_status and detail_status: + if credential_status.get("type") != detail_status.get("type"): + raise V20CredFormatError( + "Received credential status type does not match credential request" + ) + # Either credential or detail contains status. Throw error + elif (credential_status and not detail_status) or ( + not credential_status and detail_status + ): + raise V20CredFormatError( + "Received credential status contains credential status" + " that does not match credential request" + ) + + # Check if created property matches + if vc.proof.created != detail.options.created: + raise V20CredFormatError( + "Received credential proof.created does not" + " match options.created from credential request" + ) + + # Check if proof type matches + if vc.proof.type != detail.options.proof_type: + raise V20CredFormatError( + "Received credential proof.type does not" + " match options.proofType from credential request" + ) async def store_credential( self, cred_ex_record: V20CredExRecord, cred_id: str = None ) -> None: + """Store linked data proof credential.""" + # Get attachment data cred_dict: dict = V20CredIssue.deserialize( cred_ex_record.cred_issue ).attachment(self.format) + detail_dict = V20CredRequest.deserialize( + cred_ex_record.cred_request + ).attachment(self.format) - async with self.profile.session() as session: - wallet = session.inject(BaseWallet) + # Deserialize objects + credential = VerifiableCredential.deserialize(cred_dict) + detail = LDProofVCDetail.deserialize(detail_dict) - # TODO: extract to suite provider or something - suites = [ - Ed25519Signature2018( - key_pair=Ed25519WalletKeyPair(wallet=wallet), - ) - ] + # Get signature suite, proof purpose and document loader + suite = await self._get_suite(credential.proof.type) + purpose = self._get_proof_purpose(detail) + document_loader = get_default_document_loader(self.profile) - result = await verify_credential( - credential=cred_dict, - suites=suites, - document_loader=get_default_document_loader(self.profile), - ) + # Verify the credential + result = await verify_credential( + credential=cred_dict, + suites=[suite], + document_loader=document_loader, + purpose=purpose, + ) + + if not result.verified: + raise V20CredFormatError(f"Received invalid credential: {result}") + + # create VC record for storage + vc_record = VCRecord( + contexts=credential.context_urls, + types=credential.type, + issuer_id=credential.issuer_id, + subject_ids=credential.credential_subject_ids, + schema_ids=[], # Schemas not supported yet + cred_value=credential.serialize(), + given_id=credential.id, + record_id=cred_id, + cred_tags=None, # Tags should be derived from credential values + ) - if not result.verified: - raise V20CredFormatError(f"Received invalid credential: {result}") + # Create detail record with cred_id_stored + detail_record = V20CredExRecordLDProof( + cred_ex_id=cred_ex_record.cred_ex_id, cred_id_stored=vc_record.record_id + ) + # save credential and detail record + async with self.profile.session() as session: vc_holder = session.inject(VCHolder) - credential = VerifiableCredential.deserialize(cred_dict) - - # TODO: tags - vc_record = VCRecord( - contexts=credential.context_urls, - types=credential.type, - issuer_id=credential.issuer_id, - subject_ids=credential.credential_subject_ids, - schema_ids=[], # Schemas not supported yet - cred_value=credential.serialize(), - given_id=credential.id, - record_id=cred_id, - ) - await vc_holder.store_credential(vc_record) - # TODO: doesn't work with multiple attachments - cred_ex_record.cred_id_stored = vc_record.record_id + await vc_holder.store_credential(vc_record) + await detail_record.save(session, reason="store credential v2.0") diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py index 074bc1870e..4331c85e88 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail.py @@ -13,7 +13,7 @@ class LDProofVCDetailOptions: - """Linked Data Proof verifiable credential options model""" + """Linked Data Proof verifiable credential options model.""" def __init__( self, @@ -44,6 +44,7 @@ def deserialize(cls, detail_options: Union[dict, str]) -> "LDProofVCDetailOption Returns: LDProofVCDetailOptions: The deserialized LDProofVCDetailOptions object + """ if isinstance(detail_options, str): detail_options = json.loads(detail_options) @@ -56,6 +57,7 @@ def serialize(self) -> dict: Returns: dict: The LDProofVCDetailOptions serialized as dict. + """ schema = LDProofVCDetailOptionsSchema() detail_options: dict = schema.dump(copy.deepcopy(self)) @@ -71,6 +73,7 @@ def __init__( credential: Optional[Union[dict, VerifiableCredential]], options: Optional[Union[dict, LDProofVCDetailOptions]], ) -> None: + """Initialize the LDProofVCDetail instance.""" if isinstance(credential, dict): credential = VerifiableCredential.deserialize(credential) self.credential = credential @@ -88,6 +91,7 @@ def deserialize(cls, detail: Union[dict, str]) -> "LDProofVCDetail": Returns: LDProofVCDetail: The deserialized LDProofVCDetail object + """ if isinstance(detail, str): detail = json.loads(detail) @@ -100,6 +104,7 @@ def serialize(self) -> dict: Returns: dict: The LDProofVCDetail serialized as dict. + """ schema = LDProofVCDetailSchema() detail: dict = schema.dump(copy.deepcopy(self)) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py index 913d50710f..8d490f00fd 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py @@ -8,7 +8,6 @@ ) from ..manager import V20CredManager -from ..messages.cred_proposal import V20CredProposal from ..messages.cred_request import V20CredRequest from .....utils.tracing import trace_event, get_timer @@ -52,14 +51,7 @@ async def handle(self, context: RequestContext, responder: BaseResponder): # If auto_issue is enabled, respond immediately if cred_ex_record.auto_issue: - # TODO: can only auto_issue for indy if cred proposal is present - if ( - cred_ex_record.cred_proposal - or cred_ex_record.cred_request - # and (V20CredProposal.deserialize( - # cred_ex_record.cred_proposal - # ).credential_preview) - ): + if cred_ex_record.cred_proposal or cred_ex_record.cred_request: ( cred_ex_record, cred_issue_message, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py index 6be4a60e0f..0f265eb95c 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py @@ -162,8 +162,6 @@ async def receive_proposal( thread_id=cred_proposal_message._thread_id, initiator=V20CredExRecord.INITIATOR_EXTERNAL, role=V20CredExRecord.ROLE_ISSUER, - state=V20CredExRecord.STATE_PROPOSAL_RECEIVED, - cred_proposal=cred_proposal_message.serialize(), auto_offer=self._profile.settings.get( "debug.auto_respond_credential_proposal" ), @@ -173,6 +171,16 @@ async def receive_proposal( auto_remove=not self._profile.settings.get("preserve_exchange_records"), trace=(cred_proposal_message._trace is not None), ) + + # Format specific receive_proposal handlers + for format in cred_proposal_message.formats: + await V20CredFormat.Format.get(format.format).handler( + self.profile + ).receive_proposal(cred_ex_record, cred_proposal_message) + + cred_ex_record.cred_proposal = (cred_proposal_message.serialize(),) + cred_ex_record.state = (V20CredExRecord.STATE_PROPOSAL_RECEIVED,) + async with self._profile.session() as session: await cred_ex_record.save( session, @@ -207,13 +215,20 @@ async def create_offer( self._profile.settings, cred_ex_record.trace ) + formats = [] # Format specific create_offer handler - formats = [ - await V20CredFormat.Format.get(p.format) - .handler(self.profile) - .create_offer(cred_ex_record) - for p in cred_proposal_message.formats - ] + for format in cred_proposal_message.formats: + cred_format = V20CredFormat.Format.get(format.format) + + if cred_format: + formats.append( + await cred_format.handler(self.profile).create_offer(cred_ex_record) + ) + + if len(formats) == 0: + raise V20CredManagerError( + "Unable to create credential offer. No supported formats" + ) cred_offer_message = V20CredOffer( replacement_id=replacement_id, @@ -254,9 +269,6 @@ async def receive_offer( """ - # TODO: assert for all methods that we support at least one format - # TODO: assert we don't suddenly change from format during the interaction - async with self._profile.session() as session: # Get credential exchange record (holder sent proposal first) # or create it (issuer sent offer first) @@ -280,9 +292,12 @@ async def receive_offer( # Format specific receive_offer handler for cred_format in cred_offer_message.formats: - await V20CredFormat.Format.get(cred_format.format).handler( - self.profile - ).receive_offer(cred_ex_record, cred_offer_message) + cred_format = V20CredFormat.Format.get(format.format) + + if cred_format: + await cred_format.handler(self.profile).receive_offer( + cred_ex_record, cred_offer_message + ) cred_ex_record.cred_offer = cred_offer_message.serialize() cred_ex_record.state = V20CredExRecord.STATE_OFFER_RECEIVED @@ -306,7 +321,7 @@ async def create_request( A tuple (credential exchange record, credential request message) """ - # react to credential offer + # react to credential offer, use offer formats if cred_ex_record.state: if cred_ex_record.state != V20CredExRecord.STATE_OFFER_RECEIVED: raise V20CredManagerError( @@ -317,21 +332,29 @@ async def create_request( cred_offer = V20CredOffer.deserialize(cred_ex_record.cred_offer) - formats = cred_offer.formats + input_formats = cred_offer.formats # start with request (not allowed for indy -> checked in indy format handler) + # use proposal formats else: - # TODO: where to get data from if starting from request. proposal? - cred_proposal = V20CredProposal.deserialize(cred_ex_record.cred_proposal) - formats = cred_proposal.formats + input_formats = cred_proposal.formats + request_formats = [] # Format specific create_request handler - request_formats = [ - await V20CredFormat.Format.get(p.format).handler(self.profile) - # TODO: retrieve holder did from create_request handler? - .create_request(cred_ex_record, {"holder_did": holder_did}) - for p in formats - ] + for format in input_formats: + cred_format = V20CredFormat.Format.get(format.format) + + if cred_format: + request_formats.append( + await cred_format.handler(self.profile).create_request( + cred_ex_record, {"holder_did": holder_did} + ) + ) + + if len(request_formats) == 0: + raise V20CredManagerError( + "Unable to create credential request. No supported formats" + ) cred_request_message = V20CredRequest( comment=comment, @@ -389,10 +412,12 @@ async def receive_request( ), ) - for cred_format in cred_request_message.formats: - await V20CredFormat.Format.get(cred_format.format).handler( - self.profile - ).receive_request(cred_ex_record, cred_request_message) + for format in cred_request_message.formats: + cred_format = V20CredFormat.Format.get(format.format) + if cred_format: + await cred_format.handler(self.profile).receive_request( + cred_ex_record, cred_request_message + ) cred_ex_record.cred_request = cred_request_message.serialize() cred_ex_record.state = V20CredExRecord.STATE_REQUEST_RECEIVED @@ -434,23 +459,23 @@ async def issue_credential( # TODO: replacement id for jsonld start from request replacement_id = None - formats = V20CredRequest.deserialize(cred_ex_record.cred_request).formats + input_formats = V20CredRequest.deserialize(cred_ex_record.cred_request).formats if cred_ex_record.cred_offer: cred_offer_message = V20CredOffer.deserialize(cred_ex_record.cred_offer) replacement_id = cred_offer_message.replacement_id - # TODO: How do we verify if requests matches offer? - # Use offer formats if offer is sent - formats = cred_offer_message.formats - # Format specific issue_credential handler - issue_formats = [ - await V20CredFormat.Format.get(p.format) - .handler(self.profile) - .issue_credential(cred_ex_record) - for p in formats - ] + issue_formats = [] + for format in input_formats: + cred_format = V20CredFormat.Format.get(format.format) + + if cred_format: + issue_formats.append( + await cred_format.handler(self.profile).issue_credential( + cred_ex_record + ) + ) cred_issue_message = V20CredIssue( replacement_id=replacement_id, @@ -496,6 +521,12 @@ async def receive_credential( ) ) + for format in cred_issue_message.formats: + cred_format = V20CredFormat.Format.get(format.format) + await cred_format.handler(self.profile).receive_credential( + cred_ex_record, cred_issue_message + ) + cred_ex_record.cred_issue = cred_issue_message.serialize() cred_ex_record.state = V20CredExRecord.STATE_CREDENTIAL_RECEIVED @@ -524,10 +555,15 @@ async def store_credential( ) # Format specific store_credential handler - for cred_format in V20CredIssue.deserialize(cred_ex_record.cred_issue).formats: - await V20CredFormat.Format.get(cred_format.format).handler( - self.profile - ).store_credential(cred_ex_record, cred_id) + for format in V20CredIssue.deserialize(cred_ex_record.cred_issue).formats: + cred_format = await V20CredFormat.Format.get(format.format) + + if cred_format: + await cred_format.handler(self.profile).store_credential( + cred_ex_record, cred_id + ) + # TODO: if we are storing multiple credentials we can't reuse the same id + cred_id = None cred_ex_record.state = V20CredExRecord.STATE_DONE diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py index 81b4650917..8bcbb5c20f 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py @@ -3,7 +3,6 @@ from collections import namedtuple from typing import Mapping, Sequence, Type, Union from enum import Enum -from typing import Sequence, Union from uuid import uuid4 from marshmallow import EXCLUDE, fields, validate @@ -12,12 +11,10 @@ from .....messaging.decorators.attach_decorator import AttachDecorator from .....messaging.models.base import BaseModel, BaseModelSchema from .....messaging.valid import UUIDFour -from .....messaging.decorators.attach_decorator import AttachDecorator from ..models.detail.indy import V20CredExRecordIndy from ..models.detail.ld_proof import V20CredExRecordLDProof from typing import TYPE_CHECKING -# TODO: remove if TYPE_CHECKING: from ..formats.handler import V20CredFormatHandler @@ -35,17 +32,17 @@ class Meta: class Format(Enum): """Attachment format.""" + from ..message_types import PROTOCOL_PACKAGE + INDY = FormatSpec( "hlindy/", V20CredExRecordIndy, - # TODO: use PROTOCOL_PACKAGE const - "aries_cloudagent.protocols.issue_credential.v2_0.formats.indy.handler.IndyCredFormatHandler", + f"{PROTOCOL_PACKAGE}.formats.indy.handler.IndyCredFormatHandler", ) LD_PROOF = FormatSpec( "aries/", V20CredExRecordLDProof, - # TODO: use PROTOCOL_PACKAGE const - "aries_cloudagent.protocols.issue_credential.v2_0.formats.ld_proof.handler.LDProofCredFormatHandler", + f"{PROTOCOL_PACKAGE}.formats.ld_proof.handler.LDProofCredFormatHandler", ) @classmethod @@ -78,8 +75,11 @@ def detail(self) -> str: @property def handler(self) -> Type["V20CredFormatHandler"]: """Accessor for credential exchange format handler.""" - # TODO: optimize / refactor - return ClassLoader.load_class(self.value.handler) + # stupid cyclic imports + if not self._class: + self._class = ClassLoader.load_class(self.value.handler) + + return self._class def validate_fields(self, message_type: str, attachment_data: Mapping): """Raise ValidationError for invalid attachment formats.""" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/models/cred_ex_record.py b/aries_cloudagent/protocols/issue_credential/v2_0/models/cred_ex_record.py index 6afa2846b0..b99b1072dc 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/models/cred_ex_record.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/models/cred_ex_record.py @@ -59,12 +59,12 @@ def __init__( cred_offer: Mapping = None, # serialized cred offer message cred_request: Mapping = None, # serialized cred request message cred_issue: Mapping = None, # serialized cred issue message - cred_id_stored: str = None, auto_offer: bool = False, auto_issue: bool = False, auto_remove: bool = True, error_msg: str = None, trace: bool = False, + cred_id_stored: str = None, # for backward compatibility to restore from storage conn_id: str = None, # for backward compatibility to restore from storage by_format: Mapping = None, # formalism for base_record.from_storage() **kwargs, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/indy.py b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/indy.py index d47d9f76a5..c7783c4b0b 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/indy.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/indy.py @@ -29,6 +29,7 @@ def __init__( cred_ex_indy_id: str = None, *, cred_ex_id: str = None, + cred_id_stored: str = None, cred_request_metadata: Mapping = None, rev_reg_id: str = None, cred_rev_id: str = None, @@ -38,6 +39,7 @@ def __init__( super().__init__(cred_ex_indy_id, **kwargs) self.cred_ex_id = cred_ex_id + self.cred_id_stored = cred_id_stored self.cred_request_metadata = cred_request_metadata self.rev_reg_id = rev_reg_id self.cred_rev_id = cred_rev_id @@ -53,6 +55,7 @@ def record_value(self) -> dict: return { prop: getattr(self, prop) for prop in ( + "cred_id_stored", "cred_request_metadata", "rev_reg_id", "cred_rev_id", @@ -96,6 +99,11 @@ class Meta: description="Corresponding v2.0 credential exchange record identifier", example=UUIDFour.EXAMPLE, ) + cred_id_stored = fields.Str( + required=False, + description="Credential identifier stored in wallet", + example=UUIDFour.EXAMPLE, + ) cred_request_metadata = fields.Dict( required=False, description="Credential request metadata for indy holder" ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/ld_proof.py b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/ld_proof.py index 18483e47fe..d99a68a9ad 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/ld_proof.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/ld_proof.py @@ -29,16 +29,14 @@ def __init__( cred_ex_ld_proof_id: str = None, *, cred_ex_id: str = None, - # TODO: REMOVE THIS COMMENT AND SET LDProof ITEMS BELOW - item: str = None, + cred_id_stored: str = None, **kwargs, ): """Initialize LD Proof credential exchange record details.""" super().__init__(cred_ex_ld_proof_id, **kwargs) self.cred_ex_id = cred_ex_id - # TODO: REMOVE THIS COMMENT AND SET LDProof ITEMS BELOW - self.item = item + self.cred_id_stored = cred_id_stored @property def cred_ex_ld_proof_id(self) -> str: @@ -48,13 +46,7 @@ def cred_ex_ld_proof_id(self) -> str: @property def record_value(self) -> dict: """Accessor for the JSON record value generated for this credential exchange.""" - return { - prop: getattr(self, prop) - for prop in ( - # TODO: REMOVE THIS COMMENT AND SET LDProof ITEMS BELOW - "item", - ) - } + return {prop: getattr(self, prop) for prop in ("cred_id_stored",)} @classmethod async def retrieve_by_cred_ex_id( @@ -93,9 +85,8 @@ class Meta: description="Corresponding v2.0 credential exchange record identifier", example=UUIDFour.EXAMPLE, ) - # TODO: REMOVE THIS COMMENT AND SET LDProof ITEMS BELOW - item = fields.Dict( + cred_id_stored = fields.Str( required=False, - description="LDProof item", + description="Credential identifier stored in wallet", example=UUIDFour.EXAMPLE, ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py index fadac37b2d..6d280985c1 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py @@ -11,7 +11,7 @@ request_schema, response_schema, ) -from marshmallow import fields, validate, validates_schema, ValidationError, Schema +from marshmallow import fields, validate, validates_schema, ValidationError from ....admin.request_context import AdminRequestContext from ....connections.models.conn_record import ConnRecord @@ -46,6 +46,7 @@ from .models.cred_ex_record import V20CredExRecord, V20CredExRecordSchema from .models.detail.ld_proof import V20CredExRecordLDProofSchema from .models.detail.indy import V20CredExRecordIndySchema +from .formats.handler import V20CredFormatError from .formats.ld_proof.models.cred_detail_schema import LDProofVCDetailSchema @@ -255,6 +256,15 @@ class V20CredCreateSchema(V20IssueCredSchemaCore): credential_preview = fields.Nested(V20CredPreviewSchema, required=False) + @validates_schema + def validate(self, data, **kwargs): + """Make sure preview is present when indy format is present.""" + + if data.get("filter", {}).get("indy") and not data.get("credential_preview"): + raise ValidationError( + "Credential preview is required if indy filter is present" + ) + class V20CredProposalRequestSchemaBase(V20IssueCredSchemaCore): """Base class for request schema for sending credential proposal admin message.""" @@ -266,11 +276,20 @@ class V20CredProposalRequestSchemaBase(V20IssueCredSchemaCore): ) -class V20CredProposalRequestPreviewOptSchema(V20CredProposalRequestSchemaBase): +class V20CredProposalRequestPreviewIndyRequiredSchema(V20CredProposalRequestSchemaBase): """Request schema for sending credential proposal on optional proposal preview.""" credential_preview = fields.Nested(V20CredPreviewSchema, required=False) + @validates_schema + def validate(self, data, **kwargs): + """Make sure preview is present when indy format is present.""" + + if data.get("filter", {}).get("indy") and not data.get("credential_preview"): + raise ValidationError( + "Credential preview is required if indy filter is present" + ) + class V20CredOfferRequestSchema(V20IssueCredSchemaCore): """Request schema for sending credential offer admin message.""" @@ -289,6 +308,15 @@ class V20CredOfferRequestSchema(V20IssueCredSchemaCore): ) credential_preview = fields.Nested(V20CredPreviewSchema, required=False) + @validates_schema + def validate(self, data, **kwargs): + """Make sure preview is present when indy format is present.""" + + if data.get("filter", {}).get("indy") and not data.get("credential_preview"): + raise ValidationError( + "Credential preview is required if indy filter is present" + ) + class V20CredIssueRequestSchema(OpenAPISchema): """Request schema for sending credential issue admin message.""" @@ -320,7 +348,6 @@ class V20CredExIdMatchInfoSchema(OpenAPISchema): ) -# TODO: why store as format, we should pass directly to the format handler def _formats_filters(filt_spec: Mapping) -> Mapping: """Break out formats and filters for v2.0 cred proposal messages.""" @@ -468,8 +495,6 @@ async def credential_exchange_create(request: web.BaseRequest): comment = body.get("comment") preview_spec = body.get("credential_preview") filt_spec = body.get("filter") - if V20CredFormat.Format.INDY.api in filt_spec and not preview_spec: - raise web.HTTPBadRequest(reason="Missing credential_preview for indy filter") auto_remove = body.get("auto_remove") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") @@ -519,8 +544,7 @@ async def credential_exchange_create(request: web.BaseRequest): tags=["issue-credential v2.0"], summary="Send holder a credential, automating entire flow", ) -# TODO: make credential preview mandatory if indy filter is present -@request_schema(V20CredProposalRequestPreviewOptSchema()) +@request_schema(V20CredProposalRequestPreviewIndyRequiredSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") async def credential_exchange_send(request: web.BaseRequest): """ @@ -550,8 +574,6 @@ async def credential_exchange_send(request: web.BaseRequest): if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") preview_spec = body.get("credential_preview") - if V20CredFormat.Format.INDY.api in filt_spec and not preview_spec: - raise web.HTTPBadRequest(reason="Missing credential_preview for indy filter") auto_remove = body.get("auto_remove") trace_msg = body.get("trace") @@ -593,7 +615,12 @@ async def credential_exchange_send(request: web.BaseRequest): ) result = cred_ex_record.serialize() - except (StorageError, BaseModelError, V20CredManagerError) as err: + except ( + StorageError, + BaseModelError, + V20CredManagerError, + V20CredFormatError, + ) as err: await internal_error( err, web.HTTPBadRequest, @@ -620,7 +647,7 @@ async def credential_exchange_send(request: web.BaseRequest): tags=["issue-credential v2.0"], summary="Send issuer a credential proposal", ) -@request_schema(V20CredProposalRequestPreviewOptSchema()) +@request_schema(V20CredProposalRequestPreviewIndyRequiredSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") async def credential_exchange_send_proposal(request: web.BaseRequest): """ @@ -776,8 +803,6 @@ async def credential_exchange_create_free_offer(request: web.BaseRequest): filt_spec = body.get("filter") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") - if V20CredFormat.Format.INDY.api in filt_spec and not preview_spec: - raise web.HTTPBadRequest(reason="Missing credential_preview for indy filter") connection_id = body.get("connection_id") trace_msg = body.get("trace") @@ -827,7 +852,12 @@ async def credential_exchange_create_free_offer(request: web.BaseRequest): oob_url = serialize_outofband(cred_offer_message, conn_did, endpoint) result = cred_ex_record.serialize() - except (BaseModelError, V20CredManagerError, LedgerError) as err: + except ( + BaseModelError, + V20CredManagerError, + LedgerError, + V20CredFormatError, + ) as err: await internal_error( err, web.HTTPBadRequest, @@ -876,8 +906,6 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): auto_remove = body.get("auto_remove") comment = body.get("comment") preview_spec = body.get("credential_preview") - if V20CredFormat.Format.INDY.api in filt_spec and not preview_spec: - raise web.HTTPBadRequest(reason="Missing credential_preview for indy filter") trace_msg = body.get("trace") cred_ex_record = None @@ -905,6 +933,7 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): BaseModelError, V20CredManagerError, LedgerError, + V20CredFormatError, ) as err: await internal_error( err, @@ -985,7 +1014,13 @@ async def credential_exchange_send_bound_offer(request: web.BaseRequest): result = cred_ex_record.serialize() - except (StorageError, BaseModelError, V20CredManagerError, LedgerError) as err: + except ( + StorageError, + BaseModelError, + V20CredManagerError, + LedgerError, + V20CredFormatError, + ) as err: await internal_error( err, web.HTTPBadRequest, @@ -1007,7 +1042,10 @@ async def credential_exchange_send_bound_offer(request: web.BaseRequest): @docs( tags=["issue-credential v2.0"], - summary="Send issuer a credential request not bound to an existing thread. Indy credentials cannot start at a request", + summary=( + "Send issuer a credential request not bound to an existing thread." + " Indy credentials cannot start at a request" + ), ) @request_schema(V20CredRequestFreeSchema()) @response_schema(V20CredExRecordSchema(), 200, description="") @@ -1138,7 +1176,12 @@ async def credential_exchange_send_bound_request(request: web.BaseRequest): result = cred_ex_record.serialize() - except (StorageError, V20CredManagerError, BaseModelError) as err: + except ( + StorageError, + V20CredManagerError, + BaseModelError, + V20CredFormatError, + ) as err: await internal_error( err, web.HTTPBadRequest, @@ -1211,7 +1254,13 @@ async def credential_exchange_issue(request: web.BaseRequest): result = await _get_result_with_details(context.profile, cred_ex_record) - except (BaseModelError, V20CredManagerError, IndyIssuerError, StorageError) as err: + except ( + BaseModelError, + V20CredManagerError, + IndyIssuerError, + StorageError, + V20CredFormatError, + ) as err: await internal_error( err, web.HTTPBadRequest, @@ -1286,7 +1335,12 @@ async def credential_exchange_store(request: web.BaseRequest): result = await _get_result_with_details(context.profile, cred_ex_record) - except (StorageError, V20CredManagerError, BaseModelError) as err: + except ( + StorageError, + V20CredManagerError, + BaseModelError, + V20CredFormatError, + ) as err: await internal_error( err, web.HTTPBadRequest, diff --git a/aries_cloudagent/storage/vc_holder/vc_record.py b/aries_cloudagent/storage/vc_holder/vc_record.py index 31984c95bd..6e4df75be5 100644 --- a/aries_cloudagent/storage/vc_holder/vc_record.py +++ b/aries_cloudagent/storage/vc_holder/vc_record.py @@ -6,7 +6,7 @@ from pyld.jsonld import JsonLdProcessor import logging -from typing import Mapping, Sequence, Sequence +from typing import Mapping, Sequence from uuid import uuid4 from marshmallow import EXCLUDE, fields @@ -86,82 +86,54 @@ def __eq__(self, other: object) -> bool: ) @classmethod - def deserialize_jsonld_cred(cls, cred_json: str) -> "VCRecord": + def deserialize_jsonld_cred(cls, cred_json_str: str) -> "VCRecord": """ Return VCRecord. - Deserialize JSONLD cred to a VCRecord + Deserialize JSON-LD cred to a VCRecord Args: - cred_json: credential json string + cred_json_str: credential json string Return: VCRecord """ - given_id = None - tags = None - value = "" - record_id = None - subject_ids = set() - issuer_id = "" - contexts = set() - types = set() - schema_ids = set() - cred_dict = json.loads(cred_json) - if "vc" in cred_dict: - cred_dict = cred_dict.get("vc") - if "id" in cred_dict: - given_id = cred_dict.get("id") - if "@context" in cred_dict: - # Should not happen - if type(cred_dict.get("@context")) is not list: - if type(cred_dict.get("@context")) is str: - contexts.add(cred_dict.get("@context")) - else: - for tmp_item in cred_dict.get("@context"): - if type(tmp_item) is str: - contexts.add(tmp_item) - if "issuer" in cred_dict: - if type(cred_dict.get("issuer")) is dict: - issuer_id = cred_dict.get("issuer").get("id") - else: - issuer_id = cred_dict.get("issuer") - if "type" in cred_dict: - expanded = jsonld.expand(cred_dict) - types = JsonLdProcessor.get_values( - expanded[0], - "@type", - ) - if "credentialSubject" in cred_dict: - if type(cred_dict.get("credentialSubject")) is list: - tmp_list = cred_dict.get("credentialSubject") - for tmp_dict in tmp_list: - subject_ids.add(tmp_dict.get("id")) - elif type(cred_dict.get("credentialSubject")) is dict: - tmp_dict = cred_dict.get("credentialSubject") - subject_ids.add(tmp_dict.get("id")) - elif type(cred_dict.get("credentialSubject")) is str: - subject_ids.add(cred_dict.get("credentialSubject")) - if "credentialSchema" in cred_dict: - if type(cred_dict.get("credentialSchema")) is list: - tmp_list = cred_dict.get("credentialSchema") - for tmp_dict in tmp_list: - schema_ids.add(tmp_dict.get("id")) - elif type(cred_dict.get("credentialSchema")) is dict: - tmp_dict = cred_dict.get("credentialSchema") - schema_ids.add(tmp_dict.get("id")) - elif type(cred_dict.get("credentialSchema")) is str: - schema_ids.add(cred_dict.get("credentialSchema")) - value = cred_json + cred_dict = json.loads(cred_json_str) + # FIXME: why are we expanding here? + expanded = jsonld.expand(cred_dict) + + given_id = cred_dict.get("id") + contexts = [ctx for ctx in cred_dict.get("@context") if type(ctx) is str] + + # issuer + issuer = cred_dict.get("issuer") + if type(issuer) is dict: + issuer = issuer.get("id") + + # types + types = JsonLdProcessor.get_values( + expanded[0], + "@type", + ) + + # subjects + subjects = cred_dict.get("credentialSubject") + if type(subjects) is dict: + subjects = [subjects] + subject_ids = [subject.get("id") for subject in subjects if subject.get("id")] + + schemas = cred_dict.get("schemas") + if type(schemas) is dict: + schemas = [schemas] + schema_ids = [schema.get("id") for schema in schemas] + return VCRecord( contexts=contexts, types=types, - issuer_id=issuer_id, + issuer_id=issuer, subject_ids=subject_ids, given_id=given_id, - cred_value=value, - cred_tags=tags, - record_id=record_id, + cred_value=cred_json_str, schema_ids=schema_ids, ) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py index 798c54ff3e..1f36127496 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AssertionProofPurpose.py @@ -8,8 +8,12 @@ class AssertionProofPurpose(ControllerProofPurpose): """Assertion proof purpose class.""" + term = "assertionMethod" + def __init__(self, *, date: datetime = None, max_timestamp_delta: timedelta = None): """Initialize new instance of AssertionProofPurpose.""" super().__init__( - term="assertionMethod", date=date, max_timestamp_delta=max_timestamp_delta + term=AssertionProofPurpose.term, + date=date, + max_timestamp_delta=max_timestamp_delta, ) diff --git a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py index 36390016c9..146be894cf 100644 --- a/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py +++ b/aries_cloudagent/vc/ld_proofs/purposes/AuthenticationProofPurpose.py @@ -12,6 +12,8 @@ class AuthenticationProofPurpose(ControllerProofPurpose): """Authentication proof purpose.""" + term = "authentication" + def __init__( self, *, @@ -22,7 +24,9 @@ def __init__( ): """Initialize new AuthenticationProofPurpose instance.""" super().__init__( - term="authentication", date=date, max_timestamp_delta=max_timestamp_delta + term=AuthenticationProofPurpose.term, + date=date, + max_timestamp_delta=max_timestamp_delta, ) self.challenge = challenge diff --git a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py index 62e1854fba..507815491b 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py +++ b/aries_cloudagent/vc/ld_proofs/suites/Ed25519Signature2018.py @@ -10,6 +10,8 @@ class Ed25519Signature2018(JwsLinkedDataSignature): """Ed25519Signature2018 suite.""" + signature_type = "Ed25519Signature2018" + def __init__( self, *, @@ -29,7 +31,7 @@ def __init__( date (datetime, optional): Signing date to use. """ super().__init__( - signature_type="Ed25519Signature2018", + signature_type=Ed25519Signature2018.signature_type, algorithm="EdDSA", required_key_type="Ed25519VerificationKey2018", key_pair=key_pair, diff --git a/aries_cloudagent/vc/vc_ld/__init__.py b/aries_cloudagent/vc/vc_ld/__init__.py index 6e55a83269..1d9c2582cc 100644 --- a/aries_cloudagent/vc/vc_ld/__init__.py +++ b/aries_cloudagent/vc/vc_ld/__init__.py @@ -2,6 +2,13 @@ from .verify import verify_presentation, verify_credential from .prove import create_presentation, sign_presentation from .validation_result import PresentationVerificationResult +from .models import ( + VerifiableCredential, + LDProof, + LinkedDataProofSchema, + CredentialSchema, + VerifiableCredentialSchema, +) __all__ = [ issue, @@ -10,4 +17,9 @@ create_presentation, sign_presentation, PresentationVerificationResult, + VerifiableCredential, + LDProof, + LinkedDataProofSchema, + CredentialSchema, + VerifiableCredentialSchema, ] diff --git a/aries_cloudagent/vc/vc_ld/models/__init__.py b/aries_cloudagent/vc/vc_ld/models/__init__.py index e69de29bb2..3132d30b1a 100644 --- a/aries_cloudagent/vc/vc_ld/models/__init__.py +++ b/aries_cloudagent/vc/vc_ld/models/__init__.py @@ -0,0 +1,14 @@ +from .credential import VerifiableCredential, LDProof +from .credential_schema import ( + LinkedDataProofSchema, + CredentialSchema, + VerifiableCredentialSchema, +) + +__all__ = [ + VerifiableCredential, + LDProof, + LinkedDataProofSchema, + CredentialSchema, + VerifiableCredentialSchema, +] From b3f3ef1bb117e08ffe29526322bf735cec5d9581 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sun, 28 Mar 2021 00:26:16 +0100 Subject: [PATCH 053/138] add bls signature sutie to issue cred handler Signed-off-by: Timo Glastra --- .../v2_0/formats/ld_proof/handler.py | 17 ++++- aries_cloudagent/vc/ld_proofs/__init__.py | 3 +- .../crypto/Bls12381G2WalletKeyPair.py | 68 +++++++++++++++++++ .../ld_proofs/crypto/Ed25519WalletKeyPair.py | 2 - .../vc/ld_proofs/crypto/__init__.py | 3 +- .../ld_proofs/suites/BbsBlsSignature2020.py | 4 +- .../suites/BbsBlsSignature2020Base.py | 24 ++++++- .../suites/BbsBlsSignatureProof2020.py | 6 +- .../suites/JwsLinkedDataSignature.py | 5 +- .../vc/ld_proofs/suites/__init__.py | 4 ++ aries_cloudagent/vc/vc_ld/prove.py | 4 +- .../vc/vc_ld/validation_result.py | 2 +- aries_cloudagent/vc/vc_ld/verify.py | 2 +- 13 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 aries_cloudagent/vc/ld_proofs/crypto/Bls12381G2WalletKeyPair.py diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index 954f44fec0..b9dcf48d92 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -14,7 +14,9 @@ ) from ......vc.ld_proofs import ( Ed25519Signature2018, + BbsBlsSignature2020, Ed25519WalletKeyPair, + Bls12381G2WalletKeyPair, LinkedDataProof, CredentialIssuancePurpose, ProofPurpose, @@ -46,7 +48,11 @@ LOGGER = logging.getLogger(__name__) -SUPPORTED_ISSUANCE_PROOF_TYPES = {Ed25519Signature2018.signature_type} +SUPPORTED_ISSUANCE_PROOF_TYPES = { + Ed25519Signature2018.signature_type, + BbsBlsSignature2020.signature_type, +} + SUPPORTED_ISSUANCE_PROOF_PURPOSES = { CredentialIssuancePurpose.term, AuthenticationProofPurpose.term, @@ -210,6 +216,15 @@ async def _get_suite( public_key_base58=did_info.verkey if did_info else None, ), ) + elif proof_type == BbsBlsSignature2020.signature_type: + return BbsBlsSignature2020( + verification_method=verification_method, + proof=proof, + key_pair=Bls12381G2WalletKeyPair( + wallet=wallet, + public_key_base58=did_info.verkey if did_info else None, + ), + ) else: raise V20CredFormatError(f"Unsupported proof type {proof_type}") diff --git a/aries_cloudagent/vc/ld_proofs/__init__.py b/aries_cloudagent/vc/ld_proofs/__init__.py index 2d0ca2ce7d..15c8324ab9 100644 --- a/aries_cloudagent/vc/ld_proofs/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/__init__.py @@ -15,7 +15,7 @@ BbsBlsSignature2020, BbsBlsSignatureProof2020, ) -from .crypto import KeyPair, Ed25519WalletKeyPair +from .crypto import KeyPair, Ed25519WalletKeyPair, Bls12381G2WalletKeyPair from .document_loader import DocumentLoader, get_default_document_loader from .error import LinkedDataProofException from .validation_result import DocumentVerificationResult, ProofResult, PurposeResult @@ -41,6 +41,7 @@ # Key pairs KeyPair, Ed25519WalletKeyPair, + Bls12381G2WalletKeyPair, # Document Loaders DocumentLoader, get_default_document_loader, diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Bls12381G2WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Bls12381G2WalletKeyPair.py new file mode 100644 index 0000000000..2ce1994b36 --- /dev/null +++ b/aries_cloudagent/vc/ld_proofs/crypto/Bls12381G2WalletKeyPair.py @@ -0,0 +1,68 @@ +"""Bls12381G2 key pair based on base wallet interface.""" + +from typing import List, Optional, Union + +from ....wallet.util import b58_to_bytes +from ....wallet.base import BaseWallet +from ..error import LinkedDataProofException +from .WalletKeyPair import WalletKeyPair + + +class Bls12381G2WalletKeyPair(WalletKeyPair): + """Bls12381G2 wallet key pair.""" + + def __init__(self, *, wallet: BaseWallet, public_key_base58: Optional[str] = None): + """Initialize new Bls12381G2WalletKeyPair instance.""" + super().__init__(wallet=wallet) + + self.public_key_base58 = public_key_base58 + + async def sign(self, messages: Union[List[bytes], bytes]) -> bytes: + """Sign message using Bls12381G2 key.""" + if not self.public_key_base58: + raise LinkedDataProofException( + "Unable to sign message with Bls12381G2WalletKeyPair: No key to sign with" + ) + return await self.wallet.sign_message( + message=messages if type(messages) is list else [messages], + from_verkey=self.public_key_base58, + ) + + async def verify( + self, messages: Union[List[bytes], bytes], signature: bytes + ) -> bool: + """Verify message against signature using Ed25519 key.""" + if not self.public_key_base58: + raise LinkedDataProofException( + "Unable to verify message with Ed25519WalletKeyPair" + ": No key to verify with" + ) + + return await self.wallet.verify_message( + message=messages if type(messages) is list else [messages], + signature=signature, + from_verkey=self.public_key_base58, + ) + + def from_verification_method( + self, verification_method: dict + ) -> "Bls12381G2WalletKeyPair": + """Create new Bls12381G2WalletKeyPair from public key in verification method.""" + if "publicKeyBase58" not in verification_method: + raise LinkedDataProofException( + "Unable to set public key from verification method: no publicKeyBase58" + ) + + return Bls12381G2WalletKeyPair( + wallet=self.wallet, public_key_base58=verification_method["publicKeyBase58"] + ) + + @property + def public_key(self) -> Optional[bytes]: + """Getter for public key.""" + return b58_to_bytes(self.public_key_base58) + + @property + def has_public_key(self) -> bool: + """Whether key pair has public key.""" + return self.public_key_base58 is not None diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py index 354ec7629b..ce5bc97100 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py @@ -11,8 +11,6 @@ class Ed25519WalletKeyPair(WalletKeyPair): """Ed25519 wallet key pair.""" - # TODO: maybe make public key buffer default input? - # This way we can make it an input on the lower level key pair class def __init__(self, *, wallet: BaseWallet, public_key_base58: Optional[str] = None): """Initialize new Ed25519WalletKeyPair instance.""" super().__init__(wallet=wallet) diff --git a/aries_cloudagent/vc/ld_proofs/crypto/__init__.py b/aries_cloudagent/vc/ld_proofs/crypto/__init__.py index 6d9f20e860..ab9f969c2f 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/__init__.py @@ -1,4 +1,5 @@ from .KeyPair import KeyPair from .Ed25519WalletKeyPair import Ed25519WalletKeyPair +from .Bls12381G2WalletKeyPair import Bls12381G2WalletKeyPair -__all__ = [KeyPair, Ed25519WalletKeyPair] +__all__ = [KeyPair, Ed25519WalletKeyPair, Bls12381G2WalletKeyPair] diff --git a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py index ff8fa652bc..c909cde747 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py +++ b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py @@ -15,6 +15,8 @@ class BbsBlsSignature2020(BbsBlsSignature2020Base): """BbsBlsSignature2020 class.""" + signature_type = "BbsBlsSignature2020" + def __init__( self, *, @@ -34,7 +36,7 @@ def __init__( date (datetime, optional): Signing date to use. Defaults to now """ - super().__init__(signature_type="BbsBlsSignature2020", proof=proof) + super().__init__(signature_type=BbsBlsSignature2020.signature_type, proof=proof) self.key_pair = key_pair self.verification_method = verification_method self.date = date diff --git a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py index 9fa4e1c060..f52f006654 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py +++ b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py @@ -4,7 +4,7 @@ from pyld import jsonld from typing import List - +from ..error import LinkedDataProofException from ..document_loader import DocumentLoader from .LinkedDataProof import LinkedDataProof @@ -49,3 +49,25 @@ def _canonize(self, *, input, document_loader: DocumentLoader = None) -> str: @abstractmethod def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None): """Canonize proof dictionary. Removes values that are not part of proof.""" + + def _assert_verification_method(self, verification_method: dict): + """Assert verification method. Throws if not ok.""" + required_key_type = "Bls12381G2Key2020" + if not jsonld.JsonLdProcessor.has_value( + verification_method, "type", required_key_type + ): + raise LinkedDataProofException( + f"Invalid key type. The key type must be {required_key_type}" + ) + + def _get_verification_method(self, *, proof: dict, document_loader: DocumentLoader): + """Get verification method. + + Overwrites base get verification method to assert key type. + """ + verification_method = super()._get_verification_method( + proof=proof, document_loader=document_loader + ) + self._assert_verification_method(verification_method) + + return verification_method diff --git a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py index 6fa38792a7..395da0b0a1 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py +++ b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py @@ -20,6 +20,8 @@ class BbsBlsSignatureProof2020(BbsBlsSignature2020Base): """BbsBlsSignatureProof2020 class.""" + signature_type = "BbsBlsSignatureProof2020" + def __init__( self, *, @@ -32,10 +34,10 @@ def __init__( """ super().__init__( - signature_type="BbsBlsSignatureProof2020", + signature_type=BbsBlsSignatureProof2020.signature_type, proof={ "@context": SECURITY_CONTEXT_V3_URL, - "type": "BbsBlsSignatureProof2020", + "type": BbsBlsSignatureProof2020.signature_type, }, supported_derive_proof_types=( BbsBlsSignatureProof2020.supported_derive_proof_types diff --git a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py index 39534f81ff..a71b796d30 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/JwsLinkedDataSignature.py @@ -172,7 +172,10 @@ def _assert_verification_method(self, verification_method: dict): ) def _get_verification_method(self, *, proof: dict, document_loader: DocumentLoader): - """Get verification method.""" + """Get verification method. + + Overwrites base get verification method to assert key type. + """ verification_method = super()._get_verification_method( proof=proof, document_loader=document_loader ) diff --git a/aries_cloudagent/vc/ld_proofs/suites/__init__.py b/aries_cloudagent/vc/ld_proofs/suites/__init__.py index c5a1fcbc72..a6d6c74578 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/suites/__init__.py @@ -2,10 +2,14 @@ from .LinkedDataSignature import LinkedDataSignature from .JwsLinkedDataSignature import JwsLinkedDataSignature from .Ed25519Signature2018 import Ed25519Signature2018 +from .BbsBlsSignature2020 import BbsBlsSignature2020 +from .BbsBlsSignatureProof2020 import BbsBlsSignatureProof2020 __all__ = [ LinkedDataProof, LinkedDataSignature, JwsLinkedDataSignature, Ed25519Signature2018, + BbsBlsSignature2020, + BbsBlsSignatureProof2020, ] diff --git a/aries_cloudagent/vc/vc_ld/prove.py b/aries_cloudagent/vc/vc_ld/prove.py index 71a45dc1fe..42e3a7b51a 100644 --- a/aries_cloudagent/vc/vc_ld/prove.py +++ b/aries_cloudagent/vc/vc_ld/prove.py @@ -33,6 +33,7 @@ async def create_presentation( Returns: dict: The unsigned presentation object + """ presentation = { "@context": [CREDENTIALS_CONTEXT_V1_URL], @@ -84,7 +85,8 @@ async def sign_presentation( """ if not purpose and not challenge: raise LinkedDataProofException( - 'A "challenge" param is required when not providing a "purpose" (for AuthenticationProofPurpose).' + 'A "challenge" param is required when not providing a' + ' "purpose" (for AuthenticationProofPurpose).' ) if not purpose: purpose = AuthenticationProofPurpose(challenge=challenge, domain=domain) diff --git a/aries_cloudagent/vc/vc_ld/validation_result.py b/aries_cloudagent/vc/vc_ld/validation_result.py index 9857ba94b8..4223e5062f 100644 --- a/aries_cloudagent/vc/vc_ld/validation_result.py +++ b/aries_cloudagent/vc/vc_ld/validation_result.py @@ -1,4 +1,4 @@ -"""Presentation verification and validation result classes""" +"""Presentation verification and validation result classes.""" from typing import List diff --git a/aries_cloudagent/vc/vc_ld/verify.py b/aries_cloudagent/vc/vc_ld/verify.py index 6f6e277f24..ae6988cfda 100644 --- a/aries_cloudagent/vc/vc_ld/verify.py +++ b/aries_cloudagent/vc/vc_ld/verify.py @@ -66,6 +66,7 @@ async def verify_credential( Returns: DocumentVerificationResult: The result of the verification. Verified property indicates whether the verification was successful + """ try: return await _verify_credential( @@ -134,7 +135,6 @@ async def _verify_presentation( verified=verified, presentation_result=presentation_result, credential_results=credential_results, - # TODO: should this also include credential results errors? errors=presentation_result.errors, ) From 44648dc24eef5b6244934b2cd480f1aed70c10f7 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sun, 28 Mar 2021 00:54:25 +0100 Subject: [PATCH 054/138] more flake worshipping Signed-off-by: Timo Glastra --- aries_cloudagent/did/did_key.py | 34 +++++--- aries_cloudagent/messaging/valid.py | 15 +++- .../ld_proof/models/cred_detail_schema.py | 38 ++++++-- .../v2_0/messages/cred_format.py | 8 +- .../presentation_exchange/tests/test_data.py | 87 ++++++++++--------- .../tests/test_pres_exch_handler.py | 7 -- .../storage/vc_holder/vc_record.py | 2 +- aries_cloudagent/wallet/crypto.py | 15 ++-- 8 files changed, 123 insertions(+), 83 deletions(-) diff --git a/aries_cloudagent/did/did_key.py b/aries_cloudagent/did/did_key.py index 4e84eedc9a..c5f8a439ea 100644 --- a/aries_cloudagent/did/did_key.py +++ b/aries_cloudagent/did/did_key.py @@ -53,7 +53,7 @@ def from_fingerprint(cls, fingerprint: str) -> "DIDKey": @classmethod def from_did(cls, did: str) -> "DIDKey": - """Initialize a new DIDKey instance from a fully qualified did:key string + """Initialize a new DIDKey instance from a fully qualified did:key string. Extracts the fingerprint from the did:key and uses that to constrcut the did:key. """ @@ -64,51 +64,52 @@ def from_did(cls, did: str) -> "DIDKey": @property def fingerprint(self) -> str: - """Getter for did key fingerprint""" + """Getter for did key fingerprint.""" prefixed_key_bytes = add_prefix(self.key_type.multicodec_name, self.public_key) return f"z{bytes_to_b58(prefixed_key_bytes)}" @property def did(self) -> str: - """Getter for full did:key string""" + """Getter for full did:key string.""" return f"did:key:{self.fingerprint}" @property def did_doc(self) -> dict: - """Getter for did document associated with did:key""" + """Getter for did document associated with did:key.""" resolver = DID_KEY_RESOLVERS[self.key_type] return resolver(self) @property def public_key(self) -> bytes: - """Getter for public key""" + """Getter for public key.""" return self._public_key @property def public_key_b58(self) -> str: - """Getter for base58 encoded public key""" + """Getter for base58 encoded public key.""" return bytes_to_b58(self.public_key) @property def key_type(self) -> KeyType: - """Getter for key type""" + """Getter for key type.""" return self._key_type @property def key_id(self) -> str: - """Getter for key id""" + """Getter for key id.""" return f"{self.did}#{self.fingerprint}" def construct_did_key_bls12381g2(did_key: "DIDKey") -> dict: - """Construct BLS12381G2 did:key + """Construct BLS12381G2 did:key. Args: did_key (DIDKey): did key instance to parse bls12381g2 did:key document from Returns: dict: The bls12381g2 did:key did document + """ return construct_did_signature_key_base( @@ -124,13 +125,14 @@ def construct_did_key_bls12381g2(did_key: "DIDKey") -> dict: def construct_did_key_bls12381g1(did_key: "DIDKey") -> dict: - """Construct BLS12381G1 did:key + """Construct BLS12381G1 did:key. Args: did_key (DIDKey): did key instance to parse bls12381g1 did:key document from Returns: dict: The bls12381g1 did:key did document + """ return construct_did_signature_key_base( @@ -146,13 +148,14 @@ def construct_did_key_bls12381g1(did_key: "DIDKey") -> dict: def construct_did_key_bls12381g1g2(did_key: "DIDKey") -> dict: - """Construct BLS12381G1G2 did:key + """Construct BLS12381G1G2 did:key. Args: did_key (DIDKey): did key instance to parse bls12381g1g2 did:key document from Returns: dict: The bls12381g1g2 did:key did document + """ g1_public_key = did_key.public_key[:48] @@ -190,13 +193,14 @@ def construct_did_key_bls12381g1g2(did_key: "DIDKey") -> dict: def construct_did_key_x25519(did_key: "DIDKey") -> dict: - """Construct X25519 did:key + """Construct X25519 did:key. Args: did_key (DIDKey): did key instance to parse x25519 did:key document from Returns: dict: The x25519 did:key did document + """ return { @@ -219,13 +223,14 @@ def construct_did_key_x25519(did_key: "DIDKey") -> dict: def construct_did_key_ed25519(did_key: "DIDKey") -> dict: - """Construct Ed25519 did:key + """Construct Ed25519 did:key. Args: did_key (DIDKey): did key instance to parse ed25519 did:key document from Returns: dict: The ed25519 did:key did document + """ curve25519 = ed25519_pk_to_curve25519(did_key.public_key) x25519 = DIDKey.from_public_key(curve25519, KeyType.X25519) @@ -257,9 +262,10 @@ def construct_did_key_ed25519(did_key: "DIDKey") -> dict: def construct_did_signature_key_base( *, id: str, key_id: str, verification_method: dict ): - """Creates base did key structure used for most signature keys. + """Create base did key structure to use for most signature keys. May not be suitable for all did key types + """ return { diff --git a/aries_cloudagent/messaging/valid.py b/aries_cloudagent/messaging/valid.py index 4ed16d5b93..c34140d14f 100644 --- a/aries_cloudagent/messaging/valid.py +++ b/aries_cloudagent/messaging/valid.py @@ -53,6 +53,7 @@ class UriOrDictField(StrOrDictField): """URI or Dict field for Marshmallow.""" def __init__(self, *args, **kwargs): + """Initialize new UriOrDictField instance.""" super().__init__(*args, **kwargs) # Insert validation into self.validators so that multiple errors can be stored. @@ -562,6 +563,7 @@ class Uri(Regexp): PATTERN = r"^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?" def __init__(self): + """Initializer.""" super().__init__(Uri.PATTERN, error="Value {input} is not URI") @@ -600,14 +602,17 @@ def __init__(self): class CredentialType(Validator): + """Credential Type.""" FIRST_TYPE = "VerifiableCredential" EXAMPLE = [FIRST_TYPE, "AlumniCredential"] def __init__(self) -> None: + """Initializer.""" super().__init__() def __call__(self, value): + """Validate input value.""" length = len(value) if length < 1 or value[0] != CredentialType.FIRST_TYPE: @@ -619,13 +624,17 @@ def __call__(self, value): class CredentialContext(Validator): + """Credential Context.""" + FIRST_CONTEXT = "https://www.w3.org/2018/credentials/v1" EXAMPLE = [FIRST_CONTEXT, "https://www.w3.org/2018/credentials/examples/v1"] def __init__(self) -> None: + """Initializer.""" super().__init__() def __call__(self, value): + """Validate input value.""" length = len(value) if length < 1 or value[0] != CredentialContext.FIRST_CONTEXT: @@ -637,6 +646,7 @@ def __call__(self, value): class CredentialSubject(Validator): + """Credential subject.""" EXAMPLE = { "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", @@ -644,9 +654,11 @@ class CredentialSubject(Validator): } def __init__(self) -> None: + """Initializer.""" super().__init__() def __call__(self, value): + """Validate input value.""" subjects = value if isinstance(value, list) else [value] for subject in subjects: @@ -663,7 +675,7 @@ def __call__(self, value): class IndyOrKeyDID(Regexp): - """""" + """Indy or Key DID class.""" PATTERN = re.compile("|".join([DIDKey.PATTERN, IndyDID.PATTERN])) EXAMPLE = IndyDID.EXAMPLE @@ -671,6 +683,7 @@ class IndyOrKeyDID(Regexp): def __init__( self, ): + """Initializer.""" super().__init__( IndyOrKeyDID.PATTERN, error="Value {input} is not in did:key or indy did format", diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail_schema.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail_schema.py index 9151a469a0..7724a5611f 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail_schema.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/models/cred_detail_schema.py @@ -1,4 +1,4 @@ -"""Linked data proof verifiable credential detail artifacts to attach to RFC 453 messages.""" +"""Linked data proof verifiable credential artifacts to attach to RFC 453 messages.""" from marshmallow import fields, Schema, INCLUDE, post_load, post_dump @@ -18,12 +18,16 @@ class Meta: type = fields.Str( required=True, - description="Credential status method type to use for the credential. Should match status method registered in the Verifiable Credential Extension Registry", + description=( + "Credential status method type to use for the credential. Should match" + " status method registered in the Verifiable Credential Extension Registry" + ), example="CredentialStatusList2017", ) @post_dump def remove_none_values(self, data, **kwargs): + """Remove none values.""" return {key: value for key, value in data.items() if value} @@ -38,20 +42,29 @@ class Meta: proof_type = fields.Str( data_key="proofType", required=True, - description="The proof type used for the proof. Should match suites registered in the Linked Data Cryptographic Suite Registry", + description=( + "The proof type used for the proof. Should match suites registered in" + " the Linked Data Cryptographic Suite Registry" + ), example="Ed25519Signature2018", ) proof_purpose = fields.Str( data_key="proofPurpose", required=False, - description="The proof purpose used for the proof. Should match proof purposes registered in the Linked Data Proofs Specification", + description=( + "The proof purpose used for the proof. Should match proof purposes registered" + " in the Linked Data Proofs Specification" + ), example="assertionMethod", ) created = fields.Str( required=False, - description="The date and time of the proof (with a maximum accuracy in seconds). Defaults to current system time", + description=( + "The date and time of the proof (with a maximum accuracy in seconds)." + " Defaults to current system time" + ), **INDY_ISO8601_DATETIME, ) @@ -63,7 +76,10 @@ class Meta: challenge = fields.Str( required=False, - description="A challenge to include in the proof. SHOULD be provided by the requesting party of the credential (=holder)", + description=( + "A challenge to include in the proof. SHOULD be provided by the" + " requesting party of the credential (=holder)" + ), example=UUIDFour.EXAMPLE, ) @@ -71,17 +87,23 @@ class Meta: CredentialStatusOptionsSchema(), data_key="credentialStatus", required=False, - description="The credential status mechanism to use for the credential. Omitting the property indicates the issued credential will not include a credential status", + description=( + "The credential status mechanism to use for the credential." + " Omitting the property indicates the issued credential" + " will not include a credential status" + ), ) @post_load def make_ld_proof_detail_options(self, data, **kwargs): + """Make LDProofDetailOptions.""" from .cred_detail import LDProofVCDetailOptions return LDProofVCDetailOptions(**data) @post_dump def remove_none_values(self, data, **kwargs): + """Remove none values.""" return {key: value for key, value in data.items() if value} @@ -107,10 +129,12 @@ class Meta: @post_load def make_ld_proof_detail(self, data, **kwargs): + """Make LDProofVCDetail.""" from .cred_detail import LDProofVCDetail return LDProofVCDetail(**data) @post_dump def remove_none_values(self, data, **kwargs): + """Remove none values.""" return {key: value for key, value in data.items() if value} diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py index 8bcbb5c20f..af9894f615 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py @@ -32,17 +32,17 @@ class Meta: class Format(Enum): """Attachment format.""" - from ..message_types import PROTOCOL_PACKAGE - INDY = FormatSpec( "hlindy/", V20CredExRecordIndy, - f"{PROTOCOL_PACKAGE}.formats.indy.handler.IndyCredFormatHandler", + "aries_cloudagent.protocols.issue_credential.v2_0" + ".formats.indy.handler.IndyCredFormatHandler", ) LD_PROOF = FormatSpec( "aries/", V20CredExRecordLDProof, - f"{PROTOCOL_PACKAGE}.formats.ld_proof.handler.LDProofCredFormatHandler", + "aries_cloudagent.protocols.issue_credential.v2_0" + ".formats.ld_proof.handler.LDProofCredFormatHandler", ) @classmethod diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_data.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_data.py index 7869a2efa1..0f2883ebd3 100644 --- a/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_data.py +++ b/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_data.py @@ -116,36 +116,34 @@ """ cred_json_4 = """ { - "vc": { - "@context": "https://www.w3.org/2018/credentials/v1", - "id": "https://eu.com/claims/DriversLicense", - "type": ["EUDriversLicense"], - "issuer": "did:example:123", - "issuanceDate": "2010-01-01T19:73:24Z", - "credentialSchema": { - "id": "https://eu.com/claims/DriversLicense.json", - "type": "JsonSchemaValidator2018" - }, - "credentialSubject": { - "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", - "accounts": [ - { - "id": "1234567890", - "route": "DE-9876543210" - }, - { - "id": "2457913570", - "route": "DE-0753197542" - } - ] - } + "@context": ["https://www.w3.org/2018/credentials/v1"], + "id": "https://eu.com/claims/DriversLicense", + "type": ["EUDriversLicense"], + "issuer": "did:example:123", + "issuanceDate": "2010-01-01T19:73:24Z", + "credentialSchema": { + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" + }, + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "accounts": [ + { + "id": "1234567890", + "route": "DE-9876543210" + }, + { + "id": "2457913570", + "route": "DE-0753197542" + } + ] } } """ cred_json_5 = """ { - "@context": "https://www.w3.org/2018/credentials/v1", + "@context": ["https://www.w3.org/2018/credentials/v1"], "id": "https://business-standards.org/schemas/employment-history.json", "type": ["VerifiableCredential", "GenericEmploymentCredential"], "issuer": "did:foo:123", @@ -170,7 +168,7 @@ """ cred_json_6 = """ { - "@context": "https://www.w3.org/2018/credentials/v1", + "@context": ["https://www.w3.org/2018/credentials/v1"], "id": "https://eu.com/claims/DriversLicense2", "type": ["EUDriversLicense"], "issuer": "did:foo:123", @@ -579,28 +577,33 @@ def get_test_data(): - creds_json_list = [] - creds_json_list.append(cred_json_1) - creds_json_list.append(cred_json_2) - creds_json_list.append(cred_json_3) - creds_json_list.append(cred_json_4) - creds_json_list.append(cred_json_5) - creds_json_list.append(cred_json_6) + creds_json_list = [ + cred_json_1, + cred_json_2, + cred_json_3, + cred_json_4, + cred_json_5, + cred_json_6, + ] vc_record_list = [] - for tmp_cred in creds_json_list: - vc_record_list.append(VCRecord.deserialize_jsonld_cred(tmp_cred)) + for cred in creds_json_list: + vc_record_list.append(VCRecord.deserialize_jsonld_cred(cred)) - pd_json_list = [] - pd_json_list.append((pres_exch_nested_srs, 5)) - pd_json_list.append((pres_exch_multiple_srs_not_met, 0)) - pd_json_list.append((pres_exch_multiple_srs_met, 2)) - pd_json_list.append((pres_exch_datetime_minimum_not_met, 0)) - pd_json_list.append((pres_exch_number_const_met, 2)) + pd_json_list = [ + (pres_exch_nested_srs, 5), + (pres_exch_multiple_srs_not_met, 0), + (pres_exch_multiple_srs_met, 2), + (pres_exch_datetime_minimum_not_met, 0), + (pres_exch_number_const_met, 2), + ] pd_list = [] - for tmp_pd in pd_json_list: + for pd in pd_json_list: pd_list.append( - (PresentationDefinition.deserialize(json.loads(tmp_pd[0])), tmp_pd[1]) + ( + PresentationDefinition.deserialize(json.loads(pd[0])), + pd[1], + ) ) return (vc_record_list, pd_list) diff --git a/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_pres_exch_handler.py b/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_pres_exch_handler.py index 0b3fa41dcd..896d1ee3c6 100644 --- a/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_pres_exch_handler.py +++ b/aries_cloudagent/protocols/present_proof/presentation_exchange/tests/test_pres_exch_handler.py @@ -1,16 +1,10 @@ -import json import pytest -from asynctest import mock as async_mock from asynctest import TestCase as AsyncTestCase from copy import deepcopy -from time import time from unittest import TestCase -from uuid import uuid4 -from .....storage.vc_holder.vc_record import VCRecord - from ..pres_exch import ( PresentationDefinition, Requirement, @@ -18,7 +12,6 @@ SchemaInputDescriptor, ) from ..pres_exch_handler import ( - to_requirement, make_requirement, is_len_applicable, exclusive_maximum_check, diff --git a/aries_cloudagent/storage/vc_holder/vc_record.py b/aries_cloudagent/storage/vc_holder/vc_record.py index 6e4df75be5..38785be5b4 100644 --- a/aries_cloudagent/storage/vc_holder/vc_record.py +++ b/aries_cloudagent/storage/vc_holder/vc_record.py @@ -122,7 +122,7 @@ def deserialize_jsonld_cred(cls, cred_json_str: str) -> "VCRecord": subjects = [subjects] subject_ids = [subject.get("id") for subject in subjects if subject.get("id")] - schemas = cred_dict.get("schemas") + schemas = cred_dict.get("credentialsSchema", []) if type(schemas) is dict: schemas = [schemas] schema_ids = [schema.get("id") for schema in schemas] diff --git a/aries_cloudagent/wallet/crypto.py b/aries_cloudagent/wallet/crypto.py index 5acba82acd..304f7a577d 100644 --- a/aries_cloudagent/wallet/crypto.py +++ b/aries_cloudagent/wallet/crypto.py @@ -29,7 +29,7 @@ class KeyTypeException(BaseException): class KeyType(Enum): - """KeyType Enum specifying key types with multicodec name""" + """KeyType Enum specifying key types with multicodec name.""" ED25519 = KeySpec("ed25519", "ed25519-pub") X25519 = KeySpec("x25519", "x25519-pub") @@ -39,12 +39,12 @@ class KeyType(Enum): @property def key_type(self) -> str: - """Getter for key type identifier""" + """Getter for key type identifier.""" return self.value.key_type @property def multicodec_name(self) -> str: - """Getter for multicodec name""" + """Getter for multicodec name.""" return self.value.multicodec_name @classmethod @@ -83,16 +83,16 @@ class DIDMethod(Enum): @property def method_name(self) -> str: - """Getter for did method name. e.g. sov or key""" + """Getter for did method name. e.g. sov or key.""" return self.value.method_name @property def supported_key_types(self) -> List[KeyType]: - """Getter for supported key types of method""" + """Getter for supported key types of method.""" return self.value.supported_key_types def supports_key_type(self, key_type: KeyType) -> bool: - """Check whether the current method supports the key type""" + """Check whether the current method supports the key type.""" return key_type in self.supported_key_types def from_metadata(metadata: Mapping) -> "DIDMethod": @@ -112,7 +112,7 @@ def from_metadata(metadata: Mapping) -> "DIDMethod": return DIDMethod.SOV def from_method(method: str) -> Optional["DIDMethod"]: - """Get DID method instance from the method name""" + """Get DID method instance from the method name.""" for did_method in DIDMethod: if method == did_method.method_name: return did_method @@ -392,6 +392,7 @@ def prepare_pack_recipient_keys( def ed25519_pk_to_curve25519(public_key: bytes) -> bytes: + """Covert a public Ed25519 key to a public Curve25519 key as bytes.""" return nacl.bindings.crypto_sign_ed25519_pk_to_curve25519(public_key) From ece1588afec4d3f6793e243b057a194271784f3b Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sun, 28 Mar 2021 15:25:24 +0200 Subject: [PATCH 055/138] flake, intitial attempt to fix some tests Signed-off-by: Timo Glastra --- .../v2_0/messages/cred_format.py | 7 ++--- .../v2_0/tests/test_routes.py | 26 ++++++++++--------- .../storage/vc_holder/tests/test_vc_record.py | 24 ++++++++--------- .../vc/vc_ld/models/credential.py | 2 +- .../vc/vc_ld/models/credential_schema.py | 5 +++- aries_cloudagent/vc/vc_ld/prove.py | 2 ++ aries_cloudagent/wallet/routes.py | 2 +- aries_cloudagent/wallet/tests/test_routes.py | 13 +++++++++- 8 files changed, 47 insertions(+), 34 deletions(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py index af9894f615..7b0700621f 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py @@ -75,11 +75,8 @@ def detail(self) -> str: @property def handler(self) -> Type["V20CredFormatHandler"]: """Accessor for credential exchange format handler.""" - # stupid cyclic imports - if not self._class: - self._class = ClassLoader.load_class(self.value.handler) - - return self._class + # TODO: optimize + return ClassLoader.load_class(self.value.handler) def validate_fields(self, message_type: str, attachment_data: Mapping): """Raise ValidationError for invalid attachment formats.""" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py index 624e83a1d3..68976f8cf5 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py @@ -1,6 +1,8 @@ from asynctest import TestCase as AsyncTestCase from asynctest import mock as async_mock +from ..formats.indy.handler import IndyCredFormatHandler +from ..formats.ld_proof.handler import LDProofCredFormatHandler from .....admin.request_context import AdminRequestContext from .....wallet.base import BaseWallet, DIDInfo @@ -995,7 +997,7 @@ async def test_credential_exchange_send_request(self): async_mock.MagicMock(), ) - await test_module.credential_exchange_send_request(self.request) + await test_module.credential_exchange_send_bound_request(self.request) mock_response.assert_called_once_with(mock_cx_rec.serialize.return_value) @@ -1012,7 +1014,7 @@ async def test_credential_exchange_send_request_bad_cred_ex_id(self): mock_cx_rec.retrieve_by_id.side_effect = test_module.StorageNotFoundError() with self.assertRaises(test_module.web.HTTPNotFound): - await test_module.credential_exchange_send_request(self.request) + await test_module.credential_exchange_send_bound_request(self.request) async def test_credential_exchange_send_request_no_conn_record(self): self.request.json = async_mock.CoroutineMock() @@ -1044,7 +1046,7 @@ async def test_credential_exchange_send_request_no_conn_record(self): ) with self.assertRaises(test_module.web.HTTPBadRequest): - await test_module.credential_exchange_send_request(self.request) + await test_module.credential_exchange_send_bound_request(self.request) async def test_credential_exchange_send_request_not_ready(self): self.request.json = async_mock.CoroutineMock() @@ -1075,7 +1077,7 @@ async def test_credential_exchange_send_request_not_ready(self): ) with self.assertRaises(test_module.web.HTTPForbidden): - await test_module.credential_exchange_send_request(self.request) + await test_module.credential_exchange_send_bound_request(self.request) async def test_credential_exchange_issue(self): self.request.json = async_mock.CoroutineMock() @@ -1318,7 +1320,11 @@ async def test_credential_exchange_store_bad_cred_id_json(self): test_module, "V20CredExRecord", autospec=True ) as mock_cls_cx_rec, async_mock.patch.object( test_module.web, "json_response" - ) as mock_response: + ) as mock_response, async_mock.patch.object( + LDProofCredFormatHandler, "get_detail_record", autospec=True + ) as mock_ld_proof_get_detail_record, async_mock.patch.object( + IndyCredFormatHandler, "get_detail_record", autospec=True + ) as mock_indy_get_detail_record: mock_cls_cx_rec.retrieve_by_id = async_mock.CoroutineMock() mock_cls_cx_rec.retrieve_by_id.return_value.state = ( @@ -1327,14 +1333,10 @@ async def test_credential_exchange_store_bad_cred_id_json(self): mock_cx_rec = async_mock.MagicMock() - mock_cred_mgr.return_value.get_detail_record = async_mock.CoroutineMock( - side_effect=[ - async_mock.MagicMock( # indy - serialize=async_mock.MagicMock(return_value={"...": "..."}) - ), - None, # ld_proof - ] + mock_indy_get_detail_record.return_value = async_mock.MagicMock( # indy + serialize=async_mock.MagicMock(return_value={"...": "..."}) ) + mock_ld_proof_get_detail_record.return_value = None # ld_proof mock_cred_mgr.return_value.store_credential.return_value = ( mock_cx_rec, diff --git a/aries_cloudagent/storage/vc_holder/tests/test_vc_record.py b/aries_cloudagent/storage/vc_holder/tests/test_vc_record.py index c5bb037f5c..59ba7fa89a 100644 --- a/aries_cloudagent/storage/vc_holder/tests/test_vc_record.py +++ b/aries_cloudagent/storage/vc_holder/tests/test_vc_record.py @@ -7,30 +7,28 @@ sample_json_cred_1 = """ { - "vc": { - "@context": "https://www.w3.org/2018/credentials/v1", + "@context": ["https://www.w3.org/2018/credentials/v1"], "id": "https://eu.com/claims/DriversLicense", "type": ["EUDriversLicense"], "issuer": "did:example:123", "issuanceDate": "2010-01-01T19:73:24Z", "credentialSchema": { - "id": "https://eu.com/claims/DriversLicense.json", - "type": "JsonSchemaValidator2018" + "id": "https://eu.com/claims/DriversLicense.json", + "type": "JsonSchemaValidator2018" }, "credentialSubject": { - "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", - "accounts": [ + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "accounts": [ { - "id": "1234567890", - "route": "DE-9876543210" + "id": "1234567890", + "route": "DE-9876543210" }, { - "id": "2457913570", - "route": "DE-0753197542" + "id": "2457913570", + "route": "DE-0753197542" } - ] + ] } - } } """ sample_json_cred_2 = """ @@ -88,7 +86,7 @@ }, "issuanceDate": "2010-01-01T19:73:24Z", "credentialSchema": "https://example.org/examples/degree.json", - "credentialSubject": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "credentialSubject": { "id": "did:example:ebfeb1f712ebc6f1c276e12ec21" }, "proof": { "type": "RsaSignature2018", "created": "2017-06-18T21:19:10Z", diff --git a/aries_cloudagent/vc/vc_ld/models/credential.py b/aries_cloudagent/vc/vc_ld/models/credential.py index b43ffc7147..ecfaf1b4c6 100644 --- a/aries_cloudagent/vc/vc_ld/models/credential.py +++ b/aries_cloudagent/vc/vc_ld/models/credential.py @@ -1,4 +1,4 @@ -"""Verifiable Credential model classes""" +"""Verifiable Credential model classes.""" from marshmallow import ValidationError import copy diff --git a/aries_cloudagent/vc/vc_ld/models/credential_schema.py b/aries_cloudagent/vc/vc_ld/models/credential_schema.py index 464d40bd00..8594814c3e 100644 --- a/aries_cloudagent/vc/vc_ld/models/credential_schema.py +++ b/aries_cloudagent/vc/vc_ld/models/credential_schema.py @@ -151,7 +151,10 @@ class Meta: issuer = StrOrDictField( required=True, - description="The JSON-LD Verifiable Credential Issuer. Either string of object with id field.", + description=( + "The JSON-LD Verifiable Credential Issuer." + " Either string of object with id field." + ), example=DIDKey.EXAMPLE, ) diff --git a/aries_cloudagent/vc/vc_ld/prove.py b/aries_cloudagent/vc/vc_ld/prove.py index 42e3a7b51a..adf872d0da 100644 --- a/aries_cloudagent/vc/vc_ld/prove.py +++ b/aries_cloudagent/vc/vc_ld/prove.py @@ -82,6 +82,7 @@ async def sign_presentation( Returns: dict: A verifiable presentation object + """ if not purpose and not challenge: raise LinkedDataProofException( @@ -121,6 +122,7 @@ async def derive_credential( Returns: dict: The derived credential. + """ return await derive( diff --git a/aries_cloudagent/wallet/routes.py b/aries_cloudagent/wallet/routes.py index 9357071878..495ad9c344 100644 --- a/aries_cloudagent/wallet/routes.py +++ b/aries_cloudagent/wallet/routes.py @@ -163,7 +163,7 @@ def format_did_info(info: DIDInfo): "did": info.did, "verkey": info.verkey, "posture": DIDPosture.get(info.metadata).moniker, - "key_type": key_type, + "key_type": key_type.key_type, } diff --git a/aries_cloudagent/wallet/tests/test_routes.py b/aries_cloudagent/wallet/tests/test_routes.py index c41979145f..85ab621b5f 100644 --- a/aries_cloudagent/wallet/tests/test_routes.py +++ b/aries_cloudagent/wallet/tests/test_routes.py @@ -7,7 +7,7 @@ from ...ledger.base import BaseLedger from ...wallet.base import BaseWallet, DIDInfo from ...multitenant.manager import MultitenantManager - +from ...wallet.crypto import KeyType from .. import routes as test_module from ..did_posture import DIDPosture @@ -91,6 +91,7 @@ async def test_create_did(self): "did": self.test_did, "verkey": self.test_verkey, "posture": DIDPosture.WALLET_ONLY.moniker, + "key_type": KeyType.ED25519.key_type, } } ) @@ -123,11 +124,13 @@ async def test_did_list(self): "did": self.test_posted_did, "verkey": self.test_posted_verkey, "posture": DIDPosture.POSTED.moniker, + "key_type": KeyType.ED25519.key_type, }, { "did": self.test_did, "verkey": self.test_verkey, "posture": DIDPosture.WALLET_ONLY.moniker, + "key_type": KeyType.ED25519.key_type, }, ] } @@ -158,6 +161,7 @@ async def test_did_list_filter_public(self): "did": self.test_did, "verkey": self.test_verkey, "posture": DIDPosture.PUBLIC.moniker, + "key_type": KeyType.ED25519.key_type, } ] } @@ -191,6 +195,7 @@ async def test_did_list_filter_posted(self): "did": self.test_posted_did, "verkey": self.test_posted_verkey, "posture": DIDPosture.POSTED.moniker, + "key_type": KeyType.ED25519.key_type, } ] } @@ -214,6 +219,7 @@ async def test_did_list_filter_did(self): "did": self.test_did, "verkey": self.test_verkey, "posture": DIDPosture.WALLET_ONLY.moniker, + "key_type": KeyType.ED25519.key_type, } ] } @@ -248,6 +254,7 @@ async def test_did_list_filter_verkey(self): "did": self.test_did, "verkey": self.test_verkey, "posture": DIDPosture.WALLET_ONLY.moniker, + "key_type": KeyType.ED25519.key_type, } ] } @@ -280,6 +287,7 @@ async def test_get_public_did(self): "did": self.test_did, "verkey": self.test_verkey, "posture": DIDPosture.PUBLIC.moniker, + "key_type": KeyType.ED25519.key_type, } } ) @@ -316,6 +324,7 @@ async def test_set_public_did(self): "did": self.test_did, "verkey": self.test_verkey, "posture": DIDPosture.PUBLIC.moniker, + "key_type": KeyType.ED25519.key_type, } } ) @@ -453,6 +462,7 @@ async def test_set_public_did_update_endpoint(self): "did": self.test_did, "verkey": self.test_verkey, "posture": DIDPosture.PUBLIC.moniker, + "key_type": KeyType.ED25519.key_type, } } ) @@ -492,6 +502,7 @@ async def test_set_public_did_update_endpoint_use_default_update_in_wallet(self) "did": self.test_did, "verkey": self.test_verkey, "posture": DIDPosture.PUBLIC.moniker, + "key_type": KeyType.ED25519.key_type, } } ) From 4f5bc68f3129d0a744edd62cdbc9acbfdac837cf Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sun, 28 Mar 2021 15:38:28 +0200 Subject: [PATCH 056/138] fix some more tests Signed-off-by: Timo Glastra --- .../v2_0/handlers/cred_request_handler.py | 23 ++++++++++++++++++- .../v2_0/models/detail/tests/test_ld_proof.py | 10 ++++---- .../v2_0/tests/test_manager.py | 1 + 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py index 8d490f00fd..ee47d7be6a 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/handlers/cred_request_handler.py @@ -1,5 +1,11 @@ """Credential request message handler.""" +from aries_cloudagent.protocols.issue_credential.v2_0.messages.cred_proposal import ( + V20CredProposal, +) +from aries_cloudagent.protocols.issue_credential.v2_0.messages.cred_format import ( + V20CredFormat, +) from .....messaging.base_handler import ( BaseHandler, BaseResponder, @@ -51,7 +57,22 @@ async def handle(self, context: RequestContext, responder: BaseResponder): # If auto_issue is enabled, respond immediately if cred_ex_record.auto_issue: - if cred_ex_record.cred_proposal or cred_ex_record.cred_request: + cred_formats = [ + V20CredFormat.Format.get(format.format) + for format in context.message.formats + ] + + # TODO: this should be removed here and handled in the format + # specific handler. This way we don't bloat this file + can_respond_indy = ( + V20CredFormat.Format.INDY in cred_formats + and V20CredProposal.deserialize( + cred_ex_record.cred_proposal + ).credential_preview + ) + can_respond_ld_proof = V20CredFormat.Format.LD_PROOF in cred_formats + + if can_respond_indy or can_respond_ld_proof: ( cred_ex_record, cred_issue_message, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/tests/test_ld_proof.py b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/tests/test_ld_proof.py index 1c186befc5..0c6e1ad2a1 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/tests/test_ld_proof.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/tests/test_ld_proof.py @@ -6,16 +6,16 @@ class TestV20CredExRecordLDProof(AsyncTestCase): async def test_record(self): same = [ - V20CredExRecordLDProof( - cred_ex_ld_proof_id="dummy-0", cred_ex_id="abc", item="my-item" - ) + V20CredExRecordLDProof(cred_ex_ld_proof_id="dummy-0", cred_ex_id="abc") ] * 2 diff = [ V20CredExRecordLDProof( - cred_ex_ld_proof_id="dummy-0", cred_ex_id="def", item="my-cred" + cred_ex_ld_proof_id="dummy-0", + cred_ex_id="def", ), V20CredExRecordLDProof( - cred_ex_ld_proof_id="dummy-0", cred_ex_id="abc", item="your-item" + cred_ex_ld_proof_id="dummy-0", + cred_ex_id="abc", ), ] diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py index 63572e81cf..53c201c563 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py @@ -937,6 +937,7 @@ async def test_receive_request(self): connection_id=connection_id, initiator=V20CredExRecord.INITIATOR_EXTERNAL, role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_OFFER_SENT, ) cred_request = V20CredRequest( formats=[ From 5b1f7570658bb62bd11e86b6b172013acf67e037 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sun, 28 Mar 2021 19:32:56 +0200 Subject: [PATCH 057/138] first pass at adding bls key support Signed-off-by: Timo Glastra --- aries_cloudagent/ledger/tests/test_indy.py | 31 ++- .../multitenant/tests/test_manager.py | 9 +- .../protocols/connections/v1_0/manager.py | 18 +- .../connections/v1_0/tests/test_manager.py | 116 ++++++++-- .../didexchange/v1_0/tests/test_manager.py | 44 +++- .../v1_0/tests/test_routes.py | 113 ++++++++-- .../v1_0/tests/test_routes.py | 57 ++++- .../v2_0/tests/test_routes.py | 57 ++++- .../out_of_band/v1_0/tests/test_manager.py | 60 ++++- .../utils/tests/test_outofband.py | 5 +- .../crypto/Bls12381G2WalletKeyPair.py | 1 + aries_cloudagent/wallet/base.py | 68 ++++-- aries_cloudagent/wallet/crypto.py | 213 +++++++++++++++++- aries_cloudagent/wallet/in_memory.py | 130 ++++++++--- aries_cloudagent/wallet/indy.py | 13 +- .../wallet/models/key_pair_record.py | 90 ++++++++ .../models/tests/test_key_pair_record.py | 42 ++++ aries_cloudagent/wallet/tests/test_crypto.py | 4 +- aries_cloudagent/wallet/tests/test_routes.py | 143 ++++++++++-- 19 files changed, 1048 insertions(+), 166 deletions(-) create mode 100644 aries_cloudagent/wallet/models/key_pair_record.py create mode 100644 aries_cloudagent/wallet/models/tests/test_key_pair_record.py diff --git a/aries_cloudagent/ledger/tests/test_indy.py b/aries_cloudagent/ledger/tests/test_indy.py index ebd680d4a1..de2d8b09ea 100644 --- a/aries_cloudagent/ledger/tests/test_indy.py +++ b/aries_cloudagent/ledger/tests/test_indy.py @@ -13,6 +13,7 @@ from ...wallet.base import DIDInfo from ...wallet.did_posture import DIDPosture from ...wallet.error import WalletNotFoundError +from ...wallet.crypto import DIDMethod, KeyType from ..endpoint_type import EndpointType from ..indy import ( @@ -88,7 +89,11 @@ async def test_provide(self): class TestIndySdkLedger(AsyncTestCase): test_did = "55GkHamhTU1ZbTbV2ab9DE" test_did_info = DIDInfo( - test_did, "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx", None + did=test_did, + verkey="3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx", + metadata=None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) test_verkey = "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx" @@ -1073,7 +1078,11 @@ async def test_send_credential_definition( mock_wallet.get_public_did = async_mock.CoroutineMock() mock_wallet.get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + did=self.test_did, + verkey=self.test_verkey, + metadata=None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_did = mock_wallet.get_public_did.return_value @@ -1156,7 +1165,11 @@ async def test_send_credential_definition_exists_in_ledger_and_wallet( async with ledger: mock_wallet.get_public_did = async_mock.CoroutineMock() mock_wallet.get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + did=self.test_did, + verkey=self.test_verkey, + metadata=None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_did = mock_wallet.get_public_did.return_value @@ -1564,7 +1577,11 @@ async def test_send_credential_definition_on_ledger_in_wallet( mock_wallet.get_public_did = async_mock.CoroutineMock() mock_wallet.get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + did=self.test_did, + verkey=self.test_verkey, + metadata=None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_did = mock_wallet.get_public_did.return_value @@ -1636,7 +1653,11 @@ async def test_send_credential_definition_create_cred_def_exception( async with ledger: mock_wallet.get_public_did = async_mock.CoroutineMock() mock_wallet.get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + did=self.test_did, + verkey=self.test_verkey, + metadata=None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) with self.assertRaises(LedgerError): diff --git a/aries_cloudagent/multitenant/tests/test_manager.py b/aries_cloudagent/multitenant/tests/test_manager.py index 6aab8ac9a9..d5d4ff1833 100644 --- a/aries_cloudagent/multitenant/tests/test_manager.py +++ b/aries_cloudagent/multitenant/tests/test_manager.py @@ -17,6 +17,7 @@ MediationRecord, MediationManager, ) +from ...wallet.crypto import DIDMethod, KeyType from ..manager import MultitenantManager, MultitenantManagerError from ..error import WalletKeyMissingError @@ -371,7 +372,13 @@ async def test_create_wallet_saves_wallet_record_creates_profile(self): assert wallet_record.wallet_key == "test_key" async def test_create_wallet_adds_wallet_route(self): - did_info = DIDInfo("public-did", "test_verkey", {"meta": "data"}) + did_info = DIDInfo( + did="public-did", + verkey="test_verkey", + metadata={"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) with async_mock.patch.object( WalletRecord, "save" diff --git a/aries_cloudagent/protocols/connections/v1_0/manager.py b/aries_cloudagent/protocols/connections/v1_0/manager.py index 8b437dd8a4..dca729d0c9 100644 --- a/aries_cloudagent/protocols/connections/v1_0/manager.py +++ b/aries_cloudagent/protocols/connections/v1_0/manager.py @@ -4,10 +4,6 @@ from typing import Coroutine, Sequence, Tuple -from aries_cloudagent.protocols.coordinate_mediation.v1_0.manager import ( - MediationManager, -) - from ....cache.base import BaseCache from ....config.base import InjectionError from ....connections.base_manager import BaseConnectionManager @@ -21,7 +17,13 @@ from ....storage.error import StorageError, StorageNotFoundError from ....transport.inbound.receipt import MessageReceipt from ....wallet.base import BaseWallet, DIDInfo -from ....wallet.crypto import create_keypair, seed_to_did +from ....wallet.crypto import ( + DIDMethod, + KeyType, + create_keypair, + seed_to_did, +) +from ...coordinate_mediation.v1_0.manager import MediationManager from ....wallet.error import WalletNotFoundError from ....wallet.util import bytes_to_b58 from ....multitenant.manager import MultitenantManager @@ -883,9 +885,11 @@ async def create_static_connection( if not their_did: their_did = seed_to_did(their_seed) if not their_verkey: - their_verkey_bin, _ = create_keypair(their_seed.encode()) + their_verkey_bin, _ = create_keypair(KeyType.ED25519, their_seed.encode()) their_verkey = bytes_to_b58(their_verkey_bin) - their_info = DIDInfo(their_did, their_verkey, {}) + their_info = DIDInfo( + their_did, their_verkey, {}, method=DIDMethod.SOV, key_type=KeyType.ED25519 + ) # Create connection record connection = ConnRecord( diff --git a/aries_cloudagent/protocols/connections/v1_0/tests/test_manager.py b/aries_cloudagent/protocols/connections/v1_0/tests/test_manager.py index ee768efb62..fdd0ec8667 100644 --- a/aries_cloudagent/protocols/connections/v1_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/connections/v1_0/tests/test_manager.py @@ -20,7 +20,7 @@ from .....wallet.base import DIDInfo, KeyInfo from .....wallet.error import WalletNotFoundError from .....wallet.in_memory import InMemoryWallet -from .....wallet.crypto import KeyType +from .....wallet.crypto import DIDMethod, KeyType from .....did.did_key import DIDKey from ....coordinate_mediation.v1_0.models.mediation_record import MediationRecord from ....coordinate_mediation.v1_0.manager import MediationManager @@ -98,7 +98,11 @@ async def test_create_invitation_public_and_multi_use_fails(self): InMemoryWallet, "get_public_did", autospec=True ) as mock_wallet_get_public_did: mock_wallet_get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + self.test_did, + self.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) with self.assertRaises(ConnectionManagerError): await self.manager.create_invitation(public=True, multi_use=True) @@ -139,7 +143,11 @@ async def test_create_invitation_public(self): InMemoryWallet, "get_public_did", autospec=True ) as mock_wallet_get_public_did: mock_wallet_get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + self.test_did, + self.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) connect_record, connect_invite = await self.manager.create_invitation( public=True, my_endpoint="testendpoint" @@ -157,7 +165,7 @@ async def test_create_invitation_multitenant(self): InMemoryWallet, "create_signing_key", autospec=True ) as mock_wallet_create_signing_key: mock_wallet_create_signing_key.return_value = KeyInfo( - self.test_verkey, None + self.test_verkey, None, KeyType.ED25519 ) await self.manager.create_invitation() self.multitenant_mgr.add_key.assert_called_once_with( @@ -177,7 +185,11 @@ async def test_create_invitation_public_multitenant(self): InMemoryWallet, "get_public_did", autospec=True ) as mock_wallet_get_public_did: mock_wallet_get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + self.test_did, + self.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) await self.manager.create_invitation(public=True) self.multitenant_mgr.add_key.assert_called_once_with( @@ -269,7 +281,11 @@ async def test_create_invitation_public_and_metadata_fails(self): InMemoryWallet, "get_public_did", autospec=True ) as mock_wallet_get_public_did: mock_wallet_get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + self.test_did, + self.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) with self.assertRaises(ConnectionManagerError): await self.manager.create_invitation( @@ -471,7 +487,11 @@ async def test_create_request_multitenant(self): InMemoryWallet, "create_local_did", autospec=True ) as mock_wallet_create_local_did: mock_wallet_create_local_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + self.test_did, + self.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) await self.manager.create_request( ConnRecord( @@ -513,7 +533,13 @@ async def test_create_request_mediation_id(self): MediationManager, "get_default_mediator" ) as mock_get_default_mediator: - did_info = DIDInfo(did=self.test_did, verkey=self.test_verkey, metadata={}) + did_info = DIDInfo( + did=self.test_did, + verkey=self.test_verkey, + metadata={}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) create_local_did.return_value = did_info await self.manager.create_request( record, @@ -568,7 +594,13 @@ async def test_create_request_default_mediator(self): async_mock.CoroutineMock(return_value=mediation_record), ) as mock_get_default_mediator: - did_info = DIDInfo(did=self.test_did, verkey=self.test_verkey, metadata={}) + did_info = DIDInfo( + did=self.test_did, + verkey=self.test_verkey, + metadata={}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) create_local_did.return_value = did_info await self.manager.create_request( record, @@ -678,7 +710,11 @@ async def test_receive_request_multi_use_multitenant(self): InMemoryWallet, "create_local_did", autospec=True ) as mock_wallet_create_local_did: mock_wallet_create_local_did.return_value = DIDInfo( - new_info.did, new_info.verkey, None + new_info.did, + new_info.verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_conn_retrieve_by_invitation_key.return_value = async_mock.MagicMock( connection_id="dummy", @@ -722,10 +758,18 @@ async def test_receive_request_public_multitenant(self): InMemoryWallet, "get_local_did", autospec=True ) as mock_wallet_get_local_did: mock_wallet_create_local_did.return_value = DIDInfo( - new_info.did, new_info.verkey, None + new_info.did, + new_info.verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_wallet_get_local_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + self.test_did, + self.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) await self.manager.receive_request(mock_request, receipt) @@ -979,7 +1023,11 @@ async def test_create_response_multitenant(self): InMemoryWallet, "create_local_did", autospec=True ) as mock_wallet_create_local_did: mock_wallet_create_local_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + self.test_did, + self.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) await self.manager.create_response( ConnRecord( @@ -1337,7 +1385,11 @@ async def test_create_static_connection_multitenant(self): InMemoryWallet, "create_local_did", autospec=True ) as mock_wallet_create_local_did: mock_wallet_create_local_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + self.test_did, + self.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) await self.manager.create_static_connection( @@ -1368,7 +1420,11 @@ async def test_create_static_connection_multitenant_mediator(self): ConnectionManager, "store_did_document" ) as store_did_document: mock_wallet_create_local_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + self.test_did, + self.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) # With default mediator @@ -1391,7 +1447,13 @@ async def test_create_static_connection_multitenant_mediator(self): assert self.multitenant_mgr.add_key.call_count is 2 - their_info = DIDInfo(self.test_target_did, self.test_target_verkey, {}) + their_info = DIDInfo( + self.test_target_did, + self.test_target_verkey, + {}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) create_did_document.assert_has_calls( [ call( @@ -1547,7 +1609,11 @@ async def test_resolve_inbound_connection(self): self.manager, "find_connection", async_mock.CoroutineMock() ) as mock_mgr_find_conn: mock_wallet_get_local_did_for_verkey.return_value = DIDInfo( - self.test_did, self.test_verkey, {"public": True} + self.test_did, + self.test_verkey, + {"public": True}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_mgr_find_conn.return_value = mock_conn @@ -1598,6 +1664,8 @@ async def test_create_did_document(self): self.test_did, self.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_conn = async_mock.MagicMock( @@ -1629,6 +1697,8 @@ async def test_create_did_document_not_active(self): self.test_did, self.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_conn = async_mock.MagicMock( @@ -1655,6 +1725,8 @@ async def test_create_did_document_no_services(self): self.test_did, self.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_conn = async_mock.MagicMock( @@ -1688,6 +1760,8 @@ async def test_create_did_document_no_service_endpoint(self): self.test_did, self.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_conn = async_mock.MagicMock( @@ -1724,6 +1798,8 @@ async def test_create_did_document_no_service_recip_keys(self): self.test_did, self.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_conn = async_mock.MagicMock( @@ -1768,6 +1844,8 @@ async def test_create_did_document_mediation(self): self.test_did, self.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mediation_record = MediationRecord( role=MediationRecord.ROLE_CLIENT, @@ -1792,6 +1870,8 @@ async def test_create_did_document_multiple_mediators(self): self.test_did, self.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mediation_record1 = MediationRecord( role=MediationRecord.ROLE_CLIENT, @@ -1823,6 +1903,8 @@ async def test_create_did_document_mediation_svc_endpoints_overwritten(self): self.test_did, self.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mediation_record = MediationRecord( role=MediationRecord.ROLE_CLIENT, 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 908a731963..a861831d12 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/tests/test_manager.py @@ -22,7 +22,7 @@ from .....multitenant.manager import MultitenantManager from .....wallet.base import DIDInfo from .....wallet.in_memory import InMemoryWallet -from .....wallet.crypto import KeyType +from .....wallet.crypto import DIDMethod, KeyType from .....did.did_key import DIDKey from .....connections.base_manager import ( @@ -274,7 +274,11 @@ async def test_create_request_multitenant(self): serialize=async_mock.MagicMock(return_value={}) ) mock_wallet_create_local_did.return_value = DIDInfo( - TestConfig.test_did, TestConfig.test_verkey, None + TestConfig.test_did, + TestConfig.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_attach_deco.data_base64 = async_mock.MagicMock( return_value=async_mock.MagicMock( @@ -1087,7 +1091,11 @@ async def test_receive_request_multiuse_multitenant(self): return_value=mock_conn_rec ) mock_wallet_create_local_did.return_value = DIDInfo( - new_info.did, new_info.verkey, None + new_info.did, + new_info.verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_did_doc.from_json = async_mock.MagicMock( return_value=async_mock.MagicMock(did=TestConfig.test_did) @@ -1163,13 +1171,21 @@ async def test_receive_request_implicit_multitenant(self): ) mock_wallet_create_local_did.return_value = DIDInfo( - new_info.did, new_info.verkey, None + new_info.did, + new_info.verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_did_doc.from_json = async_mock.MagicMock( return_value=async_mock.MagicMock(did=TestConfig.test_did) ) mock_wallet_get_local_did.return_value = DIDInfo( - TestConfig.test_did, TestConfig.test_verkey, None + TestConfig.test_did, + TestConfig.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) await self.manager.receive_request( request=mock_request, @@ -1373,7 +1389,11 @@ async def test_create_response_multitenant(self): InMemoryWallet, "create_local_did", autospec=True ) as mock_wallet_create_local_did: mock_wallet_create_local_did.return_value = DIDInfo( - TestConfig.test_did, TestConfig.test_verkey, None + TestConfig.test_did, + TestConfig.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_create_did_doc.return_value = async_mock.MagicMock( serialize=async_mock.MagicMock() @@ -1715,6 +1735,8 @@ async def test_create_did_document(self): TestConfig.test_did, TestConfig.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_conn = async_mock.MagicMock( @@ -1747,6 +1769,8 @@ async def test_create_did_document_not_completed(self): TestConfig.test_did, TestConfig.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_conn = async_mock.MagicMock( @@ -1773,6 +1797,8 @@ async def test_create_did_document_no_services(self): TestConfig.test_did, TestConfig.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_conn = async_mock.MagicMock( @@ -1806,6 +1832,8 @@ async def test_create_did_document_no_service_endpoint(self): TestConfig.test_did, TestConfig.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_conn = async_mock.MagicMock( @@ -1842,6 +1870,8 @@ async def test_create_did_document_no_service_recip_keys(self): TestConfig.test_did, TestConfig.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_conn = async_mock.MagicMock( @@ -1886,6 +1916,8 @@ async def test_did_key_storage(self): TestConfig.test_did, TestConfig.test_verkey, None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) did_doc = self.make_did_doc( diff --git a/aries_cloudagent/protocols/endorse_transaction/v1_0/tests/test_routes.py b/aries_cloudagent/protocols/endorse_transaction/v1_0/tests/test_routes.py index 449865c386..53d38c8225 100644 --- a/aries_cloudagent/protocols/endorse_transaction/v1_0/tests/test_routes.py +++ b/aries_cloudagent/protocols/endorse_transaction/v1_0/tests/test_routes.py @@ -7,6 +7,7 @@ from .....core.in_memory import InMemoryProfile from .....ledger.base import BaseLedger from .....wallet.base import BaseWallet, DIDInfo +from .....wallet.crypto import DIDMethod, KeyType from .. import routes as test_module from ..models.transaction_record import TransactionRecord @@ -387,7 +388,13 @@ async def test_endorse_transaction_response(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) @@ -445,7 +452,13 @@ async def test_endorse_transaction_response_not_found_x(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) @@ -462,7 +475,13 @@ async def test_endorse_transaction_response_base_model_x(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) @@ -484,7 +503,13 @@ async def test_endorse_transaction_response_no_jobs_x(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) @@ -508,7 +533,13 @@ async def test_endorse_transaction_response_no_ledger_x(self): self.context.injector.clear_binding(BaseLedger) self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) @@ -550,7 +581,13 @@ async def test_endorse_transaction_response_wrong_my_job_x(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) @@ -580,7 +617,13 @@ async def test_endorse_transaction_response_ledger_x(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) self.ledger.txn_endorse = async_mock.CoroutineMock( @@ -625,7 +668,13 @@ async def test_endorse_transaction_response_txn_mgr_x(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) @@ -664,7 +713,13 @@ async def test_refuse_transaction_response(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) @@ -723,7 +778,13 @@ async def test_refuse_transaction_response_not_found_x(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) @@ -740,7 +801,13 @@ async def test_refuse_transaction_response_conn_base_model_x(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) @@ -762,7 +829,13 @@ async def test_refuse_transaction_response_no_jobs_x(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) @@ -786,7 +859,13 @@ async def test_refuse_transaction_response_wrong_my_job_x(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) @@ -816,7 +895,13 @@ async def test_refuse_transaction_response_txn_mgr_x(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ) ) diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_routes.py b/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_routes.py index 450f46bc10..888bbe71c3 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_routes.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_routes.py @@ -3,6 +3,7 @@ from .....admin.request_context import AdminRequestContext from .....wallet.base import BaseWallet, DIDInfo +from .....wallet.crypto import DIDMethod, KeyType from .. import routes as test_module @@ -408,10 +409,22 @@ async def test_credential_exchange_create_free_offer(self): self.context.update_settings({"default_endpoint": "http://1.2.3.4:8081"}) self.session_inject[BaseWallet] = async_mock.MagicMock( get_local_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("public-did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "public-did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), ) @@ -502,10 +515,22 @@ async def test_credential_exchange_create_free_offer_no_conn_id(self): self.context.update_settings({"default_endpoint": "http://1.2.3.4:8081"}) self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("public-did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "public-did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), get_local_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), ) @@ -573,7 +598,13 @@ async def test_credential_exchange_create_free_offer_no_endpoint(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), ) @@ -595,10 +626,22 @@ async def test_credential_exchange_create_free_offer_deser_x(self): self.context.update_settings({"default_endpoint": "http://1.2.3.4:8081"}) self.session_inject[BaseWallet] = async_mock.MagicMock( get_local_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("public-did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "public-did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py index 68976f8cf5..c6e7dbde1c 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py @@ -5,6 +5,7 @@ from ..formats.ld_proof.handler import LDProofCredFormatHandler from .....admin.request_context import AdminRequestContext from .....wallet.base import BaseWallet, DIDInfo +from .....wallet.crypto import DIDMethod, KeyType from .. import routes as test_module @@ -517,10 +518,22 @@ async def test_credential_exchange_create_free_offer(self): self.context.update_settings({"default_endpoint": "http://1.2.3.4:8081"}) self.session_inject[BaseWallet] = async_mock.MagicMock( get_local_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("public-did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "public-did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), ) @@ -615,10 +628,22 @@ async def test_credential_exchange_create_free_offer_no_conn_id(self): self.context.update_settings({"default_endpoint": "http://1.2.3.4:8081"}) self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("public-did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "public-did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), get_local_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), ) @@ -684,7 +709,13 @@ async def test_credential_exchange_create_free_offer_no_endpoint(self): self.session_inject[BaseWallet] = async_mock.MagicMock( get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), ) @@ -706,10 +737,22 @@ async def test_credential_exchange_create_free_offer_deser_x(self): self.context.update_settings({"default_endpoint": "http://1.2.3.4:8081"}) self.session_inject[BaseWallet] = async_mock.MagicMock( get_local_did=async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), get_public_did=async_mock.CoroutineMock( - return_value=DIDInfo("public-did", "verkey", {"meta": "data"}) + return_value=DIDInfo( + "public-did", + "verkey", + {"meta": "data"}, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) ), ) 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 9db941a467..04cc4cb5bc 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 @@ -59,7 +59,7 @@ from .....transport.inbound.receipt import MessageReceipt from .....wallet.base import DIDInfo, KeyInfo from .....wallet.in_memory import InMemoryWallet -from .....wallet.crypto import KeyType +from .....wallet.crypto import DIDMethod, KeyType from .....did.did_key import DIDKey from ....didcomm_prefix import DIDCommPrefix @@ -277,7 +277,11 @@ async def test_create_invitation_handshake_succeeds(self): InMemoryWallet, "get_public_did", autospec=True ) as mock_wallet_get_public_did: mock_wallet_get_public_did.return_value = DIDInfo( - TestConfig.test_did, TestConfig.test_verkey, None + TestConfig.test_did, + TestConfig.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) invi_rec = await self.manager.create_invitation( my_endpoint=TestConfig.test_endpoint, @@ -338,7 +342,11 @@ async def test_create_invitation_ledger_x(self): ) as mock_ledger_get_endpoint_for_did: mock_ledger_get_endpoint_for_did.side_effect = test_module.LedgerError() mock_wallet_get_public_did.return_value = DIDInfo( - TestConfig.test_did, TestConfig.test_verkey, None + TestConfig.test_did, + TestConfig.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) with self.assertRaises(OutOfBandManagerError) as context: await self.manager.create_invitation( @@ -364,7 +372,7 @@ async def test_create_invitation_multitenant_local(self): self.multitenant_mgr, "get_default_mediator" ) as mock_get_default_mediator: mock_wallet_create_signing_key.return_value = KeyInfo( - TestConfig.test_verkey, None + TestConfig.test_verkey, None, KeyType.ED25519 ) mock_get_default_mediator.return_value = MediationRecord() await self.manager.create_invitation( @@ -392,7 +400,7 @@ async def test_create_invitation_multitenant_public(self): InMemoryWallet, "get_public_did", autospec=True ) as mock_wallet_get_public_did: mock_wallet_get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, None + self.test_did, self.test_verkey, None, method=DIDMethod.SOV, key_type=KeyType.ED25519 ) await self.manager.create_invitation( hs_protos=[HSProto.RFC23], @@ -424,7 +432,11 @@ async def test_create_invitation_attachment_v1_0_cred_offer(self): async_mock.CoroutineMock(), ) as mock_retrieve_cxid: mock_wallet_get_public_did.return_value = DIDInfo( - TestConfig.test_did, TestConfig.test_verkey, None + TestConfig.test_did, + TestConfig.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_retrieve_cxid.return_value = async_mock.MagicMock( credential_offer_dict={"cred": "offer"} @@ -449,7 +461,11 @@ async def test_create_invitation_attachment_v1_0_cred_offer_no_handshake(self): async_mock.CoroutineMock(), ) as mock_retrieve_cxid: mock_wallet_get_public_did.return_value = DIDInfo( - TestConfig.test_did, TestConfig.test_verkey, None + TestConfig.test_did, + TestConfig.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_retrieve_cxid.return_value = async_mock.MagicMock( credential_offer_dict={"cred": "offer"} @@ -480,7 +496,11 @@ async def test_create_invitation_attachment_v2_0_cred_offer(self): async_mock.CoroutineMock(), ) as mock_retrieve_cxid_v2: mock_wallet_get_public_did.return_value = DIDInfo( - TestConfig.test_did, TestConfig.test_verkey, None + TestConfig.test_did, + TestConfig.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_v20_cred_offer_deser.return_value = async_mock.MagicMock( offer=async_mock.MagicMock(return_value={"cred": "offer"}) @@ -506,7 +526,11 @@ async def test_create_invitation_attachment_present_proof_v1_0(self): async_mock.CoroutineMock(), ) as mock_retrieve_pxid: mock_wallet_get_public_did.return_value = DIDInfo( - TestConfig.test_did, TestConfig.test_verkey, None + TestConfig.test_did, + TestConfig.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_retrieve_pxid.return_value = async_mock.MagicMock( presentation_request_dict={"pres": "req"} @@ -536,7 +560,11 @@ async def test_create_invitation_attachment_present_proof_v2_0(self): async_mock.CoroutineMock(), ) as mock_retrieve_pxid_2: mock_wallet_get_public_did.return_value = DIDInfo( - TestConfig.test_did, TestConfig.test_verkey, None + TestConfig.test_did, + TestConfig.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) mock_retrieve_pxid_1.side_effect = StorageNotFoundError() mock_retrieve_pxid_2.return_value = async_mock.MagicMock( @@ -606,7 +634,11 @@ async def test_create_invitation_attachment_x(self): InMemoryWallet, "get_public_did", autospec=True ) as mock_wallet_get_public_did: mock_wallet_get_public_did.return_value = DIDInfo( - TestConfig.test_did, TestConfig.test_verkey, None + TestConfig.test_did, + TestConfig.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) with self.assertRaises(OutOfBandManagerError) as context: await self.manager.create_invitation( @@ -684,7 +716,11 @@ async def test_create_invitation_x_public_metadata(self): InMemoryWallet, "get_public_did", autospec=True ) as mock_wallet_get_public_did: mock_wallet_get_public_did.return_value = DIDInfo( - TestConfig.test_did, TestConfig.test_verkey, None + TestConfig.test_did, + TestConfig.test_verkey, + None, + method=DIDMethod.SOV, + key_type=KeyType.ED25519, ) with self.assertRaises(OutOfBandManagerError) as context: await self.manager.create_invitation( diff --git a/aries_cloudagent/utils/tests/test_outofband.py b/aries_cloudagent/utils/tests/test_outofband.py index d1e92ae477..67ccf3d831 100644 --- a/aries_cloudagent/utils/tests/test_outofband.py +++ b/aries_cloudagent/utils/tests/test_outofband.py @@ -3,6 +3,7 @@ from ...messaging.agent_message import AgentMessage from ...protocols.out_of_band.v1_0.messages.invitation import InvitationMessage from ...wallet.base import DIDInfo +from ...wallet.crypto import DIDMethod, KeyType from .. import outofband as test_module @@ -10,7 +11,9 @@ class TestOutOfBand(TestCase): test_did = "55GkHamhTU1ZbTbV2ab9DE" test_verkey = "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx" - test_did_info = DIDInfo(test_did, test_verkey, None) + test_did_info = DIDInfo( + test_did, test_verkey, None, method=DIDMethod.SOV, key_type=KeyType.ED25519 + ) def test_serialize_oob(self): invi = InvitationMessage( diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Bls12381G2WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Bls12381G2WalletKeyPair.py index 2ce1994b36..c73b6cac16 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/Bls12381G2WalletKeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/Bls12381G2WalletKeyPair.py @@ -23,6 +23,7 @@ async def sign(self, messages: Union[List[bytes], bytes]) -> bytes: raise LinkedDataProofException( "Unable to sign message with Bls12381G2WalletKeyPair: No key to sign with" ) + return await self.wallet.sign_message( message=messages if type(messages) is list else [messages], from_verkey=self.public_key_base58, diff --git a/aries_cloudagent/wallet/base.py b/aries_cloudagent/wallet/base.py index 13c66b32a8..7220ac115a 100644 --- a/aries_cloudagent/wallet/base.py +++ b/aries_cloudagent/wallet/base.py @@ -1,17 +1,28 @@ """Wallet base class.""" from abc import ABC, abstractmethod -from collections import namedtuple -from typing import Sequence, Tuple +from typing import List, NamedTuple, Sequence, Tuple, Union from .crypto import DIDMethod, KeyType from ..ledger.base import BaseLedger from ..ledger.endpoint_type import EndpointType +from .error import WalletError from .did_posture import DIDPosture -KeyInfo = namedtuple("KeyInfo", "verkey metadata") -DIDInfo = namedtuple("DIDInfo", "did verkey metadata") +KeyInfo = NamedTuple( + "KeyInfo", [("verkey", str), ("metadata", dict), ("key_type", KeyType)] +) +DIDInfo = NamedTuple( + "DIDInfo", + [ + ("did", str), + ("verkey", str), + ("metadata", dict), + ("method", DIDMethod), + ("key_type", KeyType), + ], +) class BaseWallet(ABC): @@ -19,11 +30,12 @@ class BaseWallet(ABC): @abstractmethod async def create_signing_key( - self, seed: str = None, metadata: dict = None + self, key_type: KeyType, seed: str = None, metadata: dict = None ) -> KeyInfo: """Create a new public/private signing keypair. Args: + key_type: Key type to create seed: Optional seed allowing deterministic key creation metadata: Optional metadata to store with the keypair @@ -90,22 +102,21 @@ async def rotate_did_keypair_apply(self, did: str) -> None: @abstractmethod async def create_local_did( self, + method: DIDMethod, + key_type: KeyType, seed: str = None, did: str = None, metadata: dict = None, - *, - method: DIDMethod = DIDMethod.SOV, - key_type: KeyType = KeyType.ED25519 ) -> DIDInfo: """ Create and store a new local DID. Args: + method: The method to use for the DID + key_type: The key type to use for the DID seed: Optional seed to use for DID did: The DID to use metadata: Metadata to store with DID - method: The method to use for the DID. Defaults to did:sov - key_type: The key type to use for the DID. defaults to ed25519. Returns: The created `DIDInfo` @@ -113,7 +124,12 @@ async def create_local_did( """ async def create_public_did( - self, seed: str = None, did: str = None, metadata: dict = {} + self, + method: DIDMethod, + key_type: KeyType, + seed: str = None, + did: str = None, + metadata: dict = {}, ) -> DIDInfo: """ Create and store a new public DID. @@ -129,6 +145,14 @@ async def create_public_did( The created `DIDInfo` """ + if method != DIDMethod.SOV: + raise WalletError("Creating public did is only allowed for did:sov dids") + + # validate key_type + if not method.supports_key_type(key_type): + raise WalletError( + f"Invalid key type {key_type.key_type} for method {method.method_name}" + ) metadata = DIDPosture.PUBLIC.metadata dids = await self.get_local_dids() @@ -163,6 +187,10 @@ async def set_public_did(self, did: str) -> DIDInfo: """ + did_info = self.get_local_did(did) + if did_info.method != DIDMethod.SOV: + raise WalletError("Setting public did is only allowed for did:sov dids") + # will raise an exception if not found info = None if did is None else await self.get_local_did(did) @@ -264,6 +292,9 @@ async def set_did_endpoint( 'endpoint' affects local wallet """ did_info = await self.get_local_did(did) + + if did_info.method != DIDMethod.SOV: + raise WalletError("Setting did endpoint is only allowed for did:sov dids") metadata = {**did_info.metadata} if not endpoint_type: endpoint_type = EndpointType.ENDPOINT @@ -273,12 +304,14 @@ async def set_did_endpoint( await self.replace_local_did_metadata(did, metadata) @abstractmethod - async def sign_message(self, message: bytes, from_verkey: str) -> bytes: + async def sign_message( + self, message: Union[List[bytes], bytes], from_verkey: str + ) -> bytes: """ - Sign a message using the private key associated with a given verkey. + Sign message(s) using the private key associated with a given verkey. Args: - message: The message to sign + message: The message(s) to sign from_verkey: Sign using the private key related to this verkey Returns: @@ -288,7 +321,11 @@ async def sign_message(self, message: bytes, from_verkey: str) -> bytes: @abstractmethod async def verify_message( - self, message: bytes, signature: bytes, from_verkey: str + self, + message: Union[List[bytes], bytes], + signature: bytes, + from_verkey: str, + key_type: KeyType, ) -> bool: """ Verify a signature against the public key of the signer. @@ -297,6 +334,7 @@ async def verify_message( message: The message to verify signature: The signature to verify from_verkey: Verkey to use in verification + key_type: The key type to derive the signature verification algorithm from Returns: True if verified, else False diff --git a/aries_cloudagent/wallet/crypto.py b/aries_cloudagent/wallet/crypto.py index 304f7a577d..d91d293fce 100644 --- a/aries_cloudagent/wallet/crypto.py +++ b/aries_cloudagent/wallet/crypto.py @@ -11,8 +11,13 @@ import nacl.utils from marshmallow import fields, Schema, ValidationError +from ursa_bbs_signatures.models.SignRequest import SignRequest +from ursa_bbs_signatures.models.VerifyRequest import VerifyRequest +from ursa_bbs_signatures.models.keys.BlsKeyPair import BlsKeyPair +from ursa_bbs_signatures.api import sign as bbs_sign, verify as bbs_verify from .error import WalletError +from ..core.error import BaseError from .util import ( bytes_to_b58, bytes_to_b64, @@ -71,6 +76,7 @@ def from_key_type(cls, key_type: str) -> Optional["KeyType"]: [ ("method_name", str), ("supported_key_types", List[KeyType]), + ("supports_rotation", bool), ], ) @@ -78,8 +84,14 @@ def from_key_type(cls, key_type: str) -> Optional["KeyType"]: class DIDMethod(Enum): """DID Method class specifying DID methods with supported key types.""" - SOV = DIDMethodSpec("sov", [KeyType.ED25519]) - KEY = DIDMethodSpec("key", [KeyType.ED25519, KeyType.BLS12381G2]) + SOV = DIDMethodSpec( + method_name="sov", supported_key_types=[KeyType.ED25519], supports_rotation=True + ) + KEY = DIDMethodSpec( + method_name="key", + supported_key_types=[KeyType.ED25519, KeyType.BLS12381G2], + supports_rotation=False, + ) @property def method_name(self) -> str: @@ -91,6 +103,11 @@ def supported_key_types(self) -> List[KeyType]: """Getter for supported key types of method.""" return self.value.supported_key_types + @property + def supports_rotation(self) -> bool: + """Check whether the current method supports key rotation.""" + return self.value.supports_rotation + def supports_key_type(self, key_type: KeyType) -> bool: """Check whether the current method supports the key type.""" return key_type in self.supported_key_types @@ -119,6 +136,22 @@ def from_method(method: str) -> Optional["DIDMethod"]: return None + def from_did(did: str) -> "DIDMethod": + """Get DID method instance from the method name.""" + if not did.startswith("did:"): + # sov has no prefix + return DIDMethod.SOV + + parts = did.split(":") + method_str = parts[1] + + method = DIDMethod.from_method(method_str) + + if not method: + raise BaseError(f"Unsupported did method: {method_str}") + + return method + class PackMessageSchema(Schema): """Packed message schema.""" @@ -153,9 +186,32 @@ class PackRecipientsSchema(Schema): recipients = fields.List(fields.Nested(PackRecipientSchema()), required=True) -def create_keypair(seed: bytes = None) -> Tuple[bytes, bytes]: +def create_keypair(key_type: KeyType, seed: bytes = None) -> Tuple[bytes, bytes]: + """ + Create a public and private keypair from a seed value. + + Args: + key_type: The type of key to generate + seed: Seed for keypair + + Raises: + WalletError: If the key type is not supported + + Returns: + A tuple of (public key, secret key) + + """ + if key_type == KeyType.ED25519: + return create_ed25519_keypair(seed) + elif key_type == KeyType.BLS12381G2: + return create_bls12381g2_keypair(seed) + else: + raise WalletError(f"Unsupported key type: {key_type.key_type}") + + +def create_ed25519_keypair(seed: bytes = None) -> Tuple[bytes, bytes]: """ - Create a public and private signing keypair from a seed value. + Create a public and private ed25519 keypair from a seed value. Args: seed: Seed for keypair @@ -170,6 +226,24 @@ def create_keypair(seed: bytes = None) -> Tuple[bytes, bytes]: return pk, sk +def create_bls12381g2_keypair(seed: bytes = None) -> Tuple[bytes, bytes]: + """ + Create a public and private bls12381g2 keypair from a seed value. + + Args: + seed: Seed for keypair + + Returns: + A tuple of (public key, secret key) + + """ + if not seed: + seed = random_seed() + + key_pair = BlsKeyPair.generate_g2(seed) + return key_pair.public_key, key_pair.secret_key + + def random_seed() -> bytes: """ Generate a random seed value. @@ -193,7 +267,7 @@ def seed_to_did(seed: str) -> str: """ seed = validate_seed(seed) - verkey, _ = create_keypair(seed) + verkey, _ = create_ed25519_keypair(seed) did = bytes_to_b58(verkey[:16]) return did @@ -229,16 +303,47 @@ def validate_seed(seed: Union[str, bytes]) -> bytes: return seed -def sign_message(message: bytes, secret: bytes) -> bytes: +def sign_message( + message: Union[List[bytes], bytes], secret: bytes, key_type: KeyType +) -> bytes: """ - Sign a message using a private signing key. + Sign message(s) using a private signing key. Args: - message: The message to sign + message: The message(s) to sign secret: The private signing key + key_type: The key type to derive the signature algorithm from Returns: - The signature + bytes: The signature + + """ + # Make messages list if not already for easier checking going forward + messages = message if isinstance(message, list) else [message] + + if key_type == KeyType.ED25519: + if len(messages) > 1: + raise WalletError("ed25519 can only sign a single message") + + return sign_message_ed25519( + message=messages[0], + secret=secret, + ) + elif key_type == KeyType.BLS12381G2: + return sign_messages_bls12381g2(messages=messages, secret=secret) + else: + raise WalletError(f"Unsupported key type: {key_type.key_type}") + + +def sign_message_ed25519(message: bytes, secret: bytes) -> bytes: + """Sign message using a ed25519 private signing key. + + Args: + messages (bytes): The message to sign + secret (bytes): The private signing key + + Returns: + bytes: The signature """ result = nacl.bindings.crypto_sign(message, secret) @@ -246,12 +351,72 @@ def sign_message(message: bytes, secret: bytes) -> bytes: return sig -def verify_signed_message(signed: bytes, verkey: bytes) -> bool: +def sign_messages_bls12381g2(messages: List[bytes], secret: bytes): + """Sign messages using a bls12381g2 private signing key. + + Args: + messages (List[bytes]): The messages to sign + secret (bytes): The private signing key + + Returns: + bytes: The signature + + """ + key_pair = BlsKeyPair.from_secret_key(secret) + + messages = [message.decode("utf-8") for message in messages] + + sign_request = SignRequest(key_pair=key_pair, messages=messages) + + return bbs_sign(sign_request) + + +def verify_signed_message( + message: Union[List[bytes], bytes], + signature: bytes, + verkey: bytes, + key_type: KeyType, +) -> bool: """ Verify a signed message according to a public verification key. Args: - signed: The signed message + message: The message(s) to verify + signature: The signature to verify + verkey: The verkey to use in verification + key_type: The key type to derive the signature verification algorithm from + + Returns: + True if verified, else False + + """ + # Make messages list if not already for easier checking going forward + messages = message if isinstance(message, list) else [message] + + if key_type == KeyType.ED25519: + if len(messages) > 1: + raise WalletError("ed25519 can only verify a single message") + + return verify_signed_message_ed25519( + message=messages[0], signature=signature, verkey=verkey + ) + elif key_type == KeyType.BLS12381G2: + return verify_signed_messages_bls12381g2( + messages=messages, signature=signature, public_key=verkey + ) + else: + raise WalletError(f"Unsupported key type: {key_type.key_type}") + + +def verify_signed_message_ed25519( + message: bytes, signature: bytes, verkey: bytes +) -> bool: + """ + Verify an ed25519 signed message according to a public verification key. + + Args: + message: The message to verify + signature: The signature to verify verkey: The verkey to use in verification Returns: @@ -259,12 +424,36 @@ def verify_signed_message(signed: bytes, verkey: bytes) -> bool: """ try: - nacl.bindings.crypto_sign_open(signed, verkey) + nacl.bindings.crypto_sign_open(signature + message, verkey) except nacl.exceptions.BadSignatureError: return False return True +def verify_signed_messages_bls12381g2( + messages: List[bytes], signature: bytes, public_key: bytes +) -> bool: + """ + Verify an ed25519 signed message according to a public verification key. + + Args: + signed: The signed messages + public_key: The public key to use in verification + + Returns: + True if verified, else False + + """ + key_pair = BlsKeyPair(public_key=public_key) + messages = [message.decode("utf-8") for message in messages] + + verify_request = VerifyRequest( + key_pair=key_pair, signature=signature, messages=messages + ) + + return bbs_verify(verify_request) + + def prepare_pack_recipient_keys( to_verkeys: Sequence[bytes], from_secret: bytes = None ) -> Tuple[str, bytes]: diff --git a/aries_cloudagent/wallet/in_memory.py b/aries_cloudagent/wallet/in_memory.py index 239ed29c79..5e41e50fa3 100644 --- a/aries_cloudagent/wallet/in_memory.py +++ b/aries_cloudagent/wallet/in_memory.py @@ -1,7 +1,7 @@ """In-memory implementation of BaseWallet interface.""" import asyncio -from typing import Sequence +from typing import List, Sequence, Tuple, Union from ..core.in_memory import InMemoryProfile @@ -36,7 +36,10 @@ def __init__(self, profile: InMemoryProfile): self.profile = profile async def create_signing_key( - self, seed: str = None, metadata: dict = None + self, + key_type: KeyType, + seed: str = None, + metadata: dict = None, ) -> KeyInfo: """ Create a new public/private signing keypair. @@ -44,6 +47,7 @@ async def create_signing_key( Args: seed: Seed to use for signing key metadata: Optional metadata to store with the keypair + key_type: Key type to generate. Default to ed25519 Returns: A `KeyInfo` representing the new record @@ -53,7 +57,7 @@ async def create_signing_key( """ seed = validate_seed(seed) or random_seed() - verkey, secret = create_keypair(seed) + verkey, secret = create_keypair(key_type, seed) verkey_enc = bytes_to_b58(verkey) if verkey_enc in self.profile.keys: raise WalletDuplicateError("Verification key already present in wallet") @@ -62,8 +66,13 @@ async def create_signing_key( "secret": secret, "verkey": verkey_enc, "metadata": metadata.copy() if metadata else {}, + "key_type": key_type, } - return KeyInfo(verkey_enc, self.profile.keys[verkey_enc]["metadata"].copy()) + return KeyInfo( + verkey=verkey_enc, + metadata=self.profile.keys[verkey_enc]["metadata"].copy(), + key_type=key_type, + ) async def get_signing_key(self, verkey: str) -> KeyInfo: """ @@ -82,7 +91,11 @@ async def get_signing_key(self, verkey: str) -> KeyInfo: if verkey not in self.profile.keys: raise WalletNotFoundError("Key not found: {}".format(verkey)) key = self.profile.keys[verkey] - return KeyInfo(key["verkey"], key["metadata"].copy()) + return KeyInfo( + verkey=key["verkey"], + metadata=key["metadata"].copy(), + key_type=key["key_type"], + ) async def replace_signing_key_metadata(self, verkey: str, metadata: dict): """ @@ -115,10 +128,19 @@ async def rotate_did_keypair_start(self, did: str, next_seed: str = None) -> str WalletNotFoundError: if wallet does not own DID """ - if did not in self.profile.local_dids: + local_did = self.profile.local_dids.get(did) + if not local_did: raise WalletNotFoundError("Wallet owns no such DID: {}".format(did)) - key_info = await self.create_signing_key(next_seed, {"did": did}) + did_method = DIDMethod.from_did(did) + if not did_method.supports_rotation: + raise WalletError( + f"Did method {did_method.method_name} does not support key rotation." + ) + + key_info = await self.create_signing_key( + local_did["key_type"], next_seed, {"did": did} + ) return key_info.verkey async def rotate_did_keypair_apply(self, did: str) -> None: @@ -144,7 +166,9 @@ async def rotate_did_keypair_apply(self, did: str) -> None: raise WalletError("Key rotation not in progress for DID: {}".format(did)) verkey_enc = temp_keys[0] - self.profile.local_dids[did].update( + local_did = self.profile.local_dids[did] + + local_did.update( { "seed": self.profile.keys[verkey_enc]["seed"], "secret": self.profile.keys[verkey_enc]["secret"], @@ -152,26 +176,31 @@ async def rotate_did_keypair_apply(self, did: str) -> None: } ) self.profile.keys.pop(verkey_enc) - return DIDInfo(did, verkey_enc, self.profile.local_dids[did]["metadata"].copy()) + return DIDInfo( + did=did, + verkey=verkey_enc, + metadata=local_did["metadata"].copy(), + method=local_did["method"], + key_type=local_did["key_type"], + ) async def create_local_did( self, + method: DIDMethod, + key_type: KeyType, seed: str = None, did: str = None, metadata: dict = None, - *, - method: DIDMethod = DIDMethod.SOV, - key_type: KeyType = KeyType.ED25519, ) -> DIDInfo: """ Create and store a new local DID. Args: + method: The method to use for the DID + key_type: The key type to use for the DID seed: Optional seed to use for DID did: The DID to use metadata: Metadata to store with DID - method: The method to use for the DID. Defaults to did:sov - key_type: The key type to use for the DID. defaults to ed25519. Returns: A `DIDInfo` instance representing the created DID @@ -185,22 +214,25 @@ async def create_local_did( # validate key_type if not method.supports_key_type(key_type): raise WalletError( - f"Invalid key type {key_type.key_type}" - f" for did method {method.method_name}" + f"Invalid key type {key_type.key_type} for method {method.method_name}" ) - verkey, secret = create_keypair(seed) + verkey, secret = create_keypair(key_type, seed) verkey_enc = bytes_to_b58(verkey) - if not did: - # TODO: each method should have it's own class (like DIDKey) - # that can handle public key + key type to did id - if method == DIDMethod.SOV: + # We need some did method specific handling. If more did methods + # are added it is probably better create a did method specific handler + if method == DIDMethod.KEY: + if did: + raise WalletError("Not allowed to set did for did method key") + + did = DIDKey.from_public_key(verkey, key_type).did + elif method == DIDMethod.SOV: + if not did: did = bytes_to_b58(verkey[:16]) - elif method == DIDMethod.KEY: - did = DIDKey.from_public_key(verkey, key_type).did - else: - raise WalletError(f"Cannot create did for method: {method.method_name}") + else: + raise WalletError(f"Unsupported did method: {method.method_name}") + if ( did in self.profile.local_dids and self.profile.local_dids[did]["verkey"] != verkey_enc @@ -211,8 +243,16 @@ async def create_local_did( "secret": secret, "verkey": verkey_enc, "metadata": metadata.copy() if metadata else {}, + "key_type": key_type, + "method": method, } - return DIDInfo(did, verkey_enc, self.profile.local_dids[did]["metadata"].copy()) + return DIDInfo( + did=did, + verkey=verkey_enc, + metadata=self.profile.local_dids[did]["metadata"].copy(), + method=method, + key_type=key_type, + ) def _get_did_info(self, did: str) -> DIDInfo: """ @@ -226,7 +266,13 @@ def _get_did_info(self, did: str) -> DIDInfo: """ info = self.profile.local_dids[did] - return DIDInfo(did=did, verkey=info["verkey"], metadata=info["metadata"].copy()) + return DIDInfo( + did=did, + verkey=info["verkey"], + metadata=info["metadata"].copy(), + method=info["method"], + key_type=info["key_type"], + ) async def get_local_dids(self) -> Sequence[DIDInfo]: """ @@ -316,12 +362,14 @@ def _get_private_key(self, verkey: str) -> bytes: raise WalletError("Private key not found for verkey: {}".format(verkey)) - async def sign_message(self, message: bytes, from_verkey: str) -> bytes: + async def sign_message( + self, message: Union[List[bytes], bytes], from_verkey: str + ) -> bytes: """ - Sign a message using the private key associated with a given verkey. + Sign message(s) using the private key associated with a given verkey. Args: - message: Message bytes to sign + message: Message(s) bytes to sign from_verkey: The verkey to use to sign Returns: @@ -336,20 +384,31 @@ async def sign_message(self, message: bytes, from_verkey: str) -> bytes: raise WalletError("Message not provided") if not from_verkey: raise WalletError("Verkey not provided") + + try: + key_info = await self.get_signing_key(from_verkey) + except WalletNotFoundError: + key_info = await self.get_local_did_for_verkey(from_verkey) + secret = self._get_private_key(from_verkey) - signature = sign_message(message, secret) + signature = sign_message(message, secret, key_info.key_type) return signature async def verify_message( - self, message: bytes, signature: bytes, from_verkey: str + self, + message: Union[List[bytes], bytes], + signature: bytes, + from_verkey: str, + key_type: KeyType, ) -> bool: """ Verify a signature against the public key of the signer. Args: - message: Message to verify + message: Message(s) to verify signature: Signature to verify from_verkey: Verkey to use in verification + key_type: The key type to derive the signature verification algorithm from Returns: True if verified, else False @@ -367,7 +426,8 @@ async def verify_message( if not message: raise WalletError("Message not provided") verkey_bytes = b58_to_bytes(from_verkey) - verified = verify_signed_message(signature + message, verkey_bytes) + + verified = verify_signed_message(message, signature, verkey_bytes, key_type) return verified async def pack_message( @@ -398,7 +458,7 @@ async def pack_message( ) return result - async def unpack_message(self, enc_message: bytes) -> (str, str, str): + async def unpack_message(self, enc_message: bytes) -> Tuple[str, str, str]: """ Unpack a message. diff --git a/aries_cloudagent/wallet/indy.py b/aries_cloudagent/wallet/indy.py index 56f5f1939f..73d4a94bda 100644 --- a/aries_cloudagent/wallet/indy.py +++ b/aries_cloudagent/wallet/indy.py @@ -36,13 +36,14 @@ def __did_info_from_info(self, info): did = info["did"] verkey = info["verkey"] - if DIDMethod.from_metadata(metadata) == DIDMethod.KEY: + method = DIDMethod.from_metadata(metadata) + key_type = KeyType.ED25519 + + if method == DIDMethod.KEY: did = DIDKey.from_public_key_b58(info["verkey"], KeyType.ED25519).did return DIDInfo( - did=did, - verkey=verkey, - metadata=metadata, + did=did, verkey=verkey, metadata=metadata, method=method, key_type=key_type ) async def create_signing_key( @@ -245,7 +246,9 @@ async def create_local_did( if method == DIDMethod.KEY: # Transform the did to a did key did = DIDKey.from_public_key_b58(verkey, key_type).did - return DIDInfo(did, verkey, metadata) + return DIDInfo( + did=did, verkey=verkey, metadata=metadata, method=method, key_type=key_type + ) async def get_local_dids(self) -> Sequence[DIDInfo]: """ diff --git a/aries_cloudagent/wallet/models/key_pair_record.py b/aries_cloudagent/wallet/models/key_pair_record.py new file mode 100644 index 0000000000..453b694b28 --- /dev/null +++ b/aries_cloudagent/wallet/models/key_pair_record.py @@ -0,0 +1,90 @@ +"""Key pair record.""" + +from typing import Any + +from marshmallow import fields, EXCLUDE + +from ...messaging.models.base_record import ( + BaseRecord, + BaseRecordSchema, +) +from ...messaging.valid import UUIDFour +from ...wallet.crypto import KeyType + + +class KeyPairRecord(BaseRecord): + """Represents a key pair record.""" + + class Meta: + """KeyPairRecord metadata.""" + + schema_class = "KeyPairRecordSchema" + + RECORD_TYPE = "key_pair_record" + RECORD_ID_NAME = "key_id" + + TAG_NAMES = {"public_key_b58", "key_type"} + + def __init__( + self, + *, + key_id: str = None, + public_key_b58: str = None, + private_key_b58: str = None, + key_type: str = None, + **kwargs, + ): + """Initialize a new KeyPairRecord.""" + super().__init__(key_id, **kwargs) + self.public_key_b58 = public_key_b58 + self.private_key_b58 = private_key_b58 + self.key_type = key_type + + @property + def key_id(self) -> str: + """Accessor for the ID associated with this record.""" + return self._id + + @property + def record_value(self) -> dict: + """Accessor for the JSON record value generated for this record.""" + return { + prop: getattr(self, prop) + for prop in ("public_key_b58", "private_key_b58", "key_type") + } + + def __eq__(self, other: Any) -> bool: + """Comparison between records.""" + return super().__eq__(other) + + +class KeyPairRecordSchema(BaseRecordSchema): + """Schema to allow serialization/deserialization of record.""" + + class Meta: + """KeyPairRecordSchema metadata.""" + + model_class = KeyPairRecord + unknown = EXCLUDE + + key_id = fields.Str( + required=True, + description="Wallet key ID", + example=UUIDFour.EXAMPLE, + ) + public_key_b58 = fields.Str( + required=True, + description="Base 58 encoded public key", + example=( + "o1cocewfMSeasDPVYEkbmeEZUan5fM7ix2oWxeZupgVQFqXRsxUFdAjDmxoosqgdn" + "QJruhMYE3q7gx65MMdgtj67UsUJgJsFYX5ruMyZ58pttzKxnJrM2aoAbhqL1rnQWFf" + ), + ) + private_key_b58 = fields.Str( + required=True, + description="Base 58 encoded private key", + example="4xPeQ2sVw8S9opkARzeL6SSgygGiq6JQjFViwXL8v2wE", + ) + key_type = fields.Str( + required=True, description="Type of key", example=KeyType.BLS12381G2.key_type + ) diff --git a/aries_cloudagent/wallet/models/tests/test_key_pair_record.py b/aries_cloudagent/wallet/models/tests/test_key_pair_record.py new file mode 100644 index 0000000000..e8e4a48ba9 --- /dev/null +++ b/aries_cloudagent/wallet/models/tests/test_key_pair_record.py @@ -0,0 +1,42 @@ +from asynctest import TestCase as AsyncTestCase + +from ....wallet.crypto import KeyType +from ..key_pair_record import KeyPairRecord + + +class TestKeyPairRecord(AsyncTestCase): + async def test_serde(self): + rec = KeyPairRecord( + key_id="d96ff010-8d09-43f2-ae8e-f8a56ac68a88", + public_key_b58="o1cocewfMSeasDPVYEkbmeEZUan5fM7ix2oWxeZupgVQFqXRsxUFdAjDmxoosqgdnQJruhMYE3q7gx65MMdgtj67UsUJgJsFYX5ruMyZ58pttzKxnJrM2aoAbhqL1rnQWFf", + private_key_b58="4xPeQ2sVw8S9opkARzeL6SSgygGiq6JQjFViwXL8v2wE", + key_type=KeyType.BLS12381G2.key_type, + ) + ser = rec.serialize() + assert ser["key_id"] == rec.key_id + assert ser["public_key_b58"] == rec.public_key_b58 + assert ser["private_key_b58"] == rec.private_key_b58 + assert ser["key_type"] == rec.key_type + + assert rec == KeyPairRecord.deserialize(ser) + + async def test_rec_ops(self): + recs = [ + KeyPairRecord( + key_id=f"61764d00-8c16-42dc-b1ec-08c0010ad59c-{i}", + public_key_b58=f"o1cocewfMSeasDPVYEkbmeEZUan5fM7ix2oWxeZupgVQFqXRsxUFdAjDmxoosqgdnQJruhMYE3q7gx65MMdgtj67UsUJgJsFYX5ruMyZ58pttzKxnJrM2aoAbhqL1rnQWFf-{i}", + private_key_b58=f"4xPeQ2sVw8S9opkARzeL6SSgygGiq6JQjFViwXL8v2wE-{i}", + key_type=[KeyType.ED25519, KeyType.BLS12381G2][i].key_type, + ) + for i in range(2) + ] + assert recs[0] != recs[1] + assert recs[0].key_id + assert recs[0].public_key_b58 + assert recs[0].private_key_b58 + assert recs[0].key_type == KeyType.ED25519.key_type + + assert recs[1].key_id + assert recs[1].public_key_b58 + assert recs[1].private_key_b58 + assert recs[1].key_type == KeyType.BLS12381G2.key_type \ No newline at end of file diff --git a/aries_cloudagent/wallet/tests/test_crypto.py b/aries_cloudagent/wallet/tests/test_crypto.py index 7208c82b52..6332caa3df 100644 --- a/aries_cloudagent/wallet/tests/test_crypto.py +++ b/aries_cloudagent/wallet/tests/test_crypto.py @@ -29,7 +29,9 @@ def test_validate_seed(self): def test_seeds_keys(self): assert len(test_module.seed_to_did(SEED)) in (22, 23) - (public_key, secret_key) = test_module.create_keypair() + (public_key, secret_key) = test_module.create_keypair( + test_module.KeyType.ED25519 + ) assert public_key assert secret_key diff --git a/aries_cloudagent/wallet/tests/test_routes.py b/aries_cloudagent/wallet/tests/test_routes.py index 85ab621b5f..f3c7b3b5e6 100644 --- a/aries_cloudagent/wallet/tests/test_routes.py +++ b/aries_cloudagent/wallet/tests/test_routes.py @@ -7,7 +7,7 @@ from ...ledger.base import BaseLedger from ...wallet.base import BaseWallet, DIDInfo from ...multitenant.manager import MultitenantManager -from ...wallet.crypto import KeyType +from ...wallet.crypto import DIDMethod, KeyType from .. import routes as test_module from ..did_posture import DIDPosture @@ -56,7 +56,11 @@ async def test_missing_wallet(self): def test_format_did_info(self): did_info = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.WALLET_ONLY.metadata + self.test_did, + self.test_verkey, + DIDPosture.WALLET_ONLY.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) result = test_module.format_did_info(did_info) assert ( @@ -66,13 +70,21 @@ def test_format_did_info(self): ) did_info = DIDInfo( - self.test_did, self.test_verkey, {"posted": True, "public": True} + self.test_did, + self.test_verkey, + {"posted": True, "public": True}, + DIDMethod.SOV, + KeyType.ED25519, ) result = test_module.format_did_info(did_info) assert result["posture"] == DIDPosture.PUBLIC.moniker did_info = DIDInfo( - self.test_did, self.test_verkey, {"posted": True, "public": False} + self.test_did, + self.test_verkey, + {"posted": True, "public": False}, + DIDMethod.SOV, + KeyType.ED25519, ) result = test_module.format_did_info(did_info) assert result["posture"] == DIDPosture.POSTED.moniker @@ -82,7 +94,11 @@ async def test_create_did(self): test_module.web, "json_response", async_mock.Mock() ) as json_response: self.wallet.create_local_did.return_value = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.WALLET_ONLY.metadata + self.test_did, + self.test_verkey, + DIDPosture.WALLET_ONLY.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) result = await test_module.wallet_create_did(self.request) json_response.assert_called_once_with( @@ -108,12 +124,18 @@ async def test_did_list(self): ) as json_response: # , async_mock.patch.object( self.wallet.get_local_dids.return_value = [ DIDInfo( - self.test_did, self.test_verkey, DIDPosture.WALLET_ONLY.metadata + self.test_did, + self.test_verkey, + DIDPosture.WALLET_ONLY.metadata, + DIDMethod.SOV, + KeyType.ED25519, ), DIDInfo( self.test_posted_did, self.test_posted_verkey, DIDPosture.POSTED.metadata, + DIDMethod.SOV, + KeyType.ED25519, ), ] result = await test_module.wallet_did_list(self.request) @@ -144,13 +166,19 @@ async def test_did_list_filter_public(self): test_module.web, "json_response", async_mock.Mock() ) as json_response: self.wallet.get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.PUBLIC.metadata + self.test_did, + self.test_verkey, + DIDPosture.PUBLIC.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) self.wallet.get_posted_dids.return_value = [ DIDInfo( self.test_posted_did, self.test_posted_verkey, DIDPosture.POSTED.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) ] result = await test_module.wallet_did_list(self.request) @@ -175,7 +203,11 @@ async def test_did_list_filter_posted(self): test_module.web, "json_response", async_mock.Mock() ) as json_response: self.wallet.get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.PUBLIC.metadata + self.test_did, + self.test_verkey, + DIDPosture.PUBLIC.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) self.wallet.get_posted_dids.return_value = [ DIDInfo( @@ -185,6 +217,8 @@ async def test_did_list_filter_posted(self): "posted": True, "public": False, }, + DIDMethod.SOV, + KeyType.ED25519, ) ] result = await test_module.wallet_did_list(self.request) @@ -209,7 +243,11 @@ async def test_did_list_filter_did(self): test_module.web, "json_response", async_mock.Mock() ) as json_response: self.wallet.get_local_did.return_value = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.WALLET_ONLY.metadata + self.test_did, + self.test_verkey, + DIDPosture.WALLET_ONLY.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) result = await test_module.wallet_did_list(self.request) json_response.assert_called_once_with( @@ -244,7 +282,11 @@ async def test_did_list_filter_verkey(self): test_module.web, "json_response", async_mock.Mock() ) as json_response: self.wallet.get_local_did_for_verkey.return_value = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.WALLET_ONLY.metadata + self.test_did, + self.test_verkey, + DIDPosture.WALLET_ONLY.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) result = await test_module.wallet_did_list(self.request) json_response.assert_called_once_with( @@ -278,7 +320,11 @@ async def test_get_public_did(self): test_module.web, "json_response", async_mock.Mock() ) as json_response: self.wallet.get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.PUBLIC.metadata + self.test_did, + self.test_verkey, + DIDPosture.PUBLIC.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) result = await test_module.wallet_get_public_did(self.request) json_response.assert_called_once_with( @@ -312,7 +358,11 @@ async def test_set_public_did(self): test_module.web, "json_response", async_mock.Mock() ) as json_response: self.wallet.set_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.PUBLIC.metadata + self.test_did, + self.test_verkey, + DIDPosture.PUBLIC.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) result = await test_module.wallet_set_public_did(self.request) self.wallet.set_public_did.assert_awaited_once_with( @@ -351,7 +401,11 @@ async def test_set_public_did_multitenant(self): test_module.web, "json_response", async_mock.Mock() ): self.wallet.set_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.PUBLIC.metadata + self.test_did, + self.test_verkey, + DIDPosture.PUBLIC.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) await test_module.wallet_set_public_did(self.request) @@ -408,7 +462,11 @@ async def test_set_public_did_x(self): test_module.web, "json_response", async_mock.Mock() ) as json_response: self.wallet.get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.PUBLIC.metadata + self.test_did, + self.test_verkey, + DIDPosture.PUBLIC.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) self.wallet.set_public_did.side_effect = test_module.WalletError() with self.assertRaises(test_module.web.HTTPBadRequest): @@ -428,7 +486,11 @@ async def test_set_public_did_no_wallet_did(self): test_module.web, "json_response", async_mock.Mock() ) as json_response: self.wallet.get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.PUBLIC.metadata + self.test_did, + self.test_verkey, + DIDPosture.PUBLIC.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) self.wallet.set_public_did.side_effect = test_module.WalletNotFoundError() with self.assertRaises(test_module.web.HTTPNotFound): @@ -451,6 +513,8 @@ async def test_set_public_did_update_endpoint(self): self.test_did, self.test_verkey, {**DIDPosture.PUBLIC.metadata, "endpoint": "https://endpoint.com"}, + DIDMethod.SOV, + KeyType.ED25519, ) result = await test_module.wallet_set_public_did(self.request) self.wallet.set_public_did.assert_awaited_once_with( @@ -485,7 +549,11 @@ async def test_set_public_did_update_endpoint_use_default_update_in_wallet(self) test_module.web, "json_response", async_mock.Mock() ) as json_response: did_info = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.PUBLIC.metadata + self.test_did, + self.test_verkey, + DIDPosture.PUBLIC.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) self.wallet.get_local_did.return_value = did_info self.wallet.set_public_did.return_value = did_info @@ -526,9 +594,15 @@ async def test_set_did_endpoint(self): self.test_did, self.test_verkey, {"public": False, "endpoint": "http://old-endpoint.ca"}, + DIDMethod.SOV, + KeyType.ED25519, ) self.wallet.get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.PUBLIC.metadata + self.test_did, + self.test_verkey, + DIDPosture.PUBLIC.metadata, + DIDMethod.SOV, + KeyType.ED25519, ) with async_mock.patch.object( @@ -549,9 +623,16 @@ async def test_set_did_endpoint_public_did_no_ledger(self): self.test_did, self.test_verkey, {"public": False, "endpoint": "http://old-endpoint.ca"}, + DIDMethod.SOV, + KeyType.ED25519, ) self.wallet.get_public_did.return_value = DIDInfo( - self.test_did, self.test_verkey, DIDPosture.PUBLIC.metadata + self.test_did, + self.test_verkey, + DIDPosture.PUBLIC.metadata, + KeyType.ED25519, + DIDMethod.SOV, + KeyType.ED25519, ) self.wallet.set_did_endpoint.side_effect = test_module.LedgerConfigError() @@ -603,6 +684,8 @@ async def test_get_did_endpoint(self): self.test_did, self.test_verkey, {"public": False, "endpoint": "http://old-endpoint.ca"}, + DIDMethod.SOV, + KeyType.ED25519, ) with async_mock.patch.object( @@ -645,7 +728,13 @@ async def test_rotate_did_keypair(self): test_module.web, "json_response", async_mock.Mock() ) as json_response: self.wallet.get_local_did = async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"public": False}) + return_value=DIDInfo( + "did", + "verkey", + {"public": False}, + DIDMethod.SOV, + KeyType.ED25519, + ) ) self.wallet.rotate_did_keypair_start = async_mock.CoroutineMock() self.wallet.rotate_did_keypair_apply = async_mock.CoroutineMock() @@ -674,7 +763,13 @@ async def test_rotate_did_keypair_did_not_local(self): await test_module.wallet_rotate_did_keypair(self.request) self.wallet.get_local_did = async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"posted": True, "public": True}) + return_value=DIDInfo( + "did", + "verkey", + {"posted": True, "public": True}, + DIDMethod.SOV, + KeyType.ED25519, + ) ) with self.assertRaises(test_module.web.HTTPBadRequest): await test_module.wallet_rotate_did_keypair(self.request) @@ -683,7 +778,13 @@ async def test_rotate_did_keypair_x(self): self.request.query = {"did": "did"} self.wallet.get_local_did = async_mock.CoroutineMock( - return_value=DIDInfo("did", "verkey", {"public": False}) + return_value=DIDInfo( + "did", + "verkey", + {"public": False}, + DIDMethod.SOV, + KeyType.ED25519, + ) ) self.wallet.rotate_did_keypair_start = async_mock.CoroutineMock( side_effect=test_module.WalletError() From 58f426b321f220f7c85839389d53fb592a100593 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sun, 28 Mar 2021 22:37:33 +0200 Subject: [PATCH 058/138] initial bbs signatures working in in memory wallet Signed-off-by: Timo Glastra --- aries_cloudagent/vc/ld_proofs/__init__.py | 8 +- .../crypto/Bls12381G2WalletKeyPair.py | 10 +- .../ld_proofs/crypto/Ed25519WalletKeyPair.py | 3 +- .../vc/ld_proofs/crypto/KeyPair.py | 4 +- .../vc/ld_proofs/crypto/WalletKeyPair.py | 68 +++++++++++++- .../vc/ld_proofs/crypto/__init__.py | 3 +- .../ld_proofs/suites/BbsBlsSignature2020.py | 20 ++-- .../suites/BbsBlsSignature2020Base.py | 19 ++-- .../suites/BbsBlsSignatureProof2020.py | 21 +++-- .../vc/ld_proofs/suites/LinkedDataProof.py | 1 + .../ld_proofs/suites/LinkedDataSignature.py | 7 +- .../vc/ld_proofs/tests/test_doc.py | 39 ++++++++ .../vc/ld_proofs/tests/test_ld_proofs.py | 94 ++++++++++++++++--- .../vc/ld_proofs/validation_result.py | 33 +++++++ aries_cloudagent/vc/tests/dids.py | 26 +++++ aries_cloudagent/vc/tests/document_loader.py | 8 +- .../vc/vc_ld/validation_result.py | 11 +++ 17 files changed, 322 insertions(+), 53 deletions(-) diff --git a/aries_cloudagent/vc/ld_proofs/__init__.py b/aries_cloudagent/vc/ld_proofs/__init__.py index 15c8324ab9..37832c134e 100644 --- a/aries_cloudagent/vc/ld_proofs/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/__init__.py @@ -15,7 +15,12 @@ BbsBlsSignature2020, BbsBlsSignatureProof2020, ) -from .crypto import KeyPair, Ed25519WalletKeyPair, Bls12381G2WalletKeyPair +from .crypto import ( + KeyPair, + Ed25519WalletKeyPair, + Bls12381G2WalletKeyPair, + WalletKeyPair, +) from .document_loader import DocumentLoader, get_default_document_loader from .error import LinkedDataProofException from .validation_result import DocumentVerificationResult, ProofResult, PurposeResult @@ -40,6 +45,7 @@ BbsBlsSignatureProof2020, # Key pairs KeyPair, + WalletKeyPair, Ed25519WalletKeyPair, Bls12381G2WalletKeyPair, # Document Loaders diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Bls12381G2WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Bls12381G2WalletKeyPair.py index c73b6cac16..5a6783dcf8 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/Bls12381G2WalletKeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/Bls12381G2WalletKeyPair.py @@ -4,6 +4,7 @@ from ....wallet.util import b58_to_bytes from ....wallet.base import BaseWallet +from ....wallet.crypto import KeyType from ..error import LinkedDataProofException from .WalletKeyPair import WalletKeyPair @@ -25,24 +26,25 @@ async def sign(self, messages: Union[List[bytes], bytes]) -> bytes: ) return await self.wallet.sign_message( - message=messages if type(messages) is list else [messages], + message=messages, from_verkey=self.public_key_base58, ) async def verify( self, messages: Union[List[bytes], bytes], signature: bytes ) -> bool: - """Verify message against signature using Ed25519 key.""" + """Verify message against signature using Bls12381G2 key.""" if not self.public_key_base58: raise LinkedDataProofException( - "Unable to verify message with Ed25519WalletKeyPair" + "Unable to verify message with Bls12381G2WalletKeyPair" ": No key to verify with" ) return await self.wallet.verify_message( - message=messages if type(messages) is list else [messages], + message=messages, signature=signature, from_verkey=self.public_key_base58, + key_type=KeyType.BLS12381G2, ) def from_verification_method( diff --git a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py index ce5bc97100..893790aa7a 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/Ed25519WalletKeyPair.py @@ -2,6 +2,7 @@ from typing import Optional +from ....wallet.crypto import KeyType from ....wallet.util import b58_to_bytes from ....wallet.base import BaseWallet from ..error import LinkedDataProofException @@ -37,7 +38,7 @@ async def verify(self, message: bytes, signature: bytes) -> bool: ) return await self.wallet.verify_message( - message, signature, self.public_key_base58 + message, signature, self.public_key_base58, KeyType.ED25519 ) def from_verification_method( diff --git a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py index 2e7e218304..1283023cb0 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/KeyPair.py @@ -9,13 +9,13 @@ class KeyPair(ABC): @abstractmethod async def sign(self, message: Union[List[bytes], bytes]) -> bytes: - """Sign message using key pair.""" + """Sign message(s) using key pair.""" @abstractmethod async def verify( self, message: Union[List[bytes], bytes], signature: bytes ) -> bool: - """Verify message against signature using key pair.""" + """Verify message(s) against signature using key pair.""" @abstractproperty def has_public_key(self) -> bool: diff --git a/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py b/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py index efb4d17507..6e820ae1a6 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/WalletKeyPair.py @@ -1,14 +1,76 @@ """Key pair based on base wallet interface.""" -from abc import ABCMeta +from typing import List, Optional, Union +from ....wallet.util import b58_to_bytes +from ....wallet.crypto import KeyType from ....wallet.base import BaseWallet +from ..error import LinkedDataProofException from .KeyPair import KeyPair -class WalletKeyPair(KeyPair, metaclass=ABCMeta): +class WalletKeyPair(KeyPair): """Base wallet key pair.""" - def __init__(self, *, wallet: BaseWallet) -> None: + def __init__( + self, + *, + wallet: BaseWallet, + key_type: KeyType, + public_key_base58: Optional[str] = None, + ) -> None: """Initialize new WalletKeyPair instance.""" + super().__init__() self.wallet = wallet + self.key_type = key_type + self.public_key_base58 = public_key_base58 + + async def sign(self, message: Union[List[bytes], bytes]) -> bytes: + """Sign message using wallet.""" + if not self.public_key_base58: + raise LinkedDataProofException( + "Unable to sign message with WalletKey: No key to sign with" + ) + return await self.wallet.sign_message( + message=message, + from_verkey=self.public_key_base58, + ) + + async def verify( + self, message: Union[List[bytes], bytes], signature: bytes + ) -> bool: + """Verify message against signature using wallet.""" + if not self.public_key_base58: + raise LinkedDataProofException( + "Unable to verify message with key pair: No key to verify with" + ) + + return await self.wallet.verify_message( + message=message, + signature=signature, + from_verkey=self.public_key_base58, + key_type=self.key_type, + ) + + def from_verification_method(self, verification_method: dict) -> "WalletKeyPair": + """Create new WalletKeyPair from public key in verification method.""" + if "publicKeyBase58" not in verification_method: + raise LinkedDataProofException( + "Unable to set public key from verification method: no publicKeyBase58" + ) + + return WalletKeyPair( + wallet=self.wallet, + key_type=self.key_type, + public_key_base58=verification_method["publicKeyBase58"], + ) + + @property + def public_key(self) -> Optional[bytes]: + """Getter for public key.""" + return b58_to_bytes(self.public_key_base58) + + @property + def has_public_key(self) -> bool: + """Whether key pair has public key.""" + return self.public_key_base58 is not None diff --git a/aries_cloudagent/vc/ld_proofs/crypto/__init__.py b/aries_cloudagent/vc/ld_proofs/crypto/__init__.py index ab9f969c2f..07a6a8a46c 100644 --- a/aries_cloudagent/vc/ld_proofs/crypto/__init__.py +++ b/aries_cloudagent/vc/ld_proofs/crypto/__init__.py @@ -1,5 +1,6 @@ from .KeyPair import KeyPair +from .WalletKeyPair import WalletKeyPair from .Ed25519WalletKeyPair import Ed25519WalletKeyPair from .Bls12381G2WalletKeyPair import Bls12381G2WalletKeyPair -__all__ = [KeyPair, Ed25519WalletKeyPair, Bls12381G2WalletKeyPair] +__all__ = [KeyPair, WalletKeyPair, Ed25519WalletKeyPair, Bls12381G2WalletKeyPair] diff --git a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py index c909cde747..d4709ea57e 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py +++ b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020.py @@ -3,13 +3,15 @@ from datetime import datetime from typing import List, Union -from .BbsBlsSignature2020Base import BbsBlsSignature2020Base + from ....wallet.util import b64_to_bytes, bytes_to_b64 +from ..constants import SECURITY_CONTEXT_V3_URL from ..crypto import KeyPair from ..error import LinkedDataProofException from ..validation_result import ProofResult from ..document_loader import DocumentLoader from ..purposes import ProofPurpose +from .BbsBlsSignature2020Base import BbsBlsSignature2020Base class BbsBlsSignature2020(BbsBlsSignature2020Base): @@ -139,7 +141,7 @@ def _create_verify_data( """ proof_statements = self._create_verify_proof_data( - proof=proof, document_loader=document_loader + proof=proof, document=document, document_loader=document_loader ) document_statements = self._create_verify_document_data( document=document, document_loader=document_loader @@ -149,9 +151,15 @@ def _create_verify_data( return [*proof_statements, *document_statements] - def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None): - """Canonize proof dictionary. Removes jws, signature, etc...""" - proof = proof.copy() + def _canonize_proof( + self, *, proof: dict, document: dict, document_loader: DocumentLoader + ): + """Canonize proof dictionary. Removes value that are not part of signature.""" + # Use default security context url if document has no context + proof = { + "@context": document.get("@context") or SECURITY_CONTEXT_V3_URL, + **proof, + } proof.pop("proofValue", None) @@ -213,4 +221,4 @@ async def verify_signature( if not key_pair.has_public_key: key_pair = key_pair.from_verification_method(verification_method) - return await self.key_pair.verify(verify_data, signature) + return await key_pair.verify(verify_data, signature) \ No newline at end of file diff --git a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py index f52f006654..5f6887d47d 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py +++ b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignature2020Base.py @@ -13,26 +13,26 @@ class BbsBlsSignature2020Base(LinkedDataProof, metaclass=ABCMeta): """Base class for BbsBlsSignature suites.""" def _create_verify_proof_data( - self, proof: dict, document_loader: DocumentLoader + self, *, proof: dict, document: dict, document_loader: DocumentLoader ) -> List[str]: """Create proof verification data.""" c14_proof_options = self._canonize_proof( - proof=proof, document_loader=document_loader + proof=proof, document=document, document_loader=document_loader ) # Return only the lines that have any content in them # e.g. "aa\nbb\n\n\ncccdkea\n" -> ['aa', 'bb', 'cccdkea'] - list(filter(lambda _: len(_) > 0, c14_proof_options.split("\n"))) + return list(filter(lambda _: len(_) > 0, c14_proof_options.split("\n"))) def _create_verify_document_data( - self, document: dict, document_loader: DocumentLoader + self, *, document: dict, document_loader: DocumentLoader ) -> List[str]: """Create document verification data.""" c14n_doc = self._canonize(input=document, document_loader=document_loader) # Return only the lines that have any content in them # e.g. "aa\nbb\n\n\ncccdkea\n" -> ['aa', 'bb', 'cccdkea'] - list(filter(lambda _: len(_) > 0, c14n_doc.split("\n"))) + return list(filter(lambda _: len(_) > 0, c14n_doc.split("\n"))) def _canonize(self, *, input, document_loader: DocumentLoader = None) -> str: """Canonize input document using URDNA2015 algorithm.""" @@ -47,12 +47,15 @@ def _canonize(self, *, input, document_loader: DocumentLoader = None) -> str: ) @abstractmethod - def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None): - """Canonize proof dictionary. Removes values that are not part of proof.""" + def _canonize_proof( + self, *, proof: dict, document: dict, document_loader: DocumentLoader = None + ): + """Canonize proof dictionary. Removes values that are not part of signature.""" def _assert_verification_method(self, verification_method: dict): """Assert verification method. Throws if not ok.""" - required_key_type = "Bls12381G2Key2020" + # FIXME: bls is not in security yet. (may be fixed in lower level class) + required_key_type = "sec:Bls12381G2Key2020" if not jsonld.JsonLdProcessor.has_value( verification_method, "type", required_key_type ): diff --git a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py index 395da0b0a1..a11b68b460 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py +++ b/aries_cloudagent/vc/ld_proofs/suites/BbsBlsSignatureProof2020.py @@ -4,10 +4,6 @@ from pyld import jsonld from typing import List - -from .LinkedDataProof import DeriveProofResult -from .BbsBlsSignature2020Base import BbsBlsSignature2020Base -from .BbsBlsSignature2020 import BbsBlsSignature2020 from ....wallet.util import b64_to_bytes, bytes_to_b64 from ..crypto import KeyPair from ..error import LinkedDataProofException @@ -15,6 +11,9 @@ from ..document_loader import DocumentLoader from ..purposes import ProofPurpose from ..constants import SECURITY_CONTEXT_V3_URL +from .BbsBlsSignature2020Base import BbsBlsSignature2020Base +from .BbsBlsSignature2020 import BbsBlsSignature2020 +from .LinkedDataProof import DeriveProofResult class BbsBlsSignatureProof2020(BbsBlsSignature2020Base): @@ -35,10 +34,6 @@ def __init__( """ super().__init__( signature_type=BbsBlsSignatureProof2020.signature_type, - proof={ - "@context": SECURITY_CONTEXT_V3_URL, - "type": BbsBlsSignatureProof2020.signature_type, - }, supported_derive_proof_types=( BbsBlsSignatureProof2020.supported_derive_proof_types ), @@ -265,9 +260,15 @@ async def verify_proof( except Exception as err: return ProofResult(verified=False, error=err) - def _canonize_proof(self, *, proof: dict, document_loader: DocumentLoader = None): + def _canonize_proof( + self, *, proof: dict, document: dict, document_loader: DocumentLoader + ): """Canonize proof dictionary. Removes proofValue.""" - proof = proof.copy() + # Use default security context url if document has no context + proof = { + "@context": document.get("@context") or SECURITY_CONTEXT_V3_URL, + **proof, + } proof.pop("proofValue", None) proof.pop("nonce", None) diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py index 990383a720..8b350d1f82 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataProof.py @@ -124,6 +124,7 @@ def _get_verification_method( if not verification_method: raise LinkedDataProofException('No "verificationMethod" found in proof') + # TODO: This should optionally use the context of the document? framed = jsonld.frame( verification_method, frame={ diff --git a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py index a785dd8d29..5c4d754ac4 100644 --- a/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py +++ b/aries_cloudagent/vc/ld_proofs/suites/LinkedDataSignature.py @@ -180,7 +180,7 @@ def _create_verify_data( + sha256(c14n_doc.encode("utf-8")).digest() ) - def _canonize(self, *, input, document_loader: DocumentLoader = None) -> str: + def _canonize(self, *, input, document_loader: DocumentLoader) -> str: """Canonize input document using URDNA2015 algorithm.""" # application/n-quads format always returns str return jsonld.normalize( @@ -193,10 +193,11 @@ def _canonize(self, *, input, document_loader: DocumentLoader = None) -> str: ) def _canonize_proof( - self, *, proof: dict, document: dict, document_loader: DocumentLoader = None + self, *, proof: dict, document: dict, document_loader: DocumentLoader ): """Canonize proof dictionary. Removes jws, signature, etc...""" - proof = {"@context": proof.get("@context") or SECURITY_CONTEXT_URL, **proof} + # Use default security context url if document has no context + proof = {"@context": document.get("@context") or SECURITY_CONTEXT_URL, **proof} proof.pop("jws", None) proof.pop("signatureValue", None) diff --git a/aries_cloudagent/vc/ld_proofs/tests/test_doc.py b/aries_cloudagent/vc/ld_proofs/tests/test_doc.py index f67a6da58a..a43c9248be 100644 --- a/aries_cloudagent/vc/ld_proofs/tests/test_doc.py +++ b/aries_cloudagent/vc/ld_proofs/tests/test_doc.py @@ -37,6 +37,45 @@ }, } +DOC_TEMPLATE_BBS = { + "@context": [ + "https://w3id.org/security/v2", + "https://w3id.org/security/bbs/v1", + { + "schema": "http://schema.org/", + "name": "schema:name", + "homepage": "schema:url", + "image": "schema:image", + }, + ], + "name": "Manu Sporny", + "homepage": "https://manu.sporny.org/", + "image": "https://manu.sporny.org/images/manu.png", +} + +DOC_SIGNED_BBS = { + "@context": [ + "https://w3id.org/security/v2", + "https://w3id.org/security/bbs/v1", + { + "schema": "http://schema.org/", + "name": "schema:name", + "homepage": "schema:url", + "image": "schema:image", + }, + ], + "name": "Manu Sporny", + "homepage": "https://manu.sporny.org/", + "image": "https://manu.sporny.org/images/manu.png", + "proof": { + "type": "BbsBlsSignature2020", + "verificationMethod": "did:key:zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa#zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa", + "created": "2019-12-11T03:50:55", + "proofPurpose": "assertionMethod", + "proofValue": "jYdVXtAFahqd9Zp09EEQuALXFDhKTz/GfbfvjksFEzOl4zrk4xprdJo5eRcpKr2URlpAFRZ9Civr4h3++a2/Vk9sLZ5cm4/LWeY2H0PQEIcizdIX83n0LvNZoS1+jeCI2b2C6UDva3CzFyVHHKOZfw==", + }, +} + DOC_VERIFIED = DocumentVerificationResult( verified=True, document={ diff --git a/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py b/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py index 5405c6e873..d87d2cde9c 100644 --- a/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py +++ b/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py @@ -10,12 +10,20 @@ from ...ld_proofs import ( sign, Ed25519Signature2018, - Ed25519WalletKeyPair, + WalletKeyPair, AssertionProofPurpose, verify, + BbsBlsSignature2020, + Bls12381G2WalletKeyPair, ) from ...tests.document_loader import custom_document_loader -from .test_doc import DOC_TEMPLATE, DOC_SIGNED, DOC_VERIFIED +from .test_doc import ( + DOC_SIGNED_BBS, + DOC_TEMPLATE, + DOC_SIGNED, + DOC_TEMPLATE_BBS, + DOC_VERIFIED, +) class TestLDProofs(TestCase): @@ -25,19 +33,32 @@ class TestLDProofs(TestCase): async def setUp(self): self.profile = InMemoryProfile.test_profile() self.wallet = InMemoryWallet(self.profile) - self.key_info = await self.wallet.create_signing_key(self.test_seed) - self.verification_method = DIDKey.from_public_key_b58( - self.key_info.verkey, KeyType.ED25519 + + self.ed25519_key_info = await self.wallet.create_signing_key( + key_type=KeyType.ED25519, seed=self.test_seed + ) + self.ed25519_verification_method = DIDKey.from_public_key_b58( + self.ed25519_key_info.verkey, KeyType.ED25519 + ).key_id + + self.bls12381g2_key_info = await self.wallet.create_signing_key( + key_type=KeyType.BLS12381G2, seed=self.test_seed + ) + + self.bls12381g2_verification_method = DIDKey.from_public_key_b58( + self.bls12381g2_key_info.verkey, KeyType.BLS12381G2 ).key_id - async def test_sign(self): + async def test_sign_Ed25519Signature2018(self): # Use different key pair and suite for signing and verification # as during verification a lot of information can be extracted # from the proof / document suite = Ed25519Signature2018( - verification_method=self.verification_method, - key_pair=Ed25519WalletKeyPair( - wallet=self.wallet, public_key_base58=self.key_info.verkey + verification_method=self.ed25519_verification_method, + key_pair=WalletKeyPair( + wallet=self.wallet, + key_type=KeyType.ED25519, + public_key_base58=self.ed25519_key_info.verkey, ), date=datetime.strptime("2019-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), ) @@ -48,12 +69,12 @@ async def test_sign(self): document_loader=custom_document_loader, ) - assert DOC_SIGNED == signed + assert signed == DOC_SIGNED - async def test_verify(self): + async def test_verify_Ed25519Signature2018(self): # Verification requires lot less input parameters suite = Ed25519Signature2018( - key_pair=Ed25519WalletKeyPair(wallet=self.wallet), + key_pair=WalletKeyPair(wallet=self.wallet, key_type=KeyType.ED25519), ) result = await verify( @@ -66,4 +87,51 @@ async def test_verify(self): if not result.verified: raise result.errors[0] - assert DOC_VERIFIED == result + assert result == DOC_VERIFIED + + async def test_sign_BbsBlsSignature2020(self): + # Use different key pair and suite for signing and verification + # as during verification a lot of information can be extracted + # from the proof / document + suite = BbsBlsSignature2020( + verification_method=self.bls12381g2_verification_method, + key_pair=WalletKeyPair( + wallet=self.wallet, + key_type=KeyType.BLS12381G2, + public_key_base58=self.bls12381g2_key_info.verkey, + ), + date=datetime.strptime("2019-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), + ) + signed = await sign( + document=DOC_TEMPLATE_BBS, + suite=suite, + purpose=AssertionProofPurpose(), + document_loader=custom_document_loader, + ) + + # BBS generates unique signature every time so we cant compare signatures + assert signed + + result = await verify( + document=signed, + suites=[suite], + purpose=AssertionProofPurpose(), + document_loader=custom_document_loader, + ) + + assert result.verified + + async def test_verify_BbsBlsSignature2020(self): + # Verification requires lot less input parameters + suite = BbsBlsSignature2020( + key_pair=WalletKeyPair(wallet=self.wallet, key_type=KeyType.BLS12381G2), + ) + + result = await verify( + document=DOC_SIGNED_BBS, + suites=[suite], + purpose=AssertionProofPurpose(), + document_loader=custom_document_loader, + ) + + assert result.verified diff --git a/aries_cloudagent/vc/ld_proofs/validation_result.py b/aries_cloudagent/vc/ld_proofs/validation_result.py index 869f11dfb9..543a3be56e 100644 --- a/aries_cloudagent/vc/ld_proofs/validation_result.py +++ b/aries_cloudagent/vc/ld_proofs/validation_result.py @@ -14,6 +14,17 @@ def __init__( self.error = error self.controller = controller + def __repr__(self) -> str: + """ + Return a human readable representation of this class. + + Returns: + A human readable string for this class + + """ + items = ("{}={}".format(k, repr(v)) for k, v in self.__dict__.items()) + return "<{}({})>".format(self.__class__.__name__, ", ".join(items)) + def __eq__(self, other: object) -> bool: """Comparison between proof purpose results.""" if isinstance(other, PurposeResult): @@ -42,6 +53,17 @@ def __init__( self.error = error self.purpose_result = purpose_result + def __repr__(self) -> str: + """ + Return a human readable representation of this class. + + Returns: + A human readable string for this class + + """ + items = ("{}={}".format(k, repr(v)) for k, v in self.__dict__.items()) + return "<{}({})>".format(self.__class__.__name__, ", ".join(items)) + def __eq__(self, other: object) -> bool: """Comparison between proof results.""" if isinstance(other, ProofResult): @@ -71,6 +93,17 @@ def __init__( self.results = results self.errors = errors + def __repr__(self) -> str: + """ + Return a human readable representation of this class. + + Returns: + A human readable string for this class + + """ + items = ("{}={}".format(k, repr(v)) for k, v in self.__dict__.items()) + return "<{}({})>".format(self.__class__.__name__, ", ".join(items)) + def __eq__(self, other: object) -> bool: """Comparison between document verification results.""" if isinstance(other, DocumentVerificationResult): diff --git a/aries_cloudagent/vc/tests/dids.py b/aries_cloudagent/vc/tests/dids.py index faf5d1542d..db2710d464 100644 --- a/aries_cloudagent/vc/tests/dids.py +++ b/aries_cloudagent/vc/tests/dids.py @@ -30,3 +30,29 @@ } ], } + +DID_zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa = { + "@context": "https://www.w3.org/ns/did/v1", + "id": "did:key:zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa", + "verificationMethod": [ + { + "id": "did:key:zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa#zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa", + "type": "Bls12381G2Key2020", + "controller": "did:key:zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa", + "publicKeyBase58": "nZZe9Nizhaz9JGpgjysaNkWGg5TNEhpib5j6WjTUHJ5K46dedUrZ57PUFZBq9Xckv8mFJjx6G6Vvj2rPspq22BagdADEEEy2F8AVLE1DhuwWC5vHFa4fUhUwxMkH7B6joqG", + } + ], + "authentication": [ + "did:key:zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa#zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa" + ], + "assertionMethod": [ + "did:key:zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa#zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa" + ], + "capabilityDelegation": [ + "did:key:zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa#zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa" + ], + "capabilityInvocation": [ + "did:key:zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa#zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa" + ], + "keyAgreement": [], +} diff --git a/aries_cloudagent/vc/tests/document_loader.py b/aries_cloudagent/vc/tests/document_loader.py index 0ff118040a..0d645d8459 100644 --- a/aries_cloudagent/vc/tests/document_loader.py +++ b/aries_cloudagent/vc/tests/document_loader.py @@ -15,12 +15,18 @@ SECURITY_CONTEXT_BBS_URL, CREDENTIALS_CONTEXT_V1_URL, ) -from .dids import DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL +from .dids import ( + DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL, + DID_zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa, +) DOCUMENTS = { DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL.get( "id" ): DID_z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL, + DID_zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa.get( + "id" + ): DID_zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa, SECURITY_CONTEXT_V1_URL: SECURITY_V1, SECURITY_CONTEXT_V2_URL: SECURITY_V2, DID_V1_CONTEXT_URL: DID_V1, diff --git a/aries_cloudagent/vc/vc_ld/validation_result.py b/aries_cloudagent/vc/vc_ld/validation_result.py index 4223e5062f..1f25a6220e 100644 --- a/aries_cloudagent/vc/vc_ld/validation_result.py +++ b/aries_cloudagent/vc/vc_ld/validation_result.py @@ -22,6 +22,17 @@ def __init__( self.credential_results = credential_results self.errors = errors + def __repr__(self) -> str: + """ + Return a human readable representation of this class. + + Returns: + A human readable string for this class + + """ + items = ("{}={}".format(k, repr(v)) for k, v in self.__dict__.items()) + return "<{}({})>".format(self.__class__.__name__, ", ".join(items)) + def __eq__(self, other: object) -> bool: """Comparison between presentation verification results.""" if isinstance(other, PresentationVerificationResult): From a41301ca2ca4ac67fc80b1c73e8253547a6e93f1 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 29 Mar 2021 14:41:09 +0200 Subject: [PATCH 059/138] bls key support for indy wallet Signed-off-by: Timo Glastra --- aries_cloudagent/config/wallet.py | 23 +- aries_cloudagent/core/tests/test_conductor.py | 26 +- .../messaging/decorators/attach_decorator.py | 5 +- .../decorators/signature_decorator.py | 5 +- .../decorators/tests/test_attach_decorator.py | 11 +- .../tests/test_signature_decorator.py | 8 +- .../messaging/jsonld/credential.py | 5 +- .../messaging/jsonld/tests/test_routes.py | 5 +- .../messaging/tests/test_agent_message.py | 6 +- .../protocols/connections/v1_0/manager.py | 18 +- .../tests/test_connection_response.py | 4 +- .../connections/v1_0/tests/test_manager.py | 130 ++++- .../coordinate_mediation/v1_0/manager.py | 7 +- .../handlers/tests/test_request_handler.py | 5 +- .../handlers/tests/test_response_handler.py | 6 +- .../protocols/didexchange/v1_0/manager.py | 21 +- .../v1_0/messages/tests/test_request.py | 11 +- .../v1_0/messages/tests/test_response.py | 11 +- .../didexchange/v1_0/tests/test_manager.py | 124 ++++- .../v2_0/formats/ld_proof/handler.py | 11 +- .../protocols/out_of_band/v1_0/manager.py | 7 +- .../transport/tests/test_pack_format.py | 13 +- .../vc/ld_proofs/tests/test_ld_proofs.py | 2 - .../vc/vc_ld/tests/test_credential.py | 42 ++ aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py | 95 +++- aries_cloudagent/wallet/base.py | 6 +- aries_cloudagent/wallet/crypto.py | 78 +++ aries_cloudagent/wallet/in_memory.py | 2 +- aries_cloudagent/wallet/indy.py | 524 ++++++++++++++---- .../wallet/models/key_pair_record.py | 90 --- .../models/tests/test_key_pair_record.py | 42 -- aries_cloudagent/wallet/routes.py | 54 +- .../wallet/tests/test_in_memory_wallet.py | 293 +++++++--- .../wallet/tests/test_indy_wallet.py | 82 +-- 34 files changed, 1248 insertions(+), 524 deletions(-) delete mode 100644 aries_cloudagent/wallet/models/key_pair_record.py delete mode 100644 aries_cloudagent/wallet/models/tests/test_key_pair_record.py diff --git a/aries_cloudagent/config/wallet.py b/aries_cloudagent/config/wallet.py index ec85ea7b98..0b16f18302 100644 --- a/aries_cloudagent/config/wallet.py +++ b/aries_cloudagent/config/wallet.py @@ -1,11 +1,12 @@ """Wallet configuration.""" import logging +from typing import Tuple from ..core.error import ProfileNotFoundError from ..core.profile import Profile, ProfileManager from ..wallet.base import BaseWallet, DIDInfo -from ..wallet.crypto import seed_to_did +from ..wallet.crypto import DIDMethod, KeyType, seed_to_did from .base import ConfigError from .injection_context import InjectionContext @@ -17,7 +18,7 @@ async def wallet_config( context: InjectionContext, provision: bool = False -) -> (Profile, DIDInfo): +) -> Tuple[Profile, DIDInfo]: """Initialize the root profile.""" mgr = context.inject(ProfileManager) @@ -64,7 +65,9 @@ async def wallet_config( public_did = public_did_info.did if wallet_seed and seed_to_did(wallet_seed) != public_did: if context.settings.get("wallet.replace_public_did"): - replace_did_info = await wallet.create_local_did(wallet_seed) + replace_did_info = await wallet.create_local_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519, seed=wallet_seed + ) public_did = replace_did_info.did await wallet.set_public_did(public_did) print(f"Created new public DID: {public_did}") @@ -83,14 +86,19 @@ async def wallet_config( metadata = {"endpoint": endpoint} if endpoint else None local_did_info = await wallet.create_local_did( - seed=wallet_seed, metadata=metadata + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=wallet_seed, + metadata=metadata, ) local_did = local_did_info.did if provision: print(f"Created new local DID: {local_did}") print(f"Verkey: {local_did_info.verkey}") else: - public_did_info = await wallet.create_public_did(seed=wallet_seed) + public_did_info = await wallet.create_public_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519, seed=wallet_seed + ) public_did = public_did_info.did if provision: print(f"Created new public DID: {public_did}") @@ -107,7 +115,10 @@ async def wallet_config( test_seed = "testseed000000000000000000000001" if test_seed: await wallet.create_local_did( - seed=test_seed, metadata={"endpoint": "1.2.3.4:8021"} + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=test_seed, + metadata={"endpoint": "1.2.3.4:8021"}, ) await txn.commit() diff --git a/aries_cloudagent/core/tests/test_conductor.py b/aries_cloudagent/core/tests/test_conductor.py index 3696f4506d..1c05a6046c 100644 --- a/aries_cloudagent/core/tests/test_conductor.py +++ b/aries_cloudagent/core/tests/test_conductor.py @@ -29,6 +29,7 @@ from ...transport.pack_format import PackWireFormat from ...utils.stats import Collector from ...wallet.base import BaseWallet +from ...wallet.crypto import KeyType, DIDMethod from .. import conductor as test_module @@ -107,7 +108,10 @@ async def test_startup(self): session = await conductor.root_profile.session() wallet = session.inject(BaseWallet) - await wallet.create_public_did() + await wallet.create_public_did( + DIDMethod.SOV, + KeyType.ED25519, + ) mock_inbound_mgr.return_value.setup.assert_awaited_once() mock_outbound_mgr.return_value.setup.assert_awaited_once() @@ -499,7 +503,10 @@ async def test_admin(self): session = await conductor.root_profile.session() wallet = session.inject(BaseWallet) - await wallet.create_public_did() + await wallet.create_public_did( + DIDMethod.SOV, + KeyType.ED25519, + ) with async_mock.patch.object( admin, "start", autospec=True @@ -529,7 +536,10 @@ async def test_admin_startx(self): session = await conductor.root_profile.session() wallet = session.inject(BaseWallet) - await wallet.create_public_did() + await wallet.create_public_did( + DIDMethod.SOV, + KeyType.ED25519, + ) with async_mock.patch.object( admin, "start", autospec=True @@ -577,7 +587,10 @@ async def test_start_static(self): session = await conductor.root_profile.session() wallet = session.inject(BaseWallet) - await wallet.create_public_did() + await wallet.create_public_did( + DIDMethod.SOV, + KeyType.ED25519, + ) mock_mgr.return_value.create_static_connection = async_mock.CoroutineMock() await conductor.start() @@ -695,7 +708,10 @@ async def test_print_invite_connection(self): session = await conductor.root_profile.session() wallet = session.inject(BaseWallet) - await wallet.create_public_did() + await wallet.create_public_did( + DIDMethod.SOV, + KeyType.ED25519, + ) await conductor.start() await conductor.stop() diff --git a/aries_cloudagent/messaging/decorators/attach_decorator.py b/aries_cloudagent/messaging/decorators/attach_decorator.py index 205cb1b792..cf594301bf 100644 --- a/aries_cloudagent/messaging/decorators/attach_decorator.py +++ b/aries_cloudagent/messaging/decorators/attach_decorator.py @@ -23,6 +23,7 @@ str_to_b64, unpad, ) +from ...wallet.crypto import KeyType from ..models.base import BaseModel, BaseModelError, BaseModelSchema from ..valid import ( BASE64, @@ -437,7 +438,9 @@ async def verify(self, wallet: BaseWallet) -> bool: sign_input = (b64_protected + "." + b64_payload).encode("ascii") b_sig = b64_to_bytes(b64_sig, urlsafe=True) verkey = bytes_to_b58(b64_to_bytes(protected["jwk"]["x"], urlsafe=True)) - if not await wallet.verify_message(sign_input, b_sig, verkey): + if not await wallet.verify_message( + sign_input, b_sig, verkey, KeyType.ED25519 + ): return False return True diff --git a/aries_cloudagent/messaging/decorators/signature_decorator.py b/aries_cloudagent/messaging/decorators/signature_decorator.py index e11e67b1fd..22e3e32def 100644 --- a/aries_cloudagent/messaging/decorators/signature_decorator.py +++ b/aries_cloudagent/messaging/decorators/signature_decorator.py @@ -9,6 +9,7 @@ from ...protocols.didcomm_prefix import DIDCommPrefix from ...wallet.base import BaseWallet from ...wallet.util import b64_to_bytes, bytes_to_b64 +from ...wallet.crypto import KeyType from ..models.base import BaseModel, BaseModelSchema from ..valid import Base64URL, BASE64URL, INDY_RAW_PUBLIC_KEY @@ -110,7 +111,9 @@ async def verify(self, wallet: BaseWallet) -> bool: return False msg_bin = b64_to_bytes(self.sig_data, urlsafe=True) sig_bin = b64_to_bytes(self.signature, urlsafe=True) - return await wallet.verify_message(msg_bin, sig_bin, self.signer) + return await wallet.verify_message( + msg_bin, sig_bin, self.signer, KeyType.ED25519 + ) def __str__(self): """Get a string representation of this class.""" diff --git a/aries_cloudagent/messaging/decorators/tests/test_attach_decorator.py b/aries_cloudagent/messaging/decorators/tests/test_attach_decorator.py index bff4ba7c0f..d8d708ae4d 100644 --- a/aries_cloudagent/messaging/decorators/tests/test_attach_decorator.py +++ b/aries_cloudagent/messaging/decorators/tests/test_attach_decorator.py @@ -4,13 +4,13 @@ from copy import deepcopy from datetime import datetime, timezone -from time import time from unittest import TestCase from ....indy.sdk.wallet_setup import IndyWalletConfig from ....messaging.models.base import BaseModelError from ....wallet.indy import IndySdkWallet from ....wallet.util import b64_to_bytes, bytes_to_b64 +from ....wallet.crypto import KeyType, DIDMethod from ..attach_decorator import ( AttachDecorator, @@ -413,7 +413,9 @@ def test_data_json(self): class TestAttachDecoratorSignature: @pytest.mark.asyncio async def test_did_raw_key(self, wallet, seed): - did_info = await wallet.create_local_did(seed[0]) + did_info = await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, seed[0] + ) did_key0 = did_key(did_info.verkey) raw_key0 = raw_key(did_key0) assert raw_key0 != did_key0 @@ -434,7 +436,10 @@ async def test_indy_sign(self, wallet, seed): byte_count=BYTE_COUNT, ) deco_indy_master = deepcopy(deco_indy) - did_info = [await wallet.create_local_did(seed[i]) for i in [0, 1]] + did_info = [ + await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519, seed[i]) + for i in [0, 1] + ] assert deco_indy.data.signatures == 0 assert deco_indy.data.header_map() is None await deco_indy.data.sign(did_info[0].verkey, wallet) diff --git a/aries_cloudagent/messaging/decorators/tests/test_signature_decorator.py b/aries_cloudagent/messaging/decorators/tests/test_signature_decorator.py index f6ea7cc6e8..26bb807e77 100644 --- a/aries_cloudagent/messaging/decorators/tests/test_signature_decorator.py +++ b/aries_cloudagent/messaging/decorators/tests/test_signature_decorator.py @@ -1,11 +1,9 @@ -import pytest - -from asynctest import TestCase as AsyncTestCase, mock as async_mock +from asynctest import TestCase as AsyncTestCase +from ....wallet.crypto import KeyType from ....core.in_memory import InMemoryProfile from ....protocols.trustping.v1_0.messages.ping import Ping from ....wallet.in_memory import InMemoryWallet -from .. import signature_decorator as test_module from ..signature_decorator import SignatureDecorator TEST_VERKEY = "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx" @@ -45,7 +43,7 @@ async def test_create_decode_verify(self): profile = InMemoryProfile.test_profile() wallet = InMemoryWallet(profile) - key_info = await wallet.create_signing_key() + key_info = await wallet.create_signing_key(KeyType.ED25519) deco = await SignatureDecorator.create( Ping(), key_info.verkey, wallet, timestamp=None diff --git a/aries_cloudagent/messaging/jsonld/credential.py b/aries_cloudagent/messaging/jsonld/credential.py index 20bf334225..5fdd42b3d4 100644 --- a/aries_cloudagent/messaging/jsonld/credential.py +++ b/aries_cloudagent/messaging/jsonld/credential.py @@ -10,6 +10,7 @@ bytes_to_b64, str_to_b64, ) +from ...wallet.crypto import KeyType from .create_verify_data import create_verify_data @@ -88,7 +89,9 @@ async def jws_verify(verify_data, signature, public_key, wallet): jws_to_verify = create_jws(encoded_header, verify_data) - verified = await wallet.verify_message(jws_to_verify, decoded_signature, public_key) + verified = await wallet.verify_message( + jws_to_verify, decoded_signature, public_key, KeyType.ED25519 + ) return verified diff --git a/aries_cloudagent/messaging/jsonld/tests/test_routes.py b/aries_cloudagent/messaging/jsonld/tests/test_routes.py index 5509ec543c..d738345555 100644 --- a/aries_cloudagent/messaging/jsonld/tests/test_routes.py +++ b/aries_cloudagent/messaging/jsonld/tests/test_routes.py @@ -2,6 +2,7 @@ from asynctest import mock as async_mock from ....admin.request_context import AdminRequestContext +from ....wallet.crypto import KeyType, DIDMethod from .. import routes as test_module @@ -10,7 +11,9 @@ class TestJSONLDRoutes(AsyncTestCase): async def setUp(self): self.context = AdminRequestContext.test_context() - self.did_info = await (await self.context.session()).wallet.create_local_did() + self.did_info = await (await self.context.session()).wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519 + ) self.request_dict = { "context": self.context, "outbound_message_router": async_mock.CoroutineMock(), diff --git a/aries_cloudagent/messaging/tests/test_agent_message.py b/aries_cloudagent/messaging/tests/test_agent_message.py index 7b4a5a812d..8fef9cf974 100644 --- a/aries_cloudagent/messaging/tests/test_agent_message.py +++ b/aries_cloudagent/messaging/tests/test_agent_message.py @@ -1,12 +1,10 @@ -import json - from asynctest import TestCase as AsyncTestCase from marshmallow import EXCLUDE, fields from ...core.in_memory import InMemoryProfile from ...protocols.didcomm_prefix import DIDCommPrefix -from ...wallet.util import bytes_to_b64 +from ...wallet.crypto import KeyType from ..agent_message import AgentMessage, AgentMessageSchema from ..decorators.signature_decorator import SignatureDecorator from ..decorators.trace_decorator import TraceReport, TRACE_LOG_TARGET @@ -73,7 +71,7 @@ class BadImplementationClass(AgentMessage): async def test_field_signature(self): session = InMemoryProfile.test_session() wallet = session.wallet - key_info = await wallet.create_signing_key() + key_info = await wallet.create_signing_key(KeyType.ED25519) msg = SignedAgentMessage() msg.value = None diff --git a/aries_cloudagent/protocols/connections/v1_0/manager.py b/aries_cloudagent/protocols/connections/v1_0/manager.py index dca729d0c9..6fa69e9f81 100644 --- a/aries_cloudagent/protocols/connections/v1_0/manager.py +++ b/aries_cloudagent/protocols/connections/v1_0/manager.py @@ -183,7 +183,9 @@ async def create_invitation( invitation_key = recipient_keys[0] # TODO first key appropriate? else: # Create and store new invitation key - invitation_signing_key = await wallet.create_signing_key() + invitation_signing_key = await wallet.create_signing_key( + key_type=KeyType.ED25519 + ) invitation_key = invitation_signing_key.verkey recipient_keys = [invitation_key] mediation_mgr = MediationManager(self._session.profile) @@ -380,7 +382,7 @@ async def create_request( my_info = await wallet.get_local_did(connection.my_did) else: # Create new DID for connection - my_info = await wallet.create_local_did() + my_info = await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519) connection.my_did = my_info.did mediation_mgr = MediationManager(self._session.profile) keylist_updates = await mediation_mgr.add_key( @@ -499,7 +501,7 @@ async def receive_request( if connection.is_multiuse_invitation: wallet = self._session.inject(BaseWallet) - my_info = await wallet.create_local_did() + my_info = await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519) keylist_updates = await mediation_mgr.add_key( my_info.verkey, keylist_updates ) @@ -556,7 +558,7 @@ async def receive_request( elif not self._session.settings.get("public_invites"): raise ConnectionManagerError("Public invitations are not enabled") else: # request from public did - my_info = await wallet.create_local_did() + my_info = await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519) # send update-keylist message with new recipient keys keylist_updates = await mediation_mgr.add_key( my_info.verkey, keylist_updates @@ -659,7 +661,7 @@ async def create_response( if connection.my_did: my_info = await wallet.get_local_did(connection.my_did) else: - my_info = await wallet.create_local_did() + my_info = await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519) connection.my_did = my_info.did mediation_mgr = MediationManager(self._session.profile) keylist_updates = await mediation_mgr.add_key( @@ -875,7 +877,9 @@ async def create_static_connection( base_mediation_record = None # seed and DID optional - my_info = await wallet.create_local_did(my_seed, my_did) + my_info = await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, my_seed, my_did + ) # must provide their DID and verkey if the seed is not known if (not their_did or not their_verkey) and not their_seed: @@ -1116,7 +1120,7 @@ async def establish_inbound( my_info = await wallet.get_local_did(connection.my_did) else: # Create new DID for connection - my_info = await wallet.create_local_did() + my_info = await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519) connection.my_did = my_info.did try: diff --git a/aries_cloudagent/protocols/connections/v1_0/messages/tests/test_connection_response.py b/aries_cloudagent/protocols/connections/v1_0/messages/tests/test_connection_response.py index ed9387e737..5bb7db25f1 100644 --- a/aries_cloudagent/protocols/connections/v1_0/messages/tests/test_connection_response.py +++ b/aries_cloudagent/protocols/connections/v1_0/messages/tests/test_connection_response.py @@ -2,6 +2,7 @@ from asynctest import TestCase as AsyncTestCase +from ......wallet.crypto import KeyType from ......connections.models.diddoc import ( DIDDoc, PublicKey, @@ -11,7 +12,6 @@ from ......core.in_memory import InMemoryProfile from .....didcomm_prefix import DIDCommPrefix - from ...message_types import CONNECTION_RESPONSE from ...models.connection_detail import ConnectionDetail @@ -108,7 +108,7 @@ async def test_make_model(self): ) session = InMemoryProfile.test_session() wallet = session.wallet - key_info = await wallet.create_signing_key() + key_info = await wallet.create_signing_key(KeyType.ED25519) await connection_response.sign_field("connection", key_info.verkey, wallet) data = connection_response.serialize() model_instance = ConnectionResponse.deserialize(data) diff --git a/aries_cloudagent/protocols/connections/v1_0/tests/test_manager.py b/aries_cloudagent/protocols/connections/v1_0/tests/test_manager.py index fdd0ec8667..a1ddb8a132 100644 --- a/aries_cloudagent/protocols/connections/v1_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/connections/v1_0/tests/test_manager.py @@ -247,7 +247,11 @@ async def test_create_invitation_multi_use(self): async def test_create_invitation_recipient_routing_endpoint(self): await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) connect_record, connect_invite = await self.manager.create_invitation( my_endpoint=self.test_endpoint, @@ -466,7 +470,9 @@ async def test_create_request_my_endpoint(self): assert conn_req async def test_create_request_my_did(self): - await self.session.wallet.create_local_did(seed=None, did=self.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519, seed=None, did=self.test_did + ) conn_req = await self.manager.create_request( ConnRecord( invitation_key=self.test_verkey, @@ -661,7 +667,9 @@ async def test_receive_request_public_did(self): receipt = MessageReceipt(recipient_did=self.test_did, recipient_did_public=True) - await self.session.wallet.create_local_did(seed=None, did=self.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519, seed=None, did=self.test_did + ) self.context.update_settings({"public_invites": True}) with async_mock.patch.object( @@ -685,8 +693,12 @@ async def test_receive_request_public_did(self): assert "connection_id" in target async def test_receive_request_multi_use_multitenant(self): - multiuse_info = await self.session.wallet.create_local_did() - new_info = await self.session.wallet.create_local_did() + multiuse_info = await self.session.wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519 + ) + new_info = await self.session.wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519 + ) mock_request = async_mock.MagicMock() mock_request.connection = async_mock.MagicMock( @@ -728,7 +740,9 @@ async def test_receive_request_multi_use_multitenant(self): ) async def test_receive_request_public_multitenant(self): - new_info = await self.session.wallet.create_local_did() + new_info = await self.session.wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519 + ) mock_request = async_mock.MagicMock() mock_request.connection = async_mock.MagicMock(accept=ConnRecord.ACCEPT_MANUAL) @@ -785,7 +799,9 @@ async def test_receive_request_public_did_no_did_doc(self): receipt = MessageReceipt(recipient_did=self.test_did, recipient_did_public=True) - await self.session.wallet.create_local_did(seed=None, did=self.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519, seed=None, did=self.test_did + ) self.context.update_settings({"public_invites": True}) with async_mock.patch.object( @@ -809,7 +825,9 @@ async def test_receive_request_public_did_wrong_did(self): receipt = MessageReceipt(recipient_did=self.test_did, recipient_did_public=True) - await self.session.wallet.create_local_did(seed=None, did=self.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519, seed=None, did=self.test_did + ) self.context.update_settings({"public_invites": True}) with async_mock.patch.object( @@ -833,7 +851,9 @@ async def test_receive_request_public_did_no_public_invites(self): receipt = MessageReceipt(recipient_did=self.test_did, recipient_did_public=True) - await self.session.wallet.create_local_did(seed=None, did=self.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519, seed=None, did=self.test_did + ) self.context.update_settings({"public_invites": False}) with async_mock.patch.object( @@ -857,7 +877,9 @@ async def test_receive_request_public_did_no_auto_accept(self): receipt = MessageReceipt(recipient_did=self.test_did, recipient_did_public=True) - await self.session.wallet.create_local_did(seed=None, did=self.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519, seed=None, did=self.test_did + ) self.context.update_settings( {"public_invites": True, "debug.auto_accept_requests": False} @@ -888,7 +910,9 @@ async def test_receive_request_mediation_id(self): recipient_did=self.test_did, recipient_did_public=False ) - await self.session.wallet.create_local_did(seed=None, did=self.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519, seed=None, did=self.test_did + ) mediation_record = MediationRecord( role=MediationRecord.ROLE_CLIENT, @@ -1937,7 +1961,11 @@ async def test_did_key_storage(self): async def test_get_connection_targets_conn_invitation_no_did(self): local_did = await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) did_doc = self.make_did_doc( @@ -1991,7 +2019,11 @@ async def test_get_connection_targets_conn_invitation_no_did(self): async def test_get_connection_targets_retrieve_connection(self): local_did = await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) did_doc = self.make_did_doc( @@ -2039,7 +2071,11 @@ async def test_get_connection_targets_retrieve_connection(self): async def test_get_conn_targets_conn_invitation_no_cache(self): self.context.injector.clear_binding(BaseCache) local_did = await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) did_doc = self.make_did_doc( @@ -2083,7 +2119,11 @@ async def test_fetch_connection_targets_no_my_did(self): async def test_fetch_connection_targets_conn_invitation_did_no_ledger(self): await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) conn_invite = ConnectionInvitation( @@ -2116,7 +2156,11 @@ async def test_fetch_connection_targets_conn_invitation_did_ledger(self): self.context.injector.bind_instance(BaseLedger, self.ledger) local_did = await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) conn_invite = ConnectionInvitation( @@ -2147,7 +2191,11 @@ async def test_fetch_connection_targets_conn_invitation_did_ledger(self): async def test_fetch_connection_targets_oob_invitation_svc_did_no_ledger(self): await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) mock_oob_invite = async_mock.MagicMock(service_dids=["dummy"]) @@ -2173,7 +2221,11 @@ async def test_fetch_connection_targets_oob_invitation_svc_did_ledger(self): self.context.injector.bind_instance(BaseLedger, self.ledger) local_did = await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) mock_oob_invite = async_mock.MagicMock( @@ -2211,7 +2263,11 @@ async def test_fetch_connection_targets_oob_invitation_svc_block_ledger(self): self.context.injector.bind_instance(BaseLedger, self.ledger) local_did = await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) mock_oob_invite = async_mock.MagicMock( @@ -2251,7 +2307,11 @@ async def test_fetch_connection_targets_oob_invitation_svc_block_ledger(self): async def test_fetch_connection_targets_conn_initiator_completed_no_their_did(self): await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) mock_conn = async_mock.MagicMock( @@ -2263,7 +2323,11 @@ async def test_fetch_connection_targets_conn_initiator_completed_no_their_did(se async def test_fetch_connection_targets_conn_completed_their_did(self): local_did = await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) did_doc = self.make_did_doc(did=self.test_did, verkey=self.test_verkey) @@ -2304,7 +2368,11 @@ async def test_diddoc_connection_targets_diddoc_underspecified(self): async def test_establish_inbound(self): await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) mock_conn = async_mock.MagicMock( @@ -2329,7 +2397,11 @@ async def test_establish_inbound(self): async def test_establish_inbound_conn_rec_no_my_did(self): await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) mock_conn = async_mock.MagicMock() @@ -2353,7 +2425,11 @@ async def test_establish_inbound_conn_rec_no_my_did(self): async def test_establish_inbound_no_conn_record(self): await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) mock_conn = async_mock.MagicMock() @@ -2375,7 +2451,11 @@ async def test_establish_inbound_no_conn_record(self): async def test_establish_inbound_router_not_ready(self): await self.session.wallet.create_local_did( - seed=self.test_seed, did=self.test_did, metadata=None + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=self.test_seed, + did=self.test_did, + metadata=None, ) mock_conn = async_mock.MagicMock() diff --git a/aries_cloudagent/protocols/coordinate_mediation/v1_0/manager.py b/aries_cloudagent/protocols/coordinate_mediation/v1_0/manager.py index fdd7e88b24..9c851760c3 100644 --- a/aries_cloudagent/protocols/coordinate_mediation/v1_0/manager.py +++ b/aries_cloudagent/protocols/coordinate_mediation/v1_0/manager.py @@ -9,6 +9,7 @@ from ....storage.error import StorageNotFoundError from ....storage.record import StorageRecord from ....wallet.base import BaseWallet, DIDInfo +from ....wallet.crypto import KeyType, DIDMethod from ...routing.v1_0.manager import RoutingManager from ...routing.v1_0.models.route_record import RouteRecord from ...routing.v1_0.models.route_update import RouteUpdate @@ -99,7 +100,11 @@ async def _create_routing_did(self, session: ProfileSession) -> DIDInfo: """ wallet = session.inject(BaseWallet) storage = session.inject(BaseStorage) - info = await wallet.create_local_did(metadata={"type": "routing_did"}) + info = await wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + metadata={"type": "routing_did"}, + ) record = StorageRecord( type=self.ROUTING_DID_RECORD_TYPE, value=json.dumps({"verkey": info.verkey, "metadata": info.metadata}), diff --git a/aries_cloudagent/protocols/didexchange/v1_0/handlers/tests/test_request_handler.py b/aries_cloudagent/protocols/didexchange/v1_0/handlers/tests/test_request_handler.py index b70da3abd2..d825b0a3f1 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/handlers/tests/test_request_handler.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/handlers/tests/test_request_handler.py @@ -10,6 +10,7 @@ ) from ......core.profile import ProfileSession from ......core.in_memory import InMemoryProfile +from ......wallet.crypto import KeyType, DIDMethod from ......messaging.decorators.attach_decorator import AttachDecorator from ......messaging.request_context import RequestContext from ......messaging.responder import MockResponder @@ -86,7 +87,9 @@ async def setUp(self): await self.conn_rec.save(self.session) wallet = self.session.wallet - self.did_info = await wallet.create_local_did() + self.did_info = await wallet.create_local_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519 + ) self.did_doc_attach = AttachDecorator.data_base64(self.did_doc().serialize()) await self.did_doc_attach.data.sign(self.did_info.verkey, wallet) diff --git a/aries_cloudagent/protocols/didexchange/v1_0/handlers/tests/test_response_handler.py b/aries_cloudagent/protocols/didexchange/v1_0/handlers/tests/test_response_handler.py index 9f683e0a92..74b6f62b2b 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/handlers/tests/test_response_handler.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/handlers/tests/test_response_handler.py @@ -14,6 +14,7 @@ from ......messaging.responder import MockResponder from ......protocols.trustping.v1_0.messages.ping import Ping from ......transport.inbound.receipt import MessageReceipt +from ......wallet.crypto import KeyType, DIDMethod from ...handlers import response_handler as test_module from ...manager import DIDXManagerError @@ -60,7 +61,10 @@ async def setUp(self): self.ctx.message_receipt = MessageReceipt() wallet = (await self.ctx.session()).wallet - self.did_info = await wallet.create_local_did() + self.did_info = await wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) self.did_doc_attach = AttachDecorator.data_base64(self.did_doc().serialize()) await self.did_doc_attach.data.sign(self.did_info.verkey, wallet) diff --git a/aries_cloudagent/protocols/didexchange/v1_0/manager.py b/aries_cloudagent/protocols/didexchange/v1_0/manager.py index 8197b3b742..019b43857a 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/manager.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/manager.py @@ -14,6 +14,7 @@ from ....storage.error import StorageNotFoundError from ....transport.inbound.receipt import MessageReceipt from ....wallet.base import BaseWallet +from ....wallet.crypto import KeyType, DIDMethod from ....wallet.did_posture import DIDPosture from ....did.did_key import DIDKey from ....multitenant.manager import MultitenantManager @@ -236,7 +237,10 @@ async def create_request( my_info = await wallet.get_local_did(conn_rec.my_did) else: # Create new DID for connection - my_info = await wallet.create_local_did() + my_info = await wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) conn_rec.my_did = my_info.did keylist_updates = await mediation_mgr.add_key( my_info.verkey, keylist_updates @@ -364,7 +368,10 @@ async def receive_request( connection_key = conn_rec.invitation_key if conn_rec.is_multiuse_invitation: wallet = self._session.inject(BaseWallet) - my_info = await wallet.create_local_did() + my_info = await wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) keylist_updates = await mediation_mgr.add_key( my_info.verkey, keylist_updates ) @@ -434,7 +441,10 @@ async def receive_request( ) else: # request is against implicit invitation on public DID - my_info = await wallet.create_local_did() + my_info = await wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) keylist_updates = await mediation_mgr.add_key( my_info.verkey, keylist_updates @@ -545,7 +555,10 @@ async def create_response( if conn_rec.my_did: my_info = await wallet.get_local_did(conn_rec.my_did) else: - my_info = await wallet.create_local_did() + my_info = await wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) conn_rec.my_did = my_info.did keylist_updates = await mediation_mgr.add_key( my_info.verkey, keylist_updates diff --git a/aries_cloudagent/protocols/didexchange/v1_0/messages/tests/test_request.py b/aries_cloudagent/protocols/didexchange/v1_0/messages/tests/test_request.py index 2527224f25..64e323df51 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/messages/tests/test_request.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/messages/tests/test_request.py @@ -8,6 +8,7 @@ PublicKeyType, Service, ) +from ......wallet.crypto import DIDMethod, KeyType from ......core.in_memory import InMemoryProfile from ......messaging.decorators.attach_decorator import AttachDecorator @@ -56,7 +57,10 @@ def make_did_doc(self): class TestDIDXRequest(AsyncTestCase, TestConfig): async def setUp(self): self.wallet = InMemoryProfile.test_session().wallet - self.did_info = await self.wallet.create_local_did() + self.did_info = await self.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) did_doc_attach = AttachDecorator.data_base64(self.make_did_doc().serialize()) await did_doc_attach.data.sign(self.did_info.verkey, self.wallet) @@ -110,7 +114,10 @@ class TestDIDXRequestSchema(AsyncTestCase, TestConfig): async def setUp(self): self.wallet = InMemoryProfile.test_session().wallet - self.did_info = await self.wallet.create_local_did() + self.did_info = await self.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) did_doc_attach = AttachDecorator.data_base64(self.make_did_doc().serialize()) await did_doc_attach.data.sign(self.did_info.verkey, self.wallet) diff --git a/aries_cloudagent/protocols/didexchange/v1_0/messages/tests/test_response.py b/aries_cloudagent/protocols/didexchange/v1_0/messages/tests/test_response.py index e9fb7e0c9b..bf09b0bee9 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/messages/tests/test_response.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/messages/tests/test_response.py @@ -8,6 +8,7 @@ PublicKeyType, Service, ) +from ......wallet.crypto import DIDMethod, KeyType from ......core.in_memory import InMemoryProfile from ......messaging.decorators.attach_decorator import AttachDecorator @@ -55,7 +56,10 @@ def make_did_doc(self): class TestDIDXResponse(AsyncTestCase, TestConfig): async def setUp(self): self.wallet = InMemoryProfile.test_session().wallet - self.did_info = await self.wallet.create_local_did() + self.did_info = await self.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) did_doc_attach = AttachDecorator.data_base64(self.make_did_doc().serialize()) await did_doc_attach.data.sign(self.did_info.verkey, self.wallet) @@ -106,7 +110,10 @@ class TestDIDXResponseSchema(AsyncTestCase, TestConfig): async def setUp(self): self.wallet = InMemoryProfile.test_session().wallet - self.did_info = await self.wallet.create_local_did() + self.did_info = await self.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) did_doc_attach = AttachDecorator.data_base64(self.make_did_doc().serialize()) await did_doc_attach.data.sign(self.did_info.verkey, self.wallet) 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 a861831d12..85bcc667c9 100644 --- a/aries_cloudagent/protocols/didexchange/v1_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/didexchange/v1_0/tests/test_manager.py @@ -92,7 +92,10 @@ async def setUp(self): ) self.context = self.session.context - self.did_info = await self.session.wallet.create_local_did() + self.did_info = await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) self.ledger = async_mock.create_autospec(BaseLedger) self.ledger.__aenter__ = async_mock.CoroutineMock(return_value=self.ledger) @@ -412,7 +415,12 @@ async def test_receive_request_explicit_public_did(self): ) await mediation_record.save(self.session) - await self.session.wallet.create_local_did(seed=None, did=TestConfig.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=None, + did=TestConfig.test_did, + ) STATE_REQUEST = ConnRecord.State.REQUEST self.session.context.update_settings({"public_invites": True}) @@ -501,7 +509,12 @@ async def test_receive_request_invi_not_found(self): _thread=async_mock.MagicMock(pthid="explicit-not-a-did"), ) - await self.session.wallet.create_local_did(seed=None, did=TestConfig.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=None, + did=TestConfig.test_did, + ) with async_mock.patch.object( test_module, "ConnRecord", async_mock.MagicMock() @@ -522,14 +535,20 @@ async def test_receive_request_invi_not_found(self): assert "No explicit invitation found" in str(context.exception) async def test_receive_request_with_mediator_without_multi_use_multitenant(self): - multiuse_info = await self.session.wallet.create_local_did() + multiuse_info = await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) did_doc_dict = self.make_did_doc( did=TestConfig.test_target_did, verkey=TestConfig.test_target_verkey, ).serialize() del did_doc_dict["authentication"] del did_doc_dict["service"] - new_info = await self.session.wallet.create_local_did() + new_info = await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) mock_request = async_mock.MagicMock() mock_request.connection = async_mock.MagicMock( @@ -548,7 +567,12 @@ async def test_receive_request_with_mediator_without_multi_use_multitenant(self) ) ) - await self.session.wallet.create_local_did(seed=None, did=TestConfig.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=None, + did=TestConfig.test_did, + ) mediation_record = MediationRecord( role=MediationRecord.ROLE_CLIENT, @@ -599,14 +623,20 @@ async def test_receive_request_with_mediator_without_multi_use_multitenant(self) async def test_receive_request_with_mediator_without_multi_use_multitenant_mismatch( self, ): - multiuse_info = await self.session.wallet.create_local_did() + multiuse_info = await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) did_doc_dict = self.make_did_doc( did=TestConfig.test_target_did, verkey=TestConfig.test_target_verkey, ).serialize() del did_doc_dict["authentication"] del did_doc_dict["service"] - new_info = await self.session.wallet.create_local_did() + new_info = await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) mock_request = async_mock.MagicMock() mock_request.connection = async_mock.MagicMock( @@ -624,7 +654,12 @@ async def test_receive_request_with_mediator_without_multi_use_multitenant_misma ) ) - await self.session.wallet.create_local_did(seed=None, did=TestConfig.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=None, + did=TestConfig.test_did, + ) mediation_record = MediationRecord( role=MediationRecord.ROLE_CLIENT, @@ -673,7 +708,12 @@ async def test_receive_request_public_did_no_did_doc_attachment(self): _thread=async_mock.MagicMock(pthid="did:sov:publicdid0000000000000"), ) - await self.session.wallet.create_local_did(seed=None, did=TestConfig.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=None, + did=TestConfig.test_did, + ) self.session.context.update_settings({"public_invites": True}) mock_conn_rec_state_request = ConnRecord.State.REQUEST @@ -726,7 +766,12 @@ async def test_receive_request_public_did_x_not_public(self): _thread=async_mock.MagicMock(pthid="did:sov:publicdid0000000000000"), ) - await self.session.wallet.create_local_did(seed=None, did=TestConfig.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=None, + did=TestConfig.test_did, + ) self.session.context.update_settings({"public_invites": True}) mock_conn_rec_state_request = ConnRecord.State.REQUEST @@ -764,7 +809,12 @@ async def test_receive_request_public_did_x_wrong_did(self): _thread=async_mock.MagicMock(pthid="did:sov:publicdid0000000000000"), ) - await self.session.wallet.create_local_did(seed=None, did=TestConfig.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=None, + did=TestConfig.test_did, + ) self.session.context.update_settings({"public_invites": True}) mock_conn_rec_state_request = ConnRecord.State.REQUEST @@ -818,7 +868,12 @@ async def test_receive_request_public_did_x_did_doc_attach_bad_sig(self): _thread=async_mock.MagicMock(pthid="did:sov:publicdid0000000000000"), ) - await self.session.wallet.create_local_did(seed=None, did=TestConfig.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=None, + did=TestConfig.test_did, + ) self.session.context.update_settings({"public_invites": True}) mock_conn_rec_state_request = ConnRecord.State.REQUEST @@ -871,7 +926,12 @@ async def test_receive_request_public_did_no_public_invites(self): _thread=async_mock.MagicMock(pthid="did:sov:publicdid0000000000000"), ) - await self.session.wallet.create_local_did(seed=None, did=TestConfig.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=None, + did=TestConfig.test_did, + ) self.session.context.update_settings({"public_invites": False}) with async_mock.patch.object( @@ -914,7 +974,12 @@ async def test_receive_request_public_did_no_auto_accept(self): _thread=async_mock.MagicMock(pthid="did:sov:publicdid0000000000000"), ) - await self.session.wallet.create_local_did(seed=None, did=TestConfig.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=None, + did=TestConfig.test_did, + ) self.session.context.update_settings( {"public_invites": True, "debug.auto_accept_requests": False} @@ -997,7 +1062,12 @@ async def test_receive_request_peer_did(self): ) mock_conn_rec_state_request = ConnRecord.State.REQUEST - await self.session.wallet.create_local_did(seed=None, did=TestConfig.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=None, + did=TestConfig.test_did, + ) self.session.context.update_settings({"public_invites": True}) with async_mock.patch.object( @@ -1049,8 +1119,14 @@ async def test_receive_request_peer_did(self): assert not self.responder.messages async def test_receive_request_multiuse_multitenant(self): - multiuse_info = await self.session.wallet.create_local_did() - new_info = await self.session.wallet.create_local_did() + multiuse_info = await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) + new_info = await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) mock_request = async_mock.MagicMock( did=TestConfig.test_did, @@ -1115,7 +1191,10 @@ async def test_receive_request_multiuse_multitenant(self): ) async def test_receive_request_implicit_multitenant(self): - new_info = await self.session.wallet.create_local_did() + new_info = await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + ) mock_request = async_mock.MagicMock( did=TestConfig.test_did, @@ -1215,7 +1294,12 @@ async def test_receive_request_peer_did_not_found_x(self): _thread=async_mock.MagicMock(pthid="dummy-pthid"), ) - await self.session.wallet.create_local_did(seed=None, did=TestConfig.test_did) + await self.session.wallet.create_local_did( + method=DIDMethod.SOV, + key_type=KeyType.ED25519, + seed=None, + did=TestConfig.test_did, + ) with async_mock.patch.object( test_module, "ConnRecord", async_mock.MagicMock() diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index b9dcf48d92..b0038e3280 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -1,5 +1,6 @@ """V2.0 issue-credential linked data proof credential format handler.""" +from aries_cloudagent.wallet.crypto import KeyType import logging from typing import Mapping @@ -15,8 +16,7 @@ from ......vc.ld_proofs import ( Ed25519Signature2018, BbsBlsSignature2020, - Ed25519WalletKeyPair, - Bls12381G2WalletKeyPair, + WalletKeyPair, LinkedDataProof, CredentialIssuancePurpose, ProofPurpose, @@ -207,12 +207,14 @@ async def _get_suite( # for shorter sessions wallet = session.inject(BaseWallet) + # TODO: we can abstract this if proof_type == Ed25519Signature2018.signature_type: return Ed25519Signature2018( verification_method=verification_method, proof=proof, - key_pair=Ed25519WalletKeyPair( + key_pair=WalletKeyPair( wallet=wallet, + key_type=KeyType.ED25519, public_key_base58=did_info.verkey if did_info else None, ), ) @@ -220,8 +222,9 @@ async def _get_suite( return BbsBlsSignature2020( verification_method=verification_method, proof=proof, - key_pair=Bls12381G2WalletKeyPair( + key_pair=WalletKeyPair( wallet=wallet, + key_type=KeyType.BLS12381G2, public_key_base58=did_info.verkey if did_info else None, ), ) 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 aa8a3c6c06..e03c1f83dc 100644 --- a/aries_cloudagent/protocols/out_of_band/v1_0/manager.py +++ b/aries_cloudagent/protocols/out_of_band/v1_0/manager.py @@ -6,10 +6,6 @@ from typing import Mapping, Sequence, Optional -from aries_cloudagent.protocols.coordinate_mediation.v1_0.manager import ( - MediationManager, -) - from ....connections.base_manager import BaseConnectionManager from ....connections.models.conn_record import ConnRecord from ....connections.util import mediation_record_if_id @@ -28,6 +24,7 @@ from ....wallet.crypto import KeyType from ....did.did_key import DIDKey +from ...coordinate_mediation.v1_0.manager import MediationManager from ...connections.v1_0.manager import ConnectionManager from ...connections.v1_0.messages.connection_invitation import ConnectionInvitation from ...didcomm_prefix import DIDCommPrefix @@ -271,7 +268,7 @@ async def create_invitation( my_endpoint = self._session.settings.get("default_endpoint") # Create and store new invitation key - connection_key = await wallet.create_signing_key() + connection_key = await wallet.create_signing_key(KeyType.ED25519) keylist_updates = await mediation_mgr.add_key( connection_key.verkey, keylist_updates ) diff --git a/aries_cloudagent/transport/tests/test_pack_format.py b/aries_cloudagent/transport/tests/test_pack_format.py index 1f92f073bb..a809db54b4 100644 --- a/aries_cloudagent/transport/tests/test_pack_format.py +++ b/aries_cloudagent/transport/tests/test_pack_format.py @@ -8,6 +8,7 @@ from ...protocols.didcomm_prefix import DIDCommPrefix from ...wallet.base import BaseWallet from ...wallet.error import WalletError +from ...wallet.crypto import KeyType, DIDMethod from ..error import WireFormatEncodeError, WireFormatParseError, RecipientKeysError from ..pack_format import PackWireFormat @@ -136,7 +137,9 @@ async def test_fallback(self): assert message_dict == message async def test_encode_decode(self): - local_did = await self.wallet.create_local_did(self.test_seed) + local_did = await self.wallet.create_local_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519, seed=self.test_seed + ) serializer = PackWireFormat() recipient_keys = (local_did.verkey,) routing_keys = () @@ -168,8 +171,12 @@ async def test_encode_decode(self): ) async def test_forward(self): - local_did = await self.wallet.create_local_did(self.test_seed) - router_did = await self.wallet.create_local_did(self.test_routing_seed) + local_did = await self.wallet.create_local_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519, seed=self.test_seed + ) + router_did = await self.wallet.create_local_did( + method=DIDMethod.SOV, key_type=KeyType.ED25519, seed=self.test_routing_seed + ) serializer = PackWireFormat() recipient_keys = (local_did.verkey,) routing_keys = (router_did.verkey,) diff --git a/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py b/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py index d87d2cde9c..7f9873970d 100644 --- a/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py +++ b/aries_cloudagent/vc/ld_proofs/tests/test_ld_proofs.py @@ -14,7 +14,6 @@ AssertionProofPurpose, verify, BbsBlsSignature2020, - Bls12381G2WalletKeyPair, ) from ...tests.document_loader import custom_document_loader from .test_doc import ( @@ -28,7 +27,6 @@ class TestLDProofs(TestCase): test_seed = "testseed000000000000000000000001" - key_info: KeyInfo = None async def setUp(self): self.profile = InMemoryProfile.test_profile() diff --git a/aries_cloudagent/vc/vc_ld/tests/test_credential.py b/aries_cloudagent/vc/vc_ld/tests/test_credential.py index 4aad268aad..d2b6c17a1c 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_credential.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_credential.py @@ -43,6 +43,48 @@ }, } +CREDENTIAL_TEMPLATE_BBS = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/security/bbs/v1", + "https://www.w3.org/2018/credentials/examples/v1", + ], + "id": "http://example.gov/credentials/3732", + "type": ["VerifiableCredential", "UniversityDegreeCredential"], + "issuer": { + "id": "did:key:zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa" + }, + "issuanceDate": "2020-03-10T04:24:12.164Z", + "credentialSubject": { + "id": "did:example:456", + "degree": {"type": "BachelorDegree", "name": "Bachelor of Science and Arts"}, + }, +} + +CREDENTIAL_ISSUED_BBS = { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/security/bbs/v1", + "https://www.w3.org/2018/credentials/examples/v1", + ], + "id": "http://example.gov/credentials/3732", + "type": ["VerifiableCredential", "UniversityDegreeCredential"], + "issuer": { + "id": "did:key:zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa" + }, + "issuanceDate": "2020-03-10T04:24:12.164Z", + "credentialSubject": { + "id": "did:example:456", + "degree": {"type": "BachelorDegree", "name": "Bachelor of Science and Arts"}, + }, + "proof": { + "type": "BbsBlsSignature2020", + "verificationMethod": "did:key:zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa#zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa", + "created": "2019-12-11T03:50:55", + "proofPurpose": "assertionMethod", + "proofValue": "saCdwWlzoYB0Ayo7xthCGK462T4x95lkI7yINra+r/DRY8PH4udviBebYIMA0pHkFX/nW+ilcdipr8jdN+WbHElg2wIrpWEqdvbT/vrjTWM2iXS7MmsMvpQbLfJVohDCBrm4b6BuE6QYO4Va6tYsKw==", + }, +} CREDENTIAL_VERIFIED = DocumentVerificationResult( verified=True, diff --git a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py index ffee319e37..39b0199213 100644 --- a/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py +++ b/aries_cloudagent/vc/vc_ld/tests/test_vc_ld.py @@ -1,15 +1,11 @@ from asynctest import TestCase from datetime import datetime -from ....wallet.base import KeyInfo from ....wallet.crypto import KeyType from ....did.did_key import DIDKey from ....wallet.in_memory import InMemoryWallet from ....core.in_memory import InMemoryProfile -from ...ld_proofs import ( - Ed25519Signature2018, - Ed25519WalletKeyPair, -) +from ...ld_proofs import Ed25519Signature2018, WalletKeyPair, BbsBlsSignature2020 from ...vc_ld import ( issue, verify_credential, @@ -24,34 +20,45 @@ CREDENTIAL_VERIFIED, PRESENTATION_SIGNED, PRESENTATION_UNSIGNED, + CREDENTIAL_TEMPLATE_BBS, + CREDENTIAL_ISSUED_BBS, ) class TestLinkedDataVerifiableCredential(TestCase): test_seed = "testseed000000000000000000000001" - test_seed2 = "testseed000000000000000000000002" - issuer_key_info: KeyInfo = None async def setUp(self): self.profile = InMemoryProfile.test_profile() self.wallet = InMemoryWallet(self.profile) - self.issuer_key_info = await self.wallet.create_signing_key(self.test_seed) - self.issuer_verification_method = DIDKey.from_public_key_b58( - self.issuer_key_info.verkey, KeyType.ED25519 + self.ed25519_key_info = await self.wallet.create_signing_key( + key_type=KeyType.ED25519, seed=self.test_seed + ) + self.ed25519_verification_method = DIDKey.from_public_key_b58( + self.ed25519_key_info.verkey, KeyType.ED25519 + ).key_id + + self.bls12381g2_key_info = await self.wallet.create_signing_key( + key_type=KeyType.BLS12381G2, seed=self.test_seed + ) + + self.bls12381g2_verification_method = DIDKey.from_public_key_b58( + self.bls12381g2_key_info.verkey, KeyType.BLS12381G2 ).key_id - self.holder_key_info = await self.wallet.create_signing_key(self.test_seed2) self.presentation_challenge = "2b1bbff6-e608-4368-bf84-67471b27e41c" - async def test_issue(self): + async def test_issue_Ed25519Signature2018(self): # Use different key pair and suite for signing and verification # as during verification a lot of information can be extracted # from the proof / document suite = Ed25519Signature2018( - verification_method=self.issuer_verification_method, - key_pair=Ed25519WalletKeyPair( - wallet=self.wallet, public_key_base58=self.issuer_key_info.verkey + verification_method=self.ed25519_verification_method, + key_pair=WalletKeyPair( + wallet=self.wallet, + key_type=KeyType.ED25519, + public_key_base58=self.ed25519_key_info.verkey, ), date=datetime.strptime("2019-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), ) @@ -64,10 +71,10 @@ async def test_issue(self): assert issued == CREDENTIAL_ISSUED - async def test_verify(self): + async def test_verify_Ed25519Signature2018(self): # Verification requires lot less input parameters suite = Ed25519Signature2018( - key_pair=Ed25519WalletKeyPair(wallet=self.wallet), + key_pair=WalletKeyPair(wallet=self.wallet, key_type=KeyType.ED25519), ) verified = await verify_credential( credential=CREDENTIAL_ISSUED, @@ -77,17 +84,61 @@ async def test_verify(self): assert verified == CREDENTIAL_VERIFIED + async def test_issue_BbsBlsSignature2020(self): + # Use different key pair and suite for signing and verification + # as during verification a lot of information can be extracted + # from the proof / document + suite = BbsBlsSignature2020( + verification_method=self.bls12381g2_verification_method, + key_pair=WalletKeyPair( + wallet=self.wallet, + key_type=KeyType.BLS12381G2, + public_key_base58=self.bls12381g2_key_info.verkey, + ), + date=datetime.strptime("2019-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), + ) + + issued = await issue( + credential=CREDENTIAL_TEMPLATE_BBS, + suite=suite, + document_loader=custom_document_loader, + ) + + assert issued + + result = await verify_credential( + credential=issued, suites=[suite], document_loader=custom_document_loader + ) + + assert result.verified + + async def test_verify_BbsBlsSignature2020(self): + # Verification requires lot less input parameters + suite = BbsBlsSignature2020( + key_pair=WalletKeyPair(wallet=self.wallet, key_type=KeyType.BLS12381G2), + ) + result = await verify_credential( + credential=CREDENTIAL_ISSUED_BBS, + suites=[suite], + document_loader=custom_document_loader, + ) + + assert result.verified + async def test_create_presentation(self): # TODO: create presentation from subject id controller # TODO: create presentation with multiple credentials + # TODO: create presentation with bbs credential and ed presentation unsigned_presentation = await create_presentation( credentials=[CREDENTIAL_ISSUED] ) suite = Ed25519Signature2018( - verification_method=self.issuer_verification_method, - key_pair=Ed25519WalletKeyPair( - wallet=self.wallet, public_key_base58=self.issuer_key_info.verkey + verification_method=self.ed25519_verification_method, + key_pair=WalletKeyPair( + wallet=self.wallet, + key_type=KeyType.ED25519, + public_key_base58=self.ed25519_key_info.verkey, ), date=datetime.strptime("2020-12-11T03:50:55Z", "%Y-%m-%dT%H:%M:%SZ"), ) @@ -106,7 +157,7 @@ async def test_create_presentation(self): async def test_verify_presentation(self): # TODO: verify with multiple suites suite = Ed25519Signature2018( - key_pair=Ed25519WalletKeyPair(wallet=self.wallet), + key_pair=WalletKeyPair(wallet=self.wallet, key_type=KeyType.ED25519), ) verification_result = await verify_presentation( presentation=PRESENTATION_SIGNED, @@ -116,4 +167,4 @@ async def test_verify_presentation(self): ) # TODO match against stored verification result for continuity - assert verification_result.verified == True + assert verification_result.verified diff --git a/aries_cloudagent/wallet/base.py b/aries_cloudagent/wallet/base.py index 7220ac115a..aa6eac7c0a 100644 --- a/aries_cloudagent/wallet/base.py +++ b/aries_cloudagent/wallet/base.py @@ -160,7 +160,9 @@ async def create_public_did( info_meta = info.metadata info_meta["public"] = False await self.replace_local_did_metadata(info.did, info_meta) - return await self.create_local_did(seed, did, metadata) + return await self.create_local_did( + method=method, key_type=key_type, seed=seed, did=did, metadata=metadata + ) async def get_public_did(self) -> DIDInfo: """ @@ -187,7 +189,7 @@ async def set_public_did(self, did: str) -> DIDInfo: """ - did_info = self.get_local_did(did) + did_info = await self.get_local_did(did) if did_info.method != DIDMethod.SOV: raise WalletError("Setting public did is only allowed for did:sov dids") diff --git a/aries_cloudagent/wallet/crypto.py b/aries_cloudagent/wallet/crypto.py index d91d293fce..d768d801b3 100644 --- a/aries_cloudagent/wallet/crypto.py +++ b/aries_cloudagent/wallet/crypto.py @@ -1,5 +1,6 @@ """Cryptography functions used by BasicWallet.""" +import uuid from enum import Enum import json @@ -10,6 +11,8 @@ import nacl.exceptions import nacl.utils +from ..storage.base import BaseStorage +from ..storage.record import StorageRecord from marshmallow import fields, Schema, ValidationError from ursa_bbs_signatures.models.SignRequest import SignRequest from ursa_bbs_signatures.models.VerifyRequest import VerifyRequest @@ -186,6 +189,81 @@ class PackRecipientsSchema(Schema): recipients = fields.List(fields.Nested(PackRecipientSchema()), required=True) +async def store_key_pair( + *, + storage: BaseStorage, + public_key: bytes, + secret_key: bytes, + key_type: KeyType, + metadata: dict, + tags={}, +): + """Store signing key pair in storage. + + Args: + storage (BaseStorage): [description] + public_key (bytes): [description] + secret_key (bytes): [description] + key_type (KeyType): [description] + metadata (dict): [description] + """ + verkey = bytes_to_b58(public_key) + data = { + "verkey": verkey, + "secret_key": bytes_to_b58(secret_key), + "key_type": key_type.key_type, + "metadata": metadata, + } + record = StorageRecord( + "key_pair", + json.dumps(data), + {**tags, "verkey": verkey, "key_type": key_type.key_type}, + uuid.uuid4().hex, + ) + + await storage.add_record(record) + + +async def get_key_pair(*, storage: BaseStorage, verkey: str) -> dict: + """Retrieve signing key pair from storage by verkey. + + Args: + storage (BaseStorage): The storage to use for querying + verkey (str): The verkey to query for + + Raises: + StorageDuplicateError: If more than one key pair is found for this verkey + StorageNotFoundError: If no key pair is found for this verkey + + Returns + dict: The key pair data + + """ + + record = await storage.find_record("key_pair", {"verkey": verkey}) + data = json.loads(record.value) + + return data + + +async def get_key_pairs( + *, storage: BaseStorage, tag_query: Optional[Mapping] = None +) -> List[dict]: + records = await storage.find_all_records("key_pair", tag_query) + + return [json.loads(record) for record in records] + + +async def update_key_pair_metadata( + *, storage: BaseStorage, verkey: str, metadata: dict +): + record = await storage.find_record("key_pair", {"verkey": verkey}) + data = json.loads(record.value) + data["metadata"] = metadata + + await storage.update_record(record, json.dumps(data), record.tags) + + def create_keypair(key_type: KeyType, seed: bytes = None) -> Tuple[bytes, bytes]: """ Create a public and private keypair from a seed value. diff --git a/aries_cloudagent/wallet/in_memory.py b/aries_cloudagent/wallet/in_memory.py index 5e41e50fa3..64a5e0a249 100644 --- a/aries_cloudagent/wallet/in_memory.py +++ b/aries_cloudagent/wallet/in_memory.py @@ -139,7 +139,7 @@ async def rotate_did_keypair_start(self, did: str, next_seed: str = None) -> str ) key_info = await self.create_signing_key( - local_did["key_type"], next_seed, {"did": did} + key_type=local_did["key_type"], seed=next_seed, metadata={"did": did} ) return key_info.verkey diff --git a/aries_cloudagent/wallet/indy.py b/aries_cloudagent/wallet/indy.py index 73d4a94bda..118db06ea2 100644 --- a/aries_cloudagent/wallet/indy.py +++ b/aries_cloudagent/wallet/indy.py @@ -2,7 +2,7 @@ import json -from typing import Sequence +from typing import List, Sequence, Tuple, Union import indy.anoncreds import indy.did @@ -19,9 +19,22 @@ from ..did.did_key import DIDKey from .base import BaseWallet, KeyInfo, DIDInfo -from .crypto import DIDMethod, KeyType, validate_seed +from .crypto import ( + DIDMethod, + KeyType, + create_keypair, + get_key_pair, + get_key_pairs, + sign_message, + store_key_pair, + update_key_pair_metadata, + validate_seed, + verify_signed_message, +) +from ..storage.indy import IndySdkStorage +from ..storage.error import StorageDuplicateError, StorageNotFoundError from .error import WalletError, WalletDuplicateError, WalletNotFoundError -from .util import bytes_to_b58, bytes_to_b64 +from .util import b58_to_bytes, bytes_to_b58, bytes_to_b64 class IndySdkWallet(BaseWallet): @@ -31,39 +44,42 @@ def __init__(self, opened: IndyOpenWallet): """Create a new IndySdkWallet instance.""" self.opened = opened - def __did_info_from_info(self, info): + def __did_info_from_indy_info(self, info): metadata = json.loads(info["metadata"]) if info["metadata"] else {} - did = info["did"] + did: str = info["did"] verkey = info["verkey"] - method = DIDMethod.from_metadata(metadata) + method = DIDMethod.KEY if did.startswith("did:key") else DIDMethod.SOV key_type = KeyType.ED25519 if method == DIDMethod.KEY: - did = DIDKey.from_public_key_b58(info["verkey"], KeyType.ED25519).did + did = DIDKey.from_public_key_b58(info["verkey"], key_type).did return DIDInfo( did=did, verkey=verkey, metadata=metadata, method=method, key_type=key_type ) - async def create_signing_key( - self, seed: str = None, metadata: dict = None - ) -> KeyInfo: - """ - Create a new public/private signing keypair. + def __did_info_from_key_pair_info(self, info: dict): + metadata = json.loads(info["metadata"]) if info["metadata"] else {} + verkey = info["verkey"] - Args: - seed: Seed for key - metadata: Optional metadata to store with the keypair + # this needs to change if other did methods are added + method = DIDMethod.from_method(info["metadata"].get("method", "key")) + key_type = KeyType.from_key_type(info["key_type"]) - Returns: - A `KeyInfo` representing the new record + if method == DIDMethod.KEY: + did = DIDKey.from_public_key_b58(info["verkey"], key_type).did - Raises: - WalletDuplicateError: If the resulting verkey already exists in the wallet - WalletError: If there is a libindy error + return DIDInfo( + did=did, verkey=verkey, metadata=metadata, method=method, key_type=key_type + ) + + async def __create_indy_signing_key( + self, key_type: KeyType, metadata: dict, seed: str = None + ) -> str: + if key_type != KeyType.ED25519: + raise WalletError(f"Unsupported key type: {key_type.key_type}") - """ args = {} if seed: args["seed"] = bytes_to_b64(validate_seed(seed)) @@ -76,32 +92,84 @@ async def create_signing_key( x_indy, "Wallet {} error".format(self.opened.name), WalletError ) from x_indy - # must save metadata to allow identity check - # otherwise get_key_metadata just returns WalletItemNotFound - if metadata is None: - metadata = {} await indy.crypto.set_key_metadata( self.opened.handle, verkey, json.dumps(metadata) ) - return KeyInfo(verkey, metadata) - async def get_signing_key(self, verkey: str) -> KeyInfo: + return verkey + + async def __create_keypair_signing_key( + self, key_type: KeyType, metadata: dict, seed: str = None + ) -> str: + if key_type != KeyType.BLS12381G2: + raise WalletError(f"Unsupported key type: {key_type.key_type}") + + public_key, secret_key = create_keypair(key_type, validate_seed(seed)) + verkey = bytes_to_b58(public_key) + storage = IndySdkStorage(self.opened) + + # Check if key already exists + try: + key_info = await self.__get_keypair_signing_key(verkey) + if key_info: + raise WalletDuplicateError("Verification key already present in wallet") + except WalletNotFoundError: + # If we can't find the key, it means it doesn't exist already + # this is good + pass + + await store_key_pair( + storage=storage, + public_key=public_key, + secret_key=secret_key, + key_type=key_type, + metadata=metadata, + ) + + return verkey + + async def create_signing_key( + self, key_type: KeyType, seed: str = None, metadata: dict = None + ) -> KeyInfo: """ - Fetch info for a signing keypair. + Create a new public/private signing keypair. Args: - verkey: The verification key of the keypair + seed: Seed for key + metadata: Optional metadata to store with the keypair Returns: - A `KeyInfo` representing the keypair + A `KeyInfo` representing the new record Raises: - WalletNotFoundError: If no keypair is associated with the verification key + WalletDuplicateError: If the resulting verkey already exists in the wallet WalletError: If there is a libindy error """ + + # must save metadata to allow identity check + # otherwise get_key_metadata just returns WalletItemNotFound + if metadata is None: + metadata = {} + + # All ed25519 keys are handled by indy + if key_type == KeyType.ED25519: + verkey = await self.__create_indy_signing_key(key_type, metadata, seed) + # All other (only bls12381g2 atm) are handled outside of indy + else: + verkey = await self.__create_keypair_signing_key(key_type, metadata, seed) + + return KeyInfo(verkey=verkey, metadata=metadata, key_type=key_type) + + async def __get_indy_signing_key(self, verkey: str) -> KeyInfo: try: metadata = await indy.crypto.get_key_metadata(self.opened.handle, verkey) + + return KeyInfo( + verkey=verkey, + metadata=json.loads(metadata) if metadata else {}, + key_type=KeyType.ED25519, + ) except IndyError as x_indy: if x_indy.error_code == ErrorCode.WalletItemNotFound: raise WalletNotFoundError("Unknown key: {}".format(verkey)) @@ -109,7 +177,40 @@ async def get_signing_key(self, verkey: str) -> KeyInfo: raise IndyErrorHandler.wrap_error( x_indy, "Wallet {} error".format(self.opened.name), WalletError ) from x_indy - return KeyInfo(verkey, json.loads(metadata) if metadata else {}) + + async def __get_keypair_signing_key(self, verkey: str) -> KeyInfo: + try: + storage = IndySdkStorage(self.opened) + key_pair = await get_key_pair(storage=storage, verkey=verkey) + return KeyInfo( + verkey=verkey, + metadata=key_pair["metadata"], + key_type=KeyType.from_key_type(key_pair["key_type"]), + ) + except (StorageNotFoundError): + raise WalletNotFoundError(f"Unknown key: {verkey}") + except (StorageDuplicateError): + raise WalletDuplicateError(f"Multiple keys exist for verkey: {verkey}") + + async def get_signing_key(self, verkey: str) -> KeyInfo: + """ + Fetch info for a signing keypair. + + Args: + verkey: The verification key of the keypair + + Returns: + A `KeyInfo` representing the keypair + + Raises: + WalletNotFoundError: If no keypair is associated with the verification key + WalletError: If there is a libindy error + + """ + try: + return await self.__get_indy_signing_key(verkey) + except WalletNotFoundError: + return await self.__get_keypair_signing_key(verkey) async def replace_signing_key_metadata(self, verkey: str, metadata: dict): """ @@ -123,11 +224,23 @@ async def replace_signing_key_metadata(self, verkey: str, metadata: dict): WalletNotFoundError: if no keypair is associated with the verification key """ - meta_json = json.dumps(metadata or {}) - await self.get_signing_key(verkey) # throw exception if key is undefined - await indy.crypto.set_key_metadata(self.opened.handle, verkey, meta_json) + metadata = metadata or {} + + # throw exception if key is undefined + key_info = await self.get_signing_key(verkey) + + # All ed25519 keys are handled by indy + if key_info.key_type == KeyType.ED25519: + await indy.crypto.set_key_metadata( + self.opened.handle, verkey, json.dumps(metadata) + ) + # All other (only bls12381g2 atm) are handled outside of indy + else: + storage = IndySdkStorage(self.opened) + await update_key_pair_metadata( + storage=storage, verkey=key_info.verkey, metadata=metadata + ) - # TODO: rotate not possible for did key async def rotate_did_keypair_start(self, did: str, next_seed: str = None) -> str: """ Begin key rotation for DID that wallet owns: generate new keypair. @@ -140,6 +253,13 @@ async def rotate_did_keypair_start(self, did: str, next_seed: str = None) -> str The new verification key """ + # Check if did can rotate keys + did_method = DIDMethod.from_did(did) + if not did_method.supports_rotation: + raise WalletError( + f"Did method {did_method.method_name} does not support key rotation." + ) + try: verkey = await indy.did.replace_keys_start( self.opened.handle, @@ -179,24 +299,119 @@ async def rotate_did_keypair_apply(self, did: str) -> DIDInfo: x_indy, "Wallet {} error".format(self.opened.name), WalletError ) from x_indy + async def __create_indy_local_did( + self, + method: DIDMethod, + key_type: KeyType, + metadata: dict = None, + seed: str = None, + *, + did: str = None, + ) -> DIDInfo: + if method not in [DIDMethod.SOV, DIDMethod.KEY]: + raise WalletError( + f"Unsupported did method for indy storage: {method.method_name}" + ) + if key_type != KeyType.ED25519: + raise WalletError( + f"Unsupported key type for indy storage: {key_type.key_type}" + ) + + cfg = {} + if seed: + cfg["seed"] = bytes_to_b64(validate_seed(seed)) + if did: + cfg["did"] = did + # Create fully qualified did. This helps with determining the + # did method when retrieving + if method != DIDMethod.SOV: + cfg["method_name"] = method.method_name + did_json = json.dumps(cfg) + # crypto_type, cid - optional parameters skipped + try: + did, verkey = await indy.did.create_and_store_my_did( + self.opened.handle, did_json + ) + except IndyError as x_indy: + if x_indy.error_code == ErrorCode.DidAlreadyExistsError: + raise WalletDuplicateError("DID already present in wallet") + raise IndyErrorHandler.wrap_error( + x_indy, "Wallet {} error".format(self.opened.name), WalletError + ) from x_indy + + # did key uses different format + if method == DIDMethod.KEY: + did = DIDKey.from_public_key_b58(verkey, key_type).did + + await self.replace_local_did_metadata(did, metadata or {}) + + return DIDInfo( + did=did, + verkey=verkey, + metadata=metadata or {}, + method=method, + key_type=key_type, + ) + + async def __create_keypair_local_did( + self, + method: DIDMethod, + key_type: KeyType, + metadata: dict = None, + seed: str = None, + ) -> DIDInfo: + if method != DIDMethod.KEY: + raise WalletError( + f"Unsupported did method for keypair storage: {method.method_name}" + ) + if key_type != KeyType.BLS12381G2: + raise WalletError( + f"Unsupported key type for keypair storage: {key_type.key_type}" + ) + + public_key, secret_key = create_keypair(key_type, validate_seed(seed)) + storage = IndySdkStorage(self.opened) + # should change if other did methods are supported + did_key = DIDKey.from_public_key(public_key, key_type) + + if not metadata: + metadata = {} + metadata["method"] = method.method_name + + await store_key_pair( + storage=storage, + public_key=public_key, + secret_key=secret_key, + key_type=key_type, + metadata=metadata, + tags={"method": method.method_name}, + ) + + return DIDInfo( + did=did_key.did, + verkey=did_key.public_key_b58, + metadata=metadata, + method=method, + key_type=key_type, + ) + async def create_local_did( self, + method: DIDMethod, + key_type: KeyType, seed: str = None, did: str = None, metadata: dict = None, - *, - method: DIDMethod = DIDMethod.SOV, - key_type: KeyType = KeyType.ED25519, ) -> DIDInfo: """ Create and store a new local DID. Args: + method: The method to use for the DID + key_type: The key type to use for the DID seed: Optional seed to use for DID did: The DID to use metadata: Metadata to store with DID - method: The method to use for the DID. Defaults to did:sov - key_type: The key type to use for the DID. defaults to ed25519. Returns: A `DIDInfo` instance representing the created DID @@ -214,41 +429,16 @@ async def create_local_did( f" for did method {method.method_name}" ) - cfg = {} - if seed: - cfg["seed"] = bytes_to_b64(validate_seed(seed)) - if did: - cfg["did"] = did - did_json = json.dumps(cfg) - # crypto_type, cid - optional parameters skipped - try: - did, verkey = await indy.did.create_and_store_my_did( - self.opened.handle, did_json + # All ed25519 keys are handled by indy + if key_type == KeyType.ED25519: + return await self.__create_indy_local_did( + method, key_type, metadata, seed, did=did ) - except IndyError as x_indy: - if x_indy.error_code == ErrorCode.DidAlreadyExistsError: - raise WalletDuplicateError("DID already present in wallet") - raise IndyErrorHandler.wrap_error( - x_indy, "Wallet {} error".format(self.opened.name), WalletError - ) from x_indy - - # Store that we're using did:key for this did - if method == DIDMethod.KEY: - if metadata: - metadata["method"] = method.method_name - else: - metadata = {"method": method.method_name} - if metadata: - await self.replace_local_did_metadata(did, metadata) + # All other (only bls12381g2 atm) are handled outside of indy else: - metadata = {} - - if method == DIDMethod.KEY: - # Transform the did to a did key - did = DIDKey.from_public_key_b58(verkey, key_type).did - return DIDInfo( - did=did, verkey=verkey, metadata=metadata, method=method, key_type=key_type - ) + return await self.__create_keypair_local_did( + method, key_type, metadata, seed + ) async def get_local_dids(self) -> Sequence[DIDInfo]: """ @@ -258,13 +448,73 @@ async def get_local_dids(self) -> Sequence[DIDInfo]: A list of locally stored DIDs as `DIDInfo` instances """ + # retrieve indy dids info_json = await indy.did.list_my_dids_with_meta(self.opened.handle) info = json.loads(info_json) ret = [] for did in info: - ret.append(self.__did_info_from_info(did)) + ret.append(self.__did_info_from_indy_info(did)) + + # retrieve key pairs with method set to key + # this needs to change if more did methods are added + storage = IndySdkStorage(self.opened) + key_pairs = await get_key_pairs( + storage=storage, tag_query={"method": DIDMethod.KEY.method_name} + ) + for key_pair in key_pairs: + ret.append(self.__did_info_from_key_pair_info(key_pair)) + return ret + async def __get_indy_local_did( + self, method: DIDMethod, key_type: KeyType, did: str + ) -> DIDInfo: + if method not in [DIDMethod.SOV, DIDMethod.KEY]: + raise WalletError( + f"Unsupported did method for indy storage: {method.method_name}" + ) + if key_type != KeyType.ED25519: + raise WalletError( + f"Unsupported key type for indy storage: {key_type.key_type}" + ) + + # key type is always ed25519, method not always key + if method == DIDMethod.KEY and key_type == KeyType.ED25519: + did_key = DIDKey.from_did(did) + + # Ed25519 did:keys are masked indy dids so transform to indy + # did with did:key prefix. + did = "did:key:" + bytes_to_b58(did_key.public_key[:16]) + try: + info_json = await indy.did.get_my_did_with_meta(self.opened.handle, did) + except IndyError as x_indy: + if x_indy.error_code == ErrorCode.WalletItemNotFound: + raise WalletNotFoundError("Unknown DID: {}".format(did)) + raise IndyErrorHandler.wrap_error( + x_indy, "Wallet {} error".format(self.opened.name), WalletError + ) from x_indy + info = json.loads(info_json) + return self.__did_info_from_indy_info(info) + + async def __get_keypair_local_did( + self, method: DIDMethod, key_type: KeyType, did: str + ): + if method != DIDMethod.KEY: + raise WalletError( + f"Unsupported did method for keypair storage: {method.method_name}" + ) + if key_type != KeyType.BLS12381G2: + raise WalletError( + f"Unsupported key type for keypair storage: {key_type.key_type}" + ) + + # method is always did:key + did_key = DIDKey.from_did(did) + + storage = IndySdkStorage(self.opened) + key_pair = await get_key_pair(storage=storage, verkey=did_key.public_key_b58) + return self.__did_info_from_key_pair_info(key_pair) + async def get_local_did(self, did: str) -> DIDInfo: """ Find info for a local DID. @@ -280,24 +530,18 @@ async def get_local_did(self, did: str) -> DIDInfo: WalletError: If there is a libindy error """ + method = DIDMethod.from_did(did) + key_type = KeyType.ED25519 - # Resolve - if did.startswith("did:key"): + # If did key, the key type can differ + if method == DIDMethod.KEY: did_key = DIDKey.from_did(did) - # Ed25519 did:keys are masked indy dids - if did_key.key_type == KeyType.ED25519: - did = bytes_to_b58(did_key.public_key[:16]) + key_type = did_key.key_type - try: - info_json = await indy.did.get_my_did_with_meta(self.opened.handle, did) - except IndyError as x_indy: - if x_indy.error_code == ErrorCode.WalletItemNotFound: - raise WalletNotFoundError("Unknown DID: {}".format(did)) - raise IndyErrorHandler.wrap_error( - x_indy, "Wallet {} error".format(self.opened.name), WalletError - ) from x_indy - info = json.loads(info_json) - return self.__did_info_from_info(info) + if key_type == KeyType.ED25519: + return await self.__get_indy_local_did(method, key_type, did) + else: + return await self.__get_keypair_local_did(method, key_type, did) async def get_local_did_for_verkey(self, verkey: str) -> DIDInfo: """ @@ -329,14 +573,26 @@ async def replace_local_did_metadata(self, did: str, metadata: dict): metadata: The new metadata """ - meta_json = json.dumps(metadata or {}) - await self.get_local_did(did) # throw exception if undefined - try: - await indy.did.set_did_metadata(self.opened.handle, did, meta_json) - except IndyError as x_indy: - raise IndyErrorHandler.wrap_error( - x_indy, "Wallet {} error".format(self.opened.name), WalletError - ) from x_indy + if not metadata: + metadata = {} + did_info = await self.get_local_did(did) # throw exception if undefined + + # ed25519 keys are handled by indy + if did_info.key_type == KeyType.ED25519: + try: + await indy.did.set_did_metadata( + self.opened.handle, did, json.dumps(metadata) + ) + except IndyError as x_indy: + raise IndyErrorHandler.wrap_error( + x_indy, "Wallet {} error".format(self.opened.name), WalletError + ) from x_indy + # all other keys are handled by key pair + else: + storage = IndySdkStorage(self.opened) + await update_key_pair_metadata( + storage=storage, verkey=did_info.verkey, metadata=metadata + ) async def set_did_endpoint( self, @@ -357,6 +613,11 @@ async def set_did_endpoint( 'endpoint' affects local wallet """ did_info = await self.get_local_did(did) + if did_info.method != DIDMethod.SOV: + raise WalletError( + f"Did method {did_info.method.method_name} has no support for endpoints." + ) + metadata = {**did_info.metadata} if not endpoint_type: endpoint_type = EndpointType.ENDPOINT @@ -399,16 +660,38 @@ async def sign_message(self, message: bytes, from_verkey: str) -> bytes: raise WalletError("Message not provided") if not from_verkey: raise WalletError("Verkey not provided") + try: - result = await indy.crypto.crypto_sign( - self.opened.handle, from_verkey, message + key_info = await self.get_signing_key(from_verkey) + except WalletNotFoundError: + key_info = await self.get_local_did_for_verkey(from_verkey) + + # ed25519 keys are handled by indy + if key_info.key_type == KeyType.ED25519: + try: + result = await indy.crypto.crypto_sign( + self.opened.handle, from_verkey, message + ) + except IndyError: + raise WalletError("Exception when signing message") + # other keys are handled outside of indy + else: + storage = IndySdkStorage(self.opened) + key_pair = await get_key_pair(storage=storage, verkey=key_info.verkey) + result = await sign_message( + message=message, + secret=b58_to_bytes(key_pair["secret_key"]), + key_type=key_info.key_type, ) - except IndyError: - raise WalletError("Exception when signing message") + return result async def verify_message( - self, message: bytes, signature: bytes, from_verkey: str + self, + message: Union[List[bytes], bytes], + signature: bytes, + from_verkey: str, + key_type: KeyType, ) -> bool: """ Verify a signature against the public key of the signer. @@ -434,16 +717,29 @@ async def verify_message( raise WalletError("Signature not provided") if not message: raise WalletError("Message not provided") - try: - result = await indy.crypto.crypto_verify(from_verkey, message, signature) - except IndyError as x_indy: - if x_indy.error_code == ErrorCode.CommonInvalidStructure: - result = False - else: - raise IndyErrorHandler.wrap_error( - x_indy, "Wallet {} error".format(self.opened.name), WalletError - ) from x_indy - return result + + # ed25519 keys are handled by indy + if key_type == KeyType.ED25519: + try: + result = await indy.crypto.crypto_verify( + from_verkey, message, signature + ) + except IndyError as x_indy: + if x_indy.error_code == ErrorCode.CommonInvalidStructure: + result = False + else: + raise IndyErrorHandler.wrap_error( + x_indy, "Wallet {} error".format(self.opened.name), WalletError + ) from x_indy + return result + # all other keys (only bls12381g2 atm) are handled outside of indy + else: + return verify_signed_message( + message=message, + signature=signature, + verkey=b58_to_bytes(from_verkey), + key_type=key_type, + ) async def pack_message( self, message: str, to_verkeys: Sequence[str], from_verkey: str = None @@ -477,7 +773,7 @@ async def pack_message( return result - async def unpack_message(self, enc_message: bytes) -> (str, str, str): + async def unpack_message(self, enc_message: bytes) -> Tuple[str, str, str]: """ Unpack a message. diff --git a/aries_cloudagent/wallet/models/key_pair_record.py b/aries_cloudagent/wallet/models/key_pair_record.py deleted file mode 100644 index 453b694b28..0000000000 --- a/aries_cloudagent/wallet/models/key_pair_record.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Key pair record.""" - -from typing import Any - -from marshmallow import fields, EXCLUDE - -from ...messaging.models.base_record import ( - BaseRecord, - BaseRecordSchema, -) -from ...messaging.valid import UUIDFour -from ...wallet.crypto import KeyType - - -class KeyPairRecord(BaseRecord): - """Represents a key pair record.""" - - class Meta: - """KeyPairRecord metadata.""" - - schema_class = "KeyPairRecordSchema" - - RECORD_TYPE = "key_pair_record" - RECORD_ID_NAME = "key_id" - - TAG_NAMES = {"public_key_b58", "key_type"} - - def __init__( - self, - *, - key_id: str = None, - public_key_b58: str = None, - private_key_b58: str = None, - key_type: str = None, - **kwargs, - ): - """Initialize a new KeyPairRecord.""" - super().__init__(key_id, **kwargs) - self.public_key_b58 = public_key_b58 - self.private_key_b58 = private_key_b58 - self.key_type = key_type - - @property - def key_id(self) -> str: - """Accessor for the ID associated with this record.""" - return self._id - - @property - def record_value(self) -> dict: - """Accessor for the JSON record value generated for this record.""" - return { - prop: getattr(self, prop) - for prop in ("public_key_b58", "private_key_b58", "key_type") - } - - def __eq__(self, other: Any) -> bool: - """Comparison between records.""" - return super().__eq__(other) - - -class KeyPairRecordSchema(BaseRecordSchema): - """Schema to allow serialization/deserialization of record.""" - - class Meta: - """KeyPairRecordSchema metadata.""" - - model_class = KeyPairRecord - unknown = EXCLUDE - - key_id = fields.Str( - required=True, - description="Wallet key ID", - example=UUIDFour.EXAMPLE, - ) - public_key_b58 = fields.Str( - required=True, - description="Base 58 encoded public key", - example=( - "o1cocewfMSeasDPVYEkbmeEZUan5fM7ix2oWxeZupgVQFqXRsxUFdAjDmxoosqgdn" - "QJruhMYE3q7gx65MMdgtj67UsUJgJsFYX5ruMyZ58pttzKxnJrM2aoAbhqL1rnQWFf" - ), - ) - private_key_b58 = fields.Str( - required=True, - description="Base 58 encoded private key", - example="4xPeQ2sVw8S9opkARzeL6SSgygGiq6JQjFViwXL8v2wE", - ) - key_type = fields.Str( - required=True, description="Type of key", example=KeyType.BLS12381G2.key_type - ) diff --git a/aries_cloudagent/wallet/models/tests/test_key_pair_record.py b/aries_cloudagent/wallet/models/tests/test_key_pair_record.py deleted file mode 100644 index e8e4a48ba9..0000000000 --- a/aries_cloudagent/wallet/models/tests/test_key_pair_record.py +++ /dev/null @@ -1,42 +0,0 @@ -from asynctest import TestCase as AsyncTestCase - -from ....wallet.crypto import KeyType -from ..key_pair_record import KeyPairRecord - - -class TestKeyPairRecord(AsyncTestCase): - async def test_serde(self): - rec = KeyPairRecord( - key_id="d96ff010-8d09-43f2-ae8e-f8a56ac68a88", - public_key_b58="o1cocewfMSeasDPVYEkbmeEZUan5fM7ix2oWxeZupgVQFqXRsxUFdAjDmxoosqgdnQJruhMYE3q7gx65MMdgtj67UsUJgJsFYX5ruMyZ58pttzKxnJrM2aoAbhqL1rnQWFf", - private_key_b58="4xPeQ2sVw8S9opkARzeL6SSgygGiq6JQjFViwXL8v2wE", - key_type=KeyType.BLS12381G2.key_type, - ) - ser = rec.serialize() - assert ser["key_id"] == rec.key_id - assert ser["public_key_b58"] == rec.public_key_b58 - assert ser["private_key_b58"] == rec.private_key_b58 - assert ser["key_type"] == rec.key_type - - assert rec == KeyPairRecord.deserialize(ser) - - async def test_rec_ops(self): - recs = [ - KeyPairRecord( - key_id=f"61764d00-8c16-42dc-b1ec-08c0010ad59c-{i}", - public_key_b58=f"o1cocewfMSeasDPVYEkbmeEZUan5fM7ix2oWxeZupgVQFqXRsxUFdAjDmxoosqgdnQJruhMYE3q7gx65MMdgtj67UsUJgJsFYX5ruMyZ58pttzKxnJrM2aoAbhqL1rnQWFf-{i}", - private_key_b58=f"4xPeQ2sVw8S9opkARzeL6SSgygGiq6JQjFViwXL8v2wE-{i}", - key_type=[KeyType.ED25519, KeyType.BLS12381G2][i].key_type, - ) - for i in range(2) - ] - assert recs[0] != recs[1] - assert recs[0].key_id - assert recs[0].public_key_b58 - assert recs[0].private_key_b58 - assert recs[0].key_type == KeyType.ED25519.key_type - - assert recs[1].key_id - assert recs[1].public_key_b58 - assert recs[1].private_key_b58 - assert recs[1].key_type == KeyType.BLS12381G2.key_type \ No newline at end of file diff --git a/aries_cloudagent/wallet/routes.py b/aries_cloudagent/wallet/routes.py index 495ad9c344..6848cd867c 100644 --- a/aries_cloudagent/wallet/routes.py +++ b/aries_cloudagent/wallet/routes.py @@ -10,7 +10,6 @@ from marshmallow import fields, validate, ValidationError -from ..did.did_key import DIDKey from ..admin.request_context import AdminRequestContext from ..ledger.base import BaseLedger from ..ledger.endpoint_type import EndpointType @@ -48,9 +47,17 @@ class DIDSchema(OpenAPISchema): ), **DID_POSTURE, ) + method = fields.Str( + description="Did method associated with the DID", + example=DIDMethod.SOV.method_name, + validate=validate.OneOf([method.method_name for method in DIDMethod]), + ) key_type = fields.Str( description="Key type associated with the DID", - validate=validate.OneOf([KeyType.ED25519, KeyType.BLS12381G2]), + example=KeyType.ED25519.key_type, + validate=validate.OneOf( + [KeyType.ED25519.key_type, KeyType.BLS12381G2.key_type] + ), ) @@ -130,7 +137,9 @@ class DIDCreateOptionsSchema(OpenAPISchema): key_type = fields.Str( required=True, example=KeyType.ED25519.key_type, - validate=validate.OneOf([KeyType.ED25519, KeyType.BLS12381G2]), + validate=validate.OneOf( + [KeyType.ED25519.key_type, KeyType.BLS12381G2.key_type] + ), ) @@ -153,17 +162,13 @@ class DIDCreateSchema(OpenAPISchema): def format_did_info(info: DIDInfo): """Serialize a DIDInfo object.""" - key_type = KeyType.ED25519 # default from did:sov - did_method = DIDMethod.from_metadata(info.metadata) - - if did_method == DIDMethod.KEY: - key_type = DIDKey.from_did(info.did).key_type if info: return { "did": info.did, "verkey": info.verkey, "posture": DIDPosture.get(info.metadata).moniker, - "key_type": key_type.key_type, + "key_type": info.key_type.key_type, + "method": info.method.method_name, } @@ -199,11 +204,7 @@ async def wallet_did_list(request: web.BaseRequest): public_did_info and (not filter_verkey or public_did_info.verkey == filter_verkey) and (not filter_did or public_did_info.did == filter_did) - # filter by did method - and ( - not filter_method - or DIDMethod.from_metadata(public_did_info.metadata) == filter_method - ) + and (not filter_method or public_did_info.method == filter_method) ): results.append(format_did_info(public_did_info)) elif filter_posture is DIDPosture.POSTED: @@ -212,11 +213,7 @@ async def wallet_did_list(request: web.BaseRequest): if ( (not filter_verkey or info.verkey == filter_verkey) and (not filter_did or info.did == filter_did) - # filter by did method - and ( - not filter_method - or DIDMethod.from_metadata(info.metadata) == filter_method - ) + and (not filter_method or info.method == filter_method) ): results.append(format_did_info(info)) elif filter_did: @@ -228,6 +225,7 @@ async def wallet_did_list(request: web.BaseRequest): if ( info and (not filter_verkey or info.verkey == filter_verkey) + and (not filter_method or info.method == filter_method) and ( filter_posture is None or ( @@ -235,11 +233,6 @@ async def wallet_did_list(request: web.BaseRequest): and not info.metadata.get("posted") ) ) - # filter by did method - and ( - not filter_method - or DIDMethod.from_metadata(info.metadata) == filter_method - ) ): results.append(format_did_info(info)) elif filter_verkey: @@ -249,6 +242,7 @@ async def wallet_did_list(request: web.BaseRequest): info = None if ( info + and (not filter_method or info.method == filter_method) and ( filter_posture is None or ( @@ -256,11 +250,6 @@ async def wallet_did_list(request: web.BaseRequest): and not info.metadata.get("posted") ) ) - # filter by did method - and ( - not filter_method - or DIDMethod.from_metadata(info.metadata) == filter_method - ) ): results.append(format_did_info(info)) else: @@ -272,11 +261,7 @@ async def wallet_did_list(request: web.BaseRequest): filter_posture is None or DIDPosture.get(info.metadata) is DIDPosture.WALLET_ONLY ) - # filter by did method - and ( - not filter_method - or DIDMethod.from_metadata(info.metadata) == filter_method - ) + and (not filter_method or info.method == filter_method) ] results.sort( @@ -306,6 +291,7 @@ async def wallet_create_did(request: web.BaseRequest): except Exception: body = {} + # set default method and key type for backwards compat key_type = ( KeyType.from_key_type(body.get("options", {}).get("key_type")) or KeyType.ED25519 diff --git a/aries_cloudagent/wallet/tests/test_in_memory_wallet.py b/aries_cloudagent/wallet/tests/test_in_memory_wallet.py index 03a3b73437..8565eba351 100644 --- a/aries_cloudagent/wallet/tests/test_in_memory_wallet.py +++ b/aries_cloudagent/wallet/tests/test_in_memory_wallet.py @@ -4,6 +4,7 @@ from ...core.in_memory import InMemoryProfile from ...messaging.decorators.signature_decorator import SignatureDecorator from ...wallet.in_memory import InMemoryWallet +from ...wallet.crypto import DIDMethod, KeyType from ...wallet.error import ( WalletError, WalletDuplicateError, @@ -20,8 +21,11 @@ async def wallet(): class TestInMemoryWallet: test_seed = "testseed000000000000000000000001" - test_did = "55GkHamhTU1ZbTbV2ab9DE" - test_verkey = "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx" + test_sov_did = "55GkHamhTU1ZbTbV2ab9DE" + test_key_ed25519_did = "did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL" + test_key_bls12381g2_did = "did:key:zUC72Q7XD4PE4CrMiDVXuvZng3sBvMmaGgNeTUJuzavH2BS7ThbHL9FhsZM9QYY5fqAQ4MB8M9oudz3tfuaX36Ajr97QRW7LBt6WWmrtESe6Bs5NYzFtLWEmeVtvRYVAgjFcJSa" + test_ed25519_verkey = "3Dn1SJNPaCXcvvJvSbsFWP2xaCjMom3can8CQNhWrTRx" + test_bls12381g2_verkey = "nZZe9Nizhaz9JGpgjysaNkWGg5TNEhpib5j6WjTUHJ5K46dedUrZ57PUFZBq9Xckv8mFJjx6G6Vvj2rPspq22BagdADEEEy2F8AVLE1DhuwWC5vHFa4fUhUwxMkH7B6joqG" test_target_seed = "testseed000000000000000000000002" test_target_did = "GbuDUYXaUZRfHD2jeDuQuP" test_target_verkey = "9WCgWKUaAJj3VWxxtzvvMQN3AoFxoBtBDo9ntwJnVVCC" @@ -38,32 +42,64 @@ class TestInMemoryWallet: ) @pytest.mark.asyncio - async def test_create_signing_key_random(self, wallet): + async def test_create_signing_key_ed25519_random(self, wallet: InMemoryWallet): assert str(wallet) - info = await wallet.create_signing_key() + info = await wallet.create_signing_key(KeyType.ED25519) assert info and info.verkey @pytest.mark.asyncio - async def test_create_signing_key_seeded(self, wallet): - info = await wallet.create_signing_key(self.test_seed) - assert info.verkey == self.test_verkey + async def test_create_signing_key_bls12381g2_random(self, wallet: InMemoryWallet): + assert str(wallet) + info = await wallet.create_signing_key(KeyType.BLS12381G2) + assert info and info.verkey + + @pytest.mark.asyncio + async def test_create_signing_key_ed25519_seeded(self, wallet: InMemoryWallet): + info = await wallet.create_signing_key(KeyType.ED25519, self.test_seed) + assert info.verkey == self.test_ed25519_verkey + + with pytest.raises(WalletDuplicateError): + await wallet.create_signing_key(KeyType.ED25519, self.test_seed) + + with pytest.raises(WalletError): + await wallet.create_signing_key(KeyType.ED25519, "invalid-seed", None) + + @pytest.mark.asyncio + async def test_create_signing_key_bls12381g2_seeded(self, wallet: InMemoryWallet): + info = await wallet.create_signing_key(KeyType.BLS12381G2, self.test_seed) + assert info.verkey == self.test_bls12381g2_verkey with pytest.raises(WalletDuplicateError): - await wallet.create_signing_key(self.test_seed) + await wallet.create_signing_key(KeyType.BLS12381G2, self.test_seed) with pytest.raises(WalletError): - await wallet.create_signing_key("invalid-seed", None) + await wallet.create_signing_key(KeyType.BLS12381G2, "invalid-seed", None) @pytest.mark.asyncio - async def test_signing_key_metadata(self, wallet): - info = await wallet.create_signing_key(self.test_seed, self.test_metadata) + async def test_create_signing_key_unsupported_key_type( + self, wallet: InMemoryWallet + ): + with pytest.raises(WalletError): + await wallet.create_signing_key(KeyType.X25519) + + with pytest.raises(WalletError): + await wallet.create_signing_key(KeyType.BLS12381G1) + + with pytest.raises(WalletError): + await wallet.create_signing_key(KeyType.BLS12381G1G2) + + @pytest.mark.asyncio + async def test_signing_key_metadata(self, wallet: InMemoryWallet): + info = await wallet.create_signing_key( + KeyType.ED25519, self.test_seed, self.test_metadata + ) assert info.metadata == self.test_metadata - info2 = await wallet.get_signing_key(self.test_verkey) + info2 = await wallet.get_signing_key(self.test_ed25519_verkey) assert info2.metadata == self.test_metadata await wallet.replace_signing_key_metadata( - self.test_verkey, self.test_update_metadata + self.test_ed25519_verkey, self.test_update_metadata ) - info3 = await wallet.get_signing_key(self.test_verkey) + info3 = await wallet.get_signing_key(self.test_ed25519_verkey) assert info3.metadata == self.test_update_metadata with pytest.raises(WalletNotFoundError): @@ -75,64 +111,129 @@ async def test_signing_key_metadata(self, wallet): await wallet.get_signing_key(self.missing_verkey) @pytest.mark.asyncio - async def test_create_local_random(self, wallet): - info = await wallet.create_local_did(None, None) + async def test_create_local_sov_random(self, wallet: InMemoryWallet): + info = await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519, None, None) assert info and info.did and info.verkey @pytest.mark.asyncio - async def test_create_local_seeded(self, wallet): - info = await wallet.create_local_did(self.test_seed, None) - assert info.did == self.test_did - assert info.verkey == self.test_verkey + async def test_create_local_key_random(self, wallet: InMemoryWallet): + info = await wallet.create_local_did(DIDMethod.KEY, KeyType.ED25519, None, None) + assert info and info.did and info.verkey + + info = await wallet.create_local_did( + DIDMethod.KEY, KeyType.BLS12381G2, None, None + ) + assert info and info.did and info.verkey + + @pytest.mark.asyncio + async def test_create_local_incorrect_key_type_for_did_method( + self, wallet: InMemoryWallet + ): + with pytest.raises(WalletError): + await wallet.create_local_did(DIDMethod.SOV, KeyType.BLS12381G2, None, None) + + @pytest.mark.asyncio + async def test_create_local_sov_seeded(self, wallet: InMemoryWallet): + info = await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, self.test_seed, None + ) + assert info.did == self.test_sov_did + assert info.verkey == self.test_ed25519_verkey # should not raise WalletDuplicateError - same verkey - await wallet.create_local_did(self.test_seed, None) + await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, self.test_seed, None + ) with pytest.raises(WalletError): - _ = await wallet.create_local_did("invalid-seed", None) + _ = await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, "invalid-seed", None + ) @pytest.mark.asyncio - async def test_rotate_did_keypair(self, wallet): + async def test_create_local_key_seeded(self, wallet: InMemoryWallet): + info = await wallet.create_local_did( + DIDMethod.KEY, KeyType.ED25519, self.test_seed, None + ) + assert info.did == self.test_key_ed25519_did + assert info.verkey == self.test_ed25519_verkey + + info = await wallet.create_local_did( + DIDMethod.KEY, KeyType.BLS12381G2, self.test_seed, None + ) + assert info.did == self.test_key_bls12381g2_did + assert info.verkey == self.test_bls12381g2_verkey + + # should not raise WalletDuplicateError - same verkey + await wallet.create_local_did( + DIDMethod.KEY, KeyType.ED25519, self.test_seed, None + ) + + # should not raise WalletDuplicateError - same verkey + await wallet.create_local_did( + DIDMethod.KEY, KeyType.BLS12381G2, self.test_seed, None + ) + + with pytest.raises(WalletError): + _ = await wallet.create_local_did( + DIDMethod.KEY, KeyType.ED25519, "invalid-seed", None + ) + + with pytest.raises(WalletError): + _ = await wallet.create_local_did( + DIDMethod.KEY, KeyType.BLS12381G2, "invalid-seed", None + ) + + @pytest.mark.asyncio + async def test_rotate_did_keypair(self, wallet: InMemoryWallet): with pytest.raises(WalletNotFoundError): - await wallet.rotate_did_keypair_start(self.test_did) + await wallet.rotate_did_keypair_start(self.test_sov_did) with pytest.raises(WalletNotFoundError): - await wallet.rotate_did_keypair_apply(self.test_did) + await wallet.rotate_did_keypair_apply(self.test_sov_did) - info = await wallet.create_local_did(self.test_seed, self.test_did) + info = await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, self.test_seed, self.test_sov_did + ) with pytest.raises(WalletError): - await wallet.rotate_did_keypair_apply(self.test_did) + await wallet.rotate_did_keypair_apply(self.test_sov_did) - new_verkey = await wallet.rotate_did_keypair_start(self.test_did) + new_verkey = await wallet.rotate_did_keypair_start(self.test_sov_did) assert info.verkey != new_verkey - await wallet.rotate_did_keypair_apply(self.test_did) + await wallet.rotate_did_keypair_apply(self.test_sov_did) - new_info = await wallet.get_local_did(self.test_did) - assert new_info.did == self.test_did + new_info = await wallet.get_local_did(self.test_sov_did) + assert new_info.did == self.test_sov_did assert new_info.verkey != info.verkey @pytest.mark.asyncio - async def test_create_local_with_did(self, wallet): - info = await wallet.create_local_did(None, self.test_did) - assert info.did == self.test_did + async def test_create_local_with_did(self, wallet: InMemoryWallet): + info = await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, None, self.test_sov_did + ) + assert info.did == self.test_sov_did with pytest.raises(WalletDuplicateError): - await wallet.create_local_did(None, self.test_did) + await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, None, self.test_sov_did + ) @pytest.mark.asyncio - async def test_local_verkey(self, wallet): - info = await wallet.create_local_did(self.test_seed, self.test_did) - assert info.did == self.test_did - assert info.verkey == self.test_verkey + async def test_local_verkey(self, wallet: InMemoryWallet): + info = await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, self.test_seed, self.test_sov_did + ) + assert info.did == self.test_sov_did + assert info.verkey == self.test_ed25519_verkey - info2 = await wallet.get_local_did(self.test_did) - assert info2.did == self.test_did - assert info2.verkey == self.test_verkey + info2 = await wallet.get_local_did(self.test_sov_did) + assert info2.did == self.test_sov_did + assert info2.verkey == self.test_ed25519_verkey - info3 = await wallet.get_local_did_for_verkey(self.test_verkey) - assert info3.did == self.test_did - assert info3.verkey == self.test_verkey + info3 = await wallet.get_local_did_for_verkey(self.test_ed25519_verkey) + assert info3.did == self.test_sov_did + assert info3.verkey == self.test_ed25519_verkey with pytest.raises(WalletNotFoundError): _ = await wallet.get_local_did(self.missing_did) @@ -140,19 +241,23 @@ async def test_local_verkey(self, wallet): _ = await wallet.get_local_did_for_verkey(self.missing_verkey) @pytest.mark.asyncio - async def test_local_metadata(self, wallet): + async def test_local_metadata(self, wallet: InMemoryWallet): info = await wallet.create_local_did( - self.test_seed, self.test_did, self.test_metadata + DIDMethod.SOV, + KeyType.ED25519, + self.test_seed, + self.test_sov_did, + self.test_metadata, ) - assert info.did == self.test_did - assert info.verkey == self.test_verkey + assert info.did == self.test_sov_did + assert info.verkey == self.test_ed25519_verkey assert info.metadata == self.test_metadata - info2 = await wallet.get_local_did(self.test_did) + info2 = await wallet.get_local_did(self.test_sov_did) assert info2.metadata == self.test_metadata await wallet.replace_local_did_metadata( - self.test_did, self.test_update_metadata + self.test_sov_did, self.test_update_metadata ) - info3 = await wallet.get_local_did(self.test_did) + info3 = await wallet.get_local_did(self.test_sov_did) assert info3.metadata == self.test_update_metadata with pytest.raises(WalletNotFoundError): @@ -160,14 +265,18 @@ async def test_local_metadata(self, wallet): self.missing_did, self.test_update_metadata ) - await wallet.set_did_endpoint(self.test_did, "http://1.2.3.4:8021", None) - info4 = await wallet.get_local_did(self.test_did) + await wallet.set_did_endpoint(self.test_sov_did, "http://1.2.3.4:8021", None) + info4 = await wallet.get_local_did(self.test_sov_did) assert info4.metadata["endpoint"] == "http://1.2.3.4:8021" @pytest.mark.asyncio - async def test_create_public_did(self, wallet): + async def test_create_public_did(self, wallet: InMemoryWallet): info = await wallet.create_local_did( - self.test_seed, self.test_did, self.test_metadata + DIDMethod.SOV, + KeyType.ED25519, + self.test_seed, + self.test_sov_did, + self.test_metadata, ) assert not info.metadata.get("public") assert not info.metadata.get("posted") @@ -175,12 +284,18 @@ async def test_create_public_did(self, wallet): posted = await wallet.get_posted_dids() assert not posted - info_public = await wallet.create_public_did() + info_public = await wallet.create_public_did( + DIDMethod.SOV, + KeyType.ED25519, + ) assert info_public.metadata.get("public") assert info_public.metadata.get("posted") # test replace - info_replace = await wallet.create_public_did() + info_replace = await wallet.create_public_did( + DIDMethod.SOV, + KeyType.ED25519, + ) assert info_replace.metadata.get("public") assert info_replace.metadata.get("posted") info_check = await wallet.get_local_did(info_public.did) @@ -191,9 +306,13 @@ async def test_create_public_did(self, wallet): assert posted and posted[0].did == info_public.did @pytest.mark.asyncio - async def test_set_public_did(self, wallet): + async def test_set_public_did(self, wallet: InMemoryWallet): info = await wallet.create_local_did( - self.test_seed, self.test_did, self.test_metadata + DIDMethod.SOV, + KeyType.ED25519, + self.test_seed, + self.test_sov_did, + self.test_metadata, ) assert not info.metadata.get("public") @@ -205,11 +324,11 @@ async def test_set_public_did(self, wallet): assert info_same.did == info.did assert info_same.metadata.get("public") - info_new = await wallet.create_local_did() + info_new = await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519) assert info_new.did != info_same.did assert not info_new.metadata.get("public") - loc = await wallet.get_local_did(self.test_did) + loc = await wallet.get_local_did(self.test_sov_did) pub = await wallet.set_public_did(loc.did) assert pub.did == loc.did assert pub.metadata.get("public") == loc.metadata.get("public") @@ -220,22 +339,30 @@ async def test_set_public_did(self, wallet): assert info_final.metadata.get("public") @pytest.mark.asyncio - async def test_sign_verify(self, wallet): - info = await wallet.create_local_did(self.test_seed, self.test_did) + async def test_sign_verify(self, wallet: InMemoryWallet): + info = await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, self.test_seed, self.test_sov_did + ) message_bin = self.test_message.encode("ascii") signature = await wallet.sign_message(message_bin, info.verkey) assert signature == self.test_signature - verify = await wallet.verify_message(message_bin, signature, info.verkey) + verify = await wallet.verify_message( + message_bin, signature, info.verkey, KeyType.ED25519 + ) assert verify bad_sig = b"x" + signature[1:] - verify = await wallet.verify_message(message_bin, bad_sig, info.verkey) + verify = await wallet.verify_message( + message_bin, bad_sig, info.verkey, KeyType.ED25519 + ) assert not verify bad_msg = b"x" + message_bin[1:] - verify = await wallet.verify_message(bad_msg, signature, info.verkey) + verify = await wallet.verify_message( + bad_msg, signature, info.verkey, KeyType.ED25519 + ) assert not verify verify = await wallet.verify_message( - message_bin, signature, self.test_target_verkey + message_bin, signature, self.test_target_verkey, KeyType.ED25519 ) assert not verify @@ -251,38 +378,44 @@ async def test_sign_verify(self, wallet): assert "Verkey not provided" in str(excinfo.value) with pytest.raises(WalletError) as excinfo: - await wallet.verify_message(message_bin, signature, None) + await wallet.verify_message(message_bin, signature, None, KeyType.ED25519) assert "Verkey not provided" in str(excinfo.value) with pytest.raises(WalletError) as excinfo: - await wallet.verify_message(message_bin, None, info.verkey) + await wallet.verify_message(message_bin, None, info.verkey, KeyType.ED25519) assert "Signature not provided" in str(excinfo.value) with pytest.raises(WalletError) as excinfo: - await wallet.verify_message(None, message_bin, info.verkey) + await wallet.verify_message(None, message_bin, info.verkey, KeyType.ED25519) assert "Message not provided" in str(excinfo.value) @pytest.mark.asyncio - async def test_pack_unpack(self, wallet): - await wallet.create_local_did(self.test_seed, self.test_did) + async def test_pack_unpack(self, wallet: InMemoryWallet): + await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, self.test_seed, self.test_sov_did + ) - packed_anon = await wallet.pack_message(self.test_message, [self.test_verkey]) + packed_anon = await wallet.pack_message( + self.test_message, [self.test_ed25519_verkey] + ) unpacked_anon, from_verkey, to_verkey = await wallet.unpack_message(packed_anon) assert unpacked_anon == self.test_message assert from_verkey is None - assert to_verkey == self.test_verkey + assert to_verkey == self.test_ed25519_verkey with pytest.raises(WalletError) as excinfo: await wallet.pack_message(None, []) assert "Message not provided" in str(excinfo.value) - await wallet.create_local_did(self.test_target_seed, self.test_target_did) + await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, self.test_target_seed, self.test_target_did + ) packed_auth = await wallet.pack_message( - self.test_message, [self.test_target_verkey], self.test_verkey + self.test_message, [self.test_target_verkey], self.test_ed25519_verkey ) unpacked_auth, from_verkey, to_verkey = await wallet.unpack_message(packed_auth) assert unpacked_auth == self.test_message - assert from_verkey == self.test_verkey + assert from_verkey == self.test_ed25519_verkey assert to_verkey == self.test_target_verkey with pytest.raises(WalletError): @@ -293,8 +426,8 @@ async def test_pack_unpack(self, wallet): await wallet.unpack_message(None) @pytest.mark.asyncio - async def test_signature_round_trip(self, wallet): - key_info = await wallet.create_signing_key() + async def test_signature_round_trip(self, wallet: InMemoryWallet): + key_info = await wallet.create_signing_key(KeyType.ED25519) msg = {"test": "signed field"} timestamp = int(time.time()) sig = await SignatureDecorator.create(msg, key_info.verkey, wallet, timestamp) diff --git a/aries_cloudagent/wallet/tests/test_indy_wallet.py b/aries_cloudagent/wallet/tests/test_indy_wallet.py index 9b3af72721..ae26b2acb2 100644 --- a/aries_cloudagent/wallet/tests/test_indy_wallet.py +++ b/aries_cloudagent/wallet/tests/test_indy_wallet.py @@ -1,5 +1,3 @@ -from aries_cloudagent.ledger.indy import IndySdkLedgerPool -import base64 import json import os @@ -9,7 +7,6 @@ import indy.wallet import pytest -from asynctest import TestCase as AsyncTestCase from asynctest import mock as async_mock from ...core.in_memory import InMemoryProfile @@ -19,6 +16,8 @@ from ...indy.sdk.profile import IndySdkProfileManager from ...indy.sdk.wallet_setup import IndyWalletConfig from ...ledger.endpoint_type import EndpointType +from ...wallet.crypto import DIDMethod, KeyType +from ...ledger.indy import IndySdkLedgerPool from ..base import BaseWallet from ..in_memory import InMemoryWallet @@ -60,8 +59,10 @@ class TestIndySdkWallet(test_in_memory_wallet.TestInMemoryWallet): """Apply all InMemoryWallet tests against IndySdkWallet""" @pytest.mark.asyncio - async def test_rotate_did_keypair_x(self, wallet): - info = await wallet.create_local_did(self.test_seed, self.test_did) + async def test_rotate_did_keypair_x(self, wallet: IndySdkWallet): + info = await wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, self.test_seed, self.test_sov_did + ) with async_mock.patch.object( indy.did, "replace_keys_start", async_mock.CoroutineMock() @@ -70,7 +71,7 @@ async def test_rotate_did_keypair_x(self, wallet): test_module.ErrorCode.CommonIOError, {"message": "outlier"} ) with pytest.raises(test_module.WalletError) as excinfo: - await wallet.rotate_did_keypair_start(self.test_did) + await wallet.rotate_did_keypair_start(self.test_sov_did) assert "outlier" in str(excinfo.value) with async_mock.patch.object( @@ -80,11 +81,11 @@ async def test_rotate_did_keypair_x(self, wallet): test_module.ErrorCode.CommonIOError, {"message": "outlier"} ) with pytest.raises(test_module.WalletError) as excinfo: - await wallet.rotate_did_keypair_apply(self.test_did) + await wallet.rotate_did_keypair_apply(self.test_sov_did) assert "outlier" in str(excinfo.value) @pytest.mark.asyncio - async def test_create_signing_key_x(self, wallet): + async def test_create_signing_key_x(self, wallet: IndySdkWallet): with async_mock.patch.object( indy.crypto, "create_key", async_mock.CoroutineMock() ) as mock_create_key: @@ -92,11 +93,11 @@ async def test_create_signing_key_x(self, wallet): test_module.ErrorCode.CommonIOError, {"message": "outlier"} ) with pytest.raises(test_module.WalletError) as excinfo: - await wallet.create_signing_key() + await wallet.create_signing_key(KeyType.ED25519) assert "outlier" in str(excinfo.value) @pytest.mark.asyncio - async def test_create_local_did_x(self, wallet): + async def test_create_local_did_x(self, wallet: IndySdkWallet): with async_mock.patch.object( indy.did, "create_and_store_my_did", async_mock.CoroutineMock() ) as mock_create: @@ -104,15 +105,18 @@ async def test_create_local_did_x(self, wallet): test_module.ErrorCode.CommonIOError, {"message": "outlier"} ) with pytest.raises(test_module.WalletError) as excinfo: - await wallet.create_local_did() + await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519) assert "outlier" in str(excinfo.value) @pytest.mark.asyncio - async def test_set_did_endpoint_ledger(self, wallet): + async def test_set_did_endpoint_ledger(self, wallet: IndySdkWallet): mock_ledger = async_mock.MagicMock( read_only=False, update_endpoint_for_did=async_mock.CoroutineMock() ) - info_pub = await wallet.create_public_did() + info_pub = await wallet.create_public_did( + DIDMethod.SOV, + KeyType.ED25519, + ) await wallet.set_did_endpoint(info_pub.did, "http://1.2.3.4:8021", mock_ledger) mock_ledger.update_endpoint_for_did.assert_called_once_with( info_pub.did, "http://1.2.3.4:8021", EndpointType.ENDPOINT @@ -125,11 +129,14 @@ async def test_set_did_endpoint_ledger(self, wallet): assert "No ledger available" in str(excinfo.value) @pytest.mark.asyncio - async def test_set_did_endpoint_readonly_ledger(self, wallet): + async def test_set_did_endpoint_readonly_ledger(self, wallet: IndySdkWallet): mock_ledger = async_mock.MagicMock( read_only=True, update_endpoint_for_did=async_mock.CoroutineMock() ) - info_pub = await wallet.create_public_did() + info_pub = await wallet.create_public_did( + DIDMethod.SOV, + KeyType.ED25519, + ) await wallet.set_did_endpoint(info_pub.did, "http://1.2.3.4:8021", mock_ledger) mock_ledger.update_endpoint_for_did.assert_not_called() info_pub2 = await wallet.get_public_did() @@ -140,7 +147,7 @@ async def test_set_did_endpoint_readonly_ledger(self, wallet): assert "No ledger available" in str(excinfo.value) @pytest.mark.asyncio - async def test_get_signing_key_x(self, wallet): + async def test_get_signing_key_x(self, wallet: IndySdkWallet): with async_mock.patch.object( indy.crypto, "get_key_metadata", async_mock.CoroutineMock() ) as mock_signing: @@ -152,7 +159,7 @@ async def test_get_signing_key_x(self, wallet): assert "outlier" in str(excinfo.value) @pytest.mark.asyncio - async def test_get_local_did_x(self, wallet): + async def test_get_local_did_x(self, wallet: IndySdkWallet): with async_mock.patch.object( indy.did, "get_my_did_with_meta", async_mock.CoroutineMock() ) as mock_my: @@ -160,16 +167,20 @@ async def test_get_local_did_x(self, wallet): test_module.ErrorCode.CommonIOError, {"message": "outlier"} ) with pytest.raises(test_module.WalletError) as excinfo: - await wallet.get_local_did(None) + await wallet.get_local_did("did:sov") assert "outlier" in str(excinfo.value) @pytest.mark.asyncio - async def test_replace_local_did_metadata_x(self, wallet): + async def test_replace_local_did_metadata_x(self, wallet: IndySdkWallet): info = await wallet.create_local_did( - self.test_seed, self.test_did, self.test_metadata + DIDMethod.SOV, + KeyType.ED25519, + self.test_seed, + self.test_sov_did, + self.test_metadata, ) - assert info.did == self.test_did - assert info.verkey == self.test_verkey + assert info.did == self.test_sov_did + assert info.verkey == self.test_ed25519_verkey assert info.metadata == self.test_metadata with async_mock.patch.object( @@ -183,7 +194,7 @@ async def test_replace_local_did_metadata_x(self, wallet): assert "outlier" in str(excinfo.value) @pytest.mark.asyncio - async def test_verify_message_x(self, wallet): + async def test_verify_message_x(self, wallet: IndySdkWallet): with async_mock.patch.object( indy.crypto, "crypto_verify", async_mock.CoroutineMock() ) as mock_verify: @@ -192,7 +203,10 @@ async def test_verify_message_x(self, wallet): ) with pytest.raises(test_module.WalletError) as excinfo: await wallet.verify_message( - b"hello world", b"signature", self.test_verkey + b"hello world", + b"signature", + self.test_ed25519_verkey, + KeyType.ED25519, ) assert "outlier" in str(excinfo.value) @@ -200,11 +214,11 @@ async def test_verify_message_x(self, wallet): test_module.ErrorCode.CommonInvalidStructure ) assert not await wallet.verify_message( - b"hello world", b"signature", self.test_verkey + b"hello world", b"signature", self.test_ed25519_verkey, KeyType.ED25519 ) @pytest.mark.asyncio - async def test_pack_message_x(self, wallet): + async def test_pack_message_x(self, wallet: IndySdkWallet): with async_mock.patch.object( indy.crypto, "pack_message", async_mock.CoroutineMock() ) as mock_pack: @@ -215,7 +229,7 @@ async def test_pack_message_x(self, wallet): await wallet.pack_message( b"hello world", [ - self.test_verkey, + self.test_ed25519_verkey, ], ) assert "outlier" in str(excinfo.value) @@ -231,16 +245,18 @@ class TestWalletCompat: test_message = "test message" @pytest.mark.asyncio - async def test_compare_pack_unpack(self, in_memory_wallet, wallet): + async def test_compare_pack_unpack(self, in_memory_wallet, wallet: IndySdkWallet): """ Ensure that python-based pack/unpack is compatible with indy-sdk implementation """ - await in_memory_wallet.create_local_did(self.test_seed) + await in_memory_wallet.create_local_did( + DIDMethod.SOV, KeyType.ED25519, self.test_seed + ) py_packed = await in_memory_wallet.pack_message( self.test_message, [self.test_verkey], self.test_verkey ) - await wallet.create_local_did(self.test_seed) + await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519, self.test_seed) packed = await wallet.pack_message( self.test_message, [self.test_verkey], self.test_verkey ) @@ -771,7 +787,7 @@ async def test_postgres_wallet_works(self): opened = await postgres_wallet.create_wallet() wallet = IndySdkWallet(opened) - await wallet.create_local_did(self.test_seed) + await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519, self.test_seed) py_packed = await wallet.pack_message( self.test_message, [self.test_verkey], self.test_verkey ) @@ -813,7 +829,7 @@ async def test_postgres_wallet_scheme_works(self): assert "Wallet was not removed" in str(excinfo.value) wallet = IndySdkWallet(opened) - await wallet.create_local_did(self.test_seed) + await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519, self.test_seed) py_packed = await wallet.pack_message( self.test_message, [self.test_verkey], self.test_verkey ) @@ -850,7 +866,7 @@ async def test_postgres_wallet_scheme2_works(self): opened = await postgres_wallet.create_wallet() wallet = IndySdkWallet(opened) - await wallet.create_local_did(self.test_seed) + await wallet.create_local_did(DIDMethod.SOV, KeyType.ED25519, self.test_seed) py_packed = await wallet.pack_message( self.test_message, [self.test_verkey], self.test_verkey ) From 2a8b590354ddb3f2f04ac4e7dfd413607562b9ec Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 29 Mar 2021 15:31:56 +0200 Subject: [PATCH 060/138] bbs credential exchange working Signed-off-by: Timo Glastra --- .../v2_0/formats/ld_proof/handler.py | 6 +++-- .../issue_credential/v2_0/manager.py | 4 ++-- aries_cloudagent/vc/ld_proofs/ld_proofs.py | 24 +++++-------------- aries_cloudagent/wallet/crypto.py | 6 +++-- aries_cloudagent/wallet/indy.py | 20 +++++++++++----- aries_cloudagent/wallet/routes.py | 4 +++- 6 files changed, 33 insertions(+), 31 deletions(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index b0038e3280..51493a9940 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -409,8 +409,10 @@ async def receive_credential( " that does not match credential request" ) + # TODO: if created wasn't present in the detail options, should we verify + # it is ~now (e.g. some time in the past + future)? # Check if created property matches - if vc.proof.created != detail.options.created: + if detail.options.created and vc.proof.created != detail.options.created: raise V20CredFormatError( "Received credential proof.created does not" " match options.created from credential request" @@ -440,7 +442,7 @@ async def store_credential( detail = LDProofVCDetail.deserialize(detail_dict) # Get signature suite, proof purpose and document loader - suite = await self._get_suite(credential.proof.type) + suite = await self._get_suite(proof_type=credential.proof.type) purpose = self._get_proof_purpose(detail) document_loader = get_default_document_loader(self.profile) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py index 0f265eb95c..2f107ccd54 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py @@ -291,7 +291,7 @@ async def receive_offer( ) # Format specific receive_offer handler - for cred_format in cred_offer_message.formats: + for format in cred_offer_message.formats: cred_format = V20CredFormat.Format.get(format.format) if cred_format: @@ -556,7 +556,7 @@ async def store_credential( # Format specific store_credential handler for format in V20CredIssue.deserialize(cred_ex_record.cred_issue).formats: - cred_format = await V20CredFormat.Format.get(format.format) + cred_format = V20CredFormat.Format.get(format.format) if cred_format: await cred_format.handler(self.profile).store_credential( diff --git a/aries_cloudagent/vc/ld_proofs/ld_proofs.py b/aries_cloudagent/vc/ld_proofs/ld_proofs.py index 69f1189c13..7f0d374d76 100644 --- a/aries_cloudagent/vc/ld_proofs/ld_proofs.py +++ b/aries_cloudagent/vc/ld_proofs/ld_proofs.py @@ -1,14 +1,12 @@ """Linked data proof signing and verification methods.""" from typing import List -from pyld.jsonld import JsonLdError from .validation_result import DocumentVerificationResult from .document_loader import DocumentLoader from .ProofSet import ProofSet from .purposes import ProofPurpose from .suites import LinkedDataProof -from .error import LinkedDataProofException async def sign( @@ -36,22 +34,12 @@ async def sign( dict: Signed document. """ - try: - return await ProofSet.add( - document=document, - suite=suite, - purpose=purpose, - document_loader=document_loader, - ) - - except JsonLdError as e: - if e.type == "jsonld.InvalidUrl": - raise LinkedDataProofException( - f'A URL "{e.details}" could not be fetched; you need to pass a ' - "DocumentLoader function that can resolve this URL, or resolve" - ' the URL before calling "sign".' - ) - raise e + return await ProofSet.add( + document=document, + suite=suite, + purpose=purpose, + document_loader=document_loader, + ) async def verify( diff --git a/aries_cloudagent/wallet/crypto.py b/aries_cloudagent/wallet/crypto.py index d768d801b3..d8b108fcfb 100644 --- a/aries_cloudagent/wallet/crypto.py +++ b/aries_cloudagent/wallet/crypto.py @@ -249,9 +249,11 @@ async def get_key_pair(*, storage: BaseStorage, verkey: str) -> dict: async def get_key_pairs( *, storage: BaseStorage, tag_query: Optional[Mapping] = None ) -> List[dict]: - records = await storage.find_all_records("key_pair", tag_query) + records: Sequence[StorageRecord] = await storage.find_all_records( + "key_pair", tag_query + ) - return [json.loads(record) for record in records] + return [json.loads(record.value) for record in records] async def update_key_pair_metadata( diff --git a/aries_cloudagent/wallet/indy.py b/aries_cloudagent/wallet/indy.py index 118db06ea2..175ae94362 100644 --- a/aries_cloudagent/wallet/indy.py +++ b/aries_cloudagent/wallet/indy.py @@ -60,7 +60,7 @@ def __did_info_from_indy_info(self, info): ) def __did_info_from_key_pair_info(self, info: dict): - metadata = json.loads(info["metadata"]) if info["metadata"] else {} + metadata = info["metadata"] verkey = info["verkey"] # this needs to change if other did methods are added @@ -172,7 +172,10 @@ async def __get_indy_signing_key(self, verkey: str) -> KeyInfo: ) except IndyError as x_indy: if x_indy.error_code == ErrorCode.WalletItemNotFound: - raise WalletNotFoundError("Unknown key: {}".format(verkey)) + raise WalletNotFoundError(f"Unknown key: {verkey}") + # # If we resolve a key that is not 32 bytes we get CommonInvalidStructure + # elif x_indy.error_code == ErrorCode.CommonInvalidStructure: + # raise WalletNotFoundError(f"Unknown key: {verkey}") else: raise IndyErrorHandler.wrap_error( x_indy, "Wallet {} error".format(self.opened.name), WalletError @@ -207,9 +210,14 @@ async def get_signing_key(self, verkey: str) -> KeyInfo: WalletError: If there is a libindy error """ - try: - return await self.__get_indy_signing_key(verkey) - except WalletNotFoundError: + # Only try to load indy signing key if the verkey is 32 bytes + # this may change if indy is going to support verkeys of different byte length + if len(b58_to_bytes(verkey)) == 32: + try: + return await self.__get_indy_signing_key(verkey) + except WalletNotFoundError: + return await self.__get_keypair_signing_key(verkey) + else: return await self.__get_keypair_signing_key(verkey) async def replace_signing_key_metadata(self, verkey: str, metadata: dict): @@ -678,7 +686,7 @@ async def sign_message(self, message: bytes, from_verkey: str) -> bytes: else: storage = IndySdkStorage(self.opened) key_pair = await get_key_pair(storage=storage, verkey=key_info.verkey) - result = await sign_message( + result = sign_message( message=message, secret=b58_to_bytes(key_pair["secret_key"]), key_type=key_info.key_type, diff --git a/aries_cloudagent/wallet/routes.py b/aries_cloudagent/wallet/routes.py index 6848cd867c..2a04c9f2c9 100644 --- a/aries_cloudagent/wallet/routes.py +++ b/aries_cloudagent/wallet/routes.py @@ -299,7 +299,9 @@ async def wallet_create_did(request: web.BaseRequest): method = DIDMethod.from_method(body.get("method")) or DIDMethod.SOV if not method.supports_key_type(key_type): - raise ValidationError(f"method {method} does not support key type {key_type}") + raise web.HTTPForbidden( + reason=f"method {method.method_name} does not support key type {key_type.key_type}" + ) session = await context.session() wallet = session.inject(BaseWallet, required=False) From c452fc031bb2930803c5606a15607e692bb9999e Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 29 Mar 2021 15:58:36 +0200 Subject: [PATCH 061/138] add ursa_bbs_signatures with libbbs Signed-off-by: Timo Glastra --- .gitignore | 4 +++- requirements.txt | 1 + ursa_bbs_signatures/bbs.dll | Bin 0 -> 1413120 bytes ursa_bbs_signatures/libbbs.dylib | Bin 0 -> 7838248 bytes ursa_bbs_signatures/libbbs.so | Bin 0 -> 2839464 bytes 5 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 ursa_bbs_signatures/bbs.dll create mode 100644 ursa_bbs_signatures/libbbs.dylib create mode 100644 ursa_bbs_signatures/libbbs.so diff --git a/.gitignore b/.gitignore index 044eff6da1..669710d370 100644 --- a/.gitignore +++ b/.gitignore @@ -188,4 +188,6 @@ _build/ **/*.iml # Open API build -open-api/.build \ No newline at end of file +open-api/.build + +!ursa_bbs_signatures/libbbs.so \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 4d55726df9..012c3da239 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ pynacl~=1.3.0 requests~=2.23.0 pyld==2.0.1 git+git://github.com/TimoGlastra/py-multicodec@update-multicodec-table#egg=py_multicodec +git+git://github.com/animo/ffi-bbs-signatures@python-wrapper#egg=ursa_bbs_signatures&subdirectory=wrappers/python pyyaml~=5.3.1 ConfigArgParse~=1.2.3 pyjwt~=1.7.1 diff --git a/ursa_bbs_signatures/bbs.dll b/ursa_bbs_signatures/bbs.dll new file mode 100644 index 0000000000000000000000000000000000000000..47b83a0f83044e6413b334fef7bafc1d1ca4e1df GIT binary patch literal 1413120 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%0Dx3vBFrj8~)zl-E`8w&P{%0(y@FUf63fr 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�(m*`>N z=c3W{LwIdf*f>Z}QCM5}4lPAoAo5OiTlmiR-4-5}Y~hO-l`U-iqs!ar9DN=S;-7)Z zY|@jMx!B`qQT*rWEZDD8D%00dot(Z(9L!yBEda)y;!l}9;}jGqKCK6P1@8T^*QMP@ zqd?oY14B^pnk^}bS~-JLQ2y*RH;1n~U6h|0=1tY_G6$6jfs$2-)QcEoe5CfDe3{f0Q?c{!8bU0t z-=);Yx%de(<@EDPQoc+3T(Es=eLZH0msEYKW#TrNE$)FMbhzNDlvRe6L07AKtu+23 z@H^rtl4&eKX#?Y!kg(Bn!F6B@W_bFBpt5*tBRug-TU};azS|GUjg_#&!8BEeQoySa z!#H%`JuXT4*(bS_zw{k<-0Vi>N=4jD8P(`gM@uqL+)cp7+YodsQH%GGodWX+%UXgB zy5C{5Cm8FSus!1h1+z0~#-++!&lgybV>lmP1!70GrdJH%v*sI-w ze4JMU!eSq1(~w7=i^-%d_7pzxH@kR@><^lXP#kN5l!*Yas>0{66=Mc~M&V&elG<2^ zLD9dA$;Z~6apccKk%znBo(N{BzOtQljhzm50`6VvMw>Z>miTF`wl z>~f@EDB1a@IwFlW5wd*K5D`lljDm$Jvi-{sQMT{yig&IVp$y1?Ym|1 zXTyipvy%Sk7HHNoduk^7h)q{!0rkYRvrFS|gsq8L@b{c?`iRvyB5T^=pv7>!X9P^e z+i|cO3hcW6z#MHIhS<~&Q4f$j;xTD^JNx+~Wj?#$4N=D!h$$@# zibU6Vs$*BfQdQKlEBlV#(@qU7SD@FL8+5k{6Ts{{@^!G7U~C~Eh^7qGL`YG#4Vbm9 zpR8sn)v}wIEZ|%G1CtdG_2d0UdEa~}2Fb2nAEP0i-Rn`d*{@e~8nD3_l=SdpgqyKD zxBTIEGPgYTomT^$qW-W9YQo#lOPyz6B`az{r zSjgl?`VhxmGCAxY1CmLle_%3M1Cn*g+Cf+V<*jhPf3#?nU>fQ!VG+VV= zSeWtRfB5ehOO{M{vQ_K0<6NQi)Yn{oI-Y5Da@~Ph+P)(-H`%Inh?XK!_>RAIQ|Ec9 zS0^`m4PjJEW<_q*5@!D15ky-3{!c#MW#;d{>K6Xn552+{GpaCi5>D5C^pov)hCP|w zhxh_u>B%8%Bd;fq!^Xwl1D=U6;BUwtpeHfYB*b@o{7`O2yc6%HC%5YwL8&a>Dy-k0 z-c4)Xn8!0RLN%*ft_5;WtVG`a;-2&lPVK9vF|0PE0XcmbB?+v}uycjmif{hIZHD$Y zy=JIqRNMDBL#R_-j>5h%aSiUrVvw|45kr}~ zZ`gm5tk*d~SnsUWy;!fa6s-3&4p%E@pOiWTCb)Ij&a1sV<#U4t(e#1j)?P!*$8$n*-J3DtAiW`xD z_e0U_GHgbNMqP%G^7w!JOY8_nVd>zNnCZy$;D=8*`c%Nos%7yHobTpwLkN2YTR=El zm_c7WyOoRtyx~E4*yMCYEoY)u&W&xdA;cfM8#O7eZ5{#cu|g&n$STb&3Qm3atBQMp zi|O`U0pR8r-((FkEn6P{T=yTN|5e>R3bPO@hejU8oyfYyV1%iC`|KDCTy+dGs@GQU zSN?l9P5wi~RQWIAua{g5+R!nvcC8bDuctsZCXnd^gPXuG(9LWiOcx|uat6GzdHBPW zA_+0=Xq=U999tRXYSY^@a8*+8Mf0R z*S0MLoA^;#UDx~vylV7Ss{Wp){{B_y1#TTVo}k^J6_goHyOQRaXKAieRNcTgorfi` zosBw9)sx*e$7zr>=fNI(KI}!eIfFycFgg_WWK{09 zLCG?8zlwh9x>`1-c*H1Or!q_2$WGb&juiR)HHfTO;viXKcB+~KG4p6{L-{dI7M{XO zY8E5d2+FlaAoo>?3zzOiIS3pJrkETAnG982KImca?EBFd&OOMJ2ANB~m8rm`$g=@D zSL0jPu9)YX8WZbnlqY7JSBTF!CiE>tkx-smQ)c_d{EGx5#++krfZ1mpMiNH@#7Xun zoSH#6_5<;|PHuJH&(baj|G4Q>etpthm`6%qie z-MP5p0a(oVa;Vv`azQkU*toDV&3$P+rP^>;(jCTAZ z6)!CIlkhB24xN7#L!z+kIs_%{R!<<_+O8|Vl+7YFz?&4>*Z_~!njza@eVYQItF zKd0*VNo(Imwtp?tXKkLewrym3Dc?Eurj!%Sj?zKb;*@v z5D5-IZdeHXjc6?+wPnJ~y7F)Y<1(&5mJaTQ0!-{{b<;1;GquGJlsn2RsyHn+^LwN< zk8V?9qIiVWr)I=ceMGd%SuH_|=$l8mh;}~hBKmM9s;!dhvy4j9K8$B{GMpc}TQ!>hK5`GO*fksF^kowIf=y^S%uvx~Wgcw81wRzgq9GNLmvBFkjbY&Fk- zF47yx_Wc%4Kd!hlr&z?D;Rr%Ev#nG>n{yw<9cOFgMd|Dxu7ve0^8;fhby@WtcsNy~ zMb;oTNm-+73h2@0$KLvn>(|M0oWjY;aVq#2lvb$MM^#Y(@LFQFGkXXat4W6tJmo{~Z`9k@d0!AZB1G*`2hG-vzD_a+sUAdc^1zH&6?hvN>y z5*U;TJo6w(o{Dc6gXb=1=al4O0KR=C+Q33WoaDI?o&mWyxQ%pVO2L z9RZ3LL&xW^^{Q?*o?4nx{DX{YJOgG*GSI`eP=AbPzz)Yp;$ zjAy_bOro9v383Y91}vA>NqPqCvKm<6{3o~6_YgVh8StTuj7b@_C0W*_@59!F&N7}0 zjoxrdu=zD?Z#yzPYdJj>r3(V(IN_zd9bJJYMvuW^u{7S{yonw0a3six%*;~$a3ec} z{Gml!Eaz^4wW+o^++_|=E_0jekv6ZXE@f2PcNarw7OR#@tE)kKGjy375PZH0$7Lx8@$=Ec~~7d14lgggC!8f%kwlYI{(~nGl427!z$-e{1dA<9>!;JJ>w3% zn`2zI$C2FahUlcGP>U1J`CTv&%*=!3Ru>FP^4qoqGRR;YJP>9&89bzXdZ#ml$hb$> zYz5#6#_F2OsFttfSEyjLN7+}10NK|vXM!9um-LY+QCMj$BJs2VzsKRiN^6%V+y?A< z!E3+?jGD4mR+6g=WvLiWS)77l6#!X9;|NtR3M*%?lmdr%^G?t!@Z;~i0>3X{fpV;K zNkBauUSg0L8s#3kuRP(RTbe}oPB-<4)g#t+m4hibZ(ZzpIDCvnBzQRFfg%p?B+jbJ z?S|ooLH+CZbxrQdg zxEk-MM8jKBiG(VnzeiT9wn6TXV{Oam-_Ksb>_M?D7@#;$g9Fnl&rMDgPoUMn!9juk z*f8+bu6^+b%2BF(-J4%ygg<#vQYn?mW1aB>&Su|arn*e5Gk+W|`S0VBI!~(4L%;Kr zT^?}euio(dt?~+I{w-{xl9q6MLKAwH7L4RH=6TfhKc8)nwCkMD$~&FSc25fJ>u;Yt z=atY>0 ztNk_Sf<=~Exmc6-*ZhI+@fVzlO&&w&aE>FunRZV~Q1KG1vrh`aPaurEk$L$jZWHWq zhW|Sg_THJJpbUhuLGTE(+4DW7&U_Eszrt`|AUn2wxBZeV0uzhndx5W5U&g?&$h^2F$}-O@ez%Hn^_^2bE1v}R(y9tmE* z&mKvpR5#M)p<|Z1Oz937C(V4F$*9Kpk&_gAPVUzg0+zo#dkH(wu!@%j>jTA;ZxD0A^&zz-J=V-m!45`Yz7C^L0!}@)&Vl1#QM(+nk~>Lnd!U)W8As>q z9_Lrr5wYprk(oU@Uw5%nSjSfc<(zK?*6HTNgWdW17mvD4HJI~&iZ;@pw(lGAcFxyV zvp$y2*Le?MzFy3Lwvtht1L<=TED06Kgy{V9{`5)w4ZuQoq)K#n0F(Hw56dJD=Zf-x zH;Lc#rA*?OY|8V{v1|T`bY>EN5AVGu@qiImKwy}}AHW?0(U~}hzGkS@wIny~93pVh z5Vv@aBQBi7%dz?SC;)=8Op~3ch&r3_rlBX%SyOjM^SM*{p8=HXlz#ayxXO{7(x)mM zwvGVS_cM{1BMaEZB0l~N##OWd#>^f`ndovxO`X!SyeT~dNf|l-^oi-Coa^9}?+G5) z(nJ3k`IPg!l(G&#)a}o)URgW*+w0H$q%57y(UBCAHcLr;*+DL*J}>np)JByWI(8$b zNG-GZ@hIKNJ|DBW44ax~WHz6Pj_{q^K~!}%KLLXgqyB91XekNkYTE6}pyo%DM{Nmc!WT-(W^2u6L0UF`WV7n`PA&4_RL=bgV&&c*FDVmt$7nNQ=VJKe zCx?&kh&4$(-}#noDf`n`QYY*OZsFuC(>_;vu91u|fB#6o8?~PgxePd9pQ4$+|AK)E zAgk~Ejn0f6$oczvVApFt5PO_CoxIT7-B&)!nY5G6I)y1TnRI_j-(S`@eZXlw&zM*? zAw6dK&vDy?SZD@F9zrX4_VK>ucRSCQs+h1T_@n%DeoFaP`9x>Z4eTgSikP5M#6Z}1 z`gMQ(;Vi4CRFT^H>x|F2)M?n^r}f{YpOik~9Nwx@$Urq-cmD;P&pz!-0G@2Zmo)jL zj!f6~tZo?qT@j@o89^|a!K(-TH78=GbETA{`3@=Np}W{sa(#4%b1wl(DSOK*VNAC} zH?+%i6YJn~gJ0OT`9UR?sjDcl=*DTQnNc9OjFf{7I-I>0BEQUc;oZd#{+e#V)c3C) z$Csw^!D@<$5P*_vBDDayHb4E#pT@e~1@1`wWVB9DeT z&5iM}1e+mHeLx;^as<*fYLr4gut#5^5FU#%yWk{6V>gKh|ICRU?RYgJSq02|sdkm4 z0LvgdOL|RfnC}}5fry6&ARz`=CDV&B1|&Yzp=>4*!#HV|F|3@4Zz3Bbp&gF*q=cqb z@Xq33y}U7$)+noAr*{U6g6Q@c6%$L!?KPh?b5bacVXc?%X+8WxGk-(qbqMNB>`=!D_YnI}@jm zp(Ajnw~nEwA8^OeVXfX6nu`?P81np`qma{STbk7E{0-Y9Jr?)?0V~!G{8bu%=+07xlxmamxT3G`XvySgP9|cE4>uUuEhrR}I!WlBjM<)5mq`=U(P&+uC5`TeE z6PTsxp{0;&-5#=iljPz69Y?rO3w1%(ByxH3)z+@XwLtiS54m_q94wB>cO7=y9;ogt z2*S8o$Vb}nbQXP_>9t8gEW)JJrRi!Eeq%uclCNBc?PgtjGwm zS}Tx6q?HUv^|9Y=N5TQ$e23%J%9$dt>SHT#>ZiJJMelPd=pyoC<-%ha)%Kmq5b9$% zp9DGhWvf* zs`Bxay#Bn8+aBk8d6ja%mbS;KjH=U7tP?1zN>gYuFtZy<%U(d7LhtmNkZqDHGSu~bPSsCN2*=!R9W+C5344#UaevLzUA;bb}M*wfmCp>-W9z1T2$}~TmB= z=+$+|Dz0-Hh|iuYqoux%EiyHanxNf{8ud~JyAFUiVc+vz`shz0+&U*^uSok(9?a!;8|EAo5_llFNAft!<$2+AEXP+N?)E)B5}h_0fjuoz=SWiL|oxjA?_R=yf}bhkTDc zp!tQTAY;4ED08MYLKK@a?=*I~nqm9s4BdvnH z>}ID3>Ab&HU&5}? zzG?K{j>dQ19Nf1Vl6|`V_BiRizAE$vrkD1(7#C;tw!tJ-@YMaFScm71ofHjx6hPI$ zp9)(StL}Zt=7?hLC@Tc#;+?wpA#+J{M)%&I0Cn%b>PDLk(GD82_`pFFc<_hr;s@2e zSHC5?x5UKJeF#s{z3*jyqI>g$zb1zW*uJxdaF!E?o4@ka)xDcQ{1FfmBs+OP_l7vN zB6ctmQ&D9)@Zw7#!&3LgHz+0_ory9mWQM&`P#+*pcH_~X?#<7x?#(!zch9O3sud#W@{A#FpG~g6?%Uc zcNV|qc2^qs&)>XR{CK2rBOA?od&vZ|?J>y0Xx!ny=0^8&4AGdD^cy@+SYc~9>Y=^p(ud1Y{aV{W_p~@h&S9>C(UWs3k1904VV0| z7PsQ@-e`8^^XsF+{8FOx;s76b7LNAwINu*mS_d31W~z{bEV@5@AwmD8I!w$Zo6_ zzA_jZN-ZFh4*Bz&bKOeOP!JnB_P>}K=avG(1^{vUT0Yox=ArT}=VgIaq~CuhS7810 zR+mISSm2T9U5sk`+8IK%yU)fi*5D7^Uw@(a97;DJIg!Rh$(*Y3%3oOh^w@0(t;K`+dptvE1??csxYZtz z3*_?a3u4-d4yG!?07a zS8$*3Jz~HR;Uj@<(z`RW9@zRDxLK-4Vp(=i}#{# zjj=FopQEjsy($IhbI9}wNu;`+;|VA|XvVxRb2DTN!l*!Fn(^fhjk~sBOM%b9Q5coU zSt76wjcsw+hw5@K-Rw458~M04*;R~c`P^6^?aqoq89a@xZ{xdi?Z#gBu(=6QQ>t^mNtsd|XM49g=Xe=>c(<295mKa9M~6}^_0MxogvB%7RuuV$ z*96MkRC5L@hPjDj26K~6Ccan2Jz48_8wLUD_v_yEyX|t+Zx%?-*&f+a+5M7IxfSE| zw1W2wvB>Wx=(k681DX5nUaEZ1b5W}3xp;Ddkx2;5oULa2{+8l*S}qQ!*{3X#R%Qg% zb9Vw3DSGbryQ-e6Q?ubd8g?&`VgEcDkYg?7Bn(z5r0vJBZOS?Ha!RL`Xh zhVHdHWQwR~<(%2vjBnW-;7L9AaV!q6?Fzzzk`#4Q{7d`ae}JLRslloG@jK7YbYu2o z7y-p_3}@MRYP`}g&;T}Ve+sF@nfpr8HD_b+0U~Tdr{%V!E6_%!rv&BH?vwPj+Q{^r zcAfl}s0xwCV^-#r>v!~@ApX`X_cx=d&t1qA9=YB+9`zMdCRRQ zKbKy!ZrwC`bwvXsuRi#KOLl#0<|(jg>&FpJueP^-+>OE0TYC**K)TxNL3pS3IwQ{h zGurEU1gO29p)1D;vetM5^#q89pkeu;s{EkZ>!{~NdzF}$9J!sMy?)00M0;ggf6beM zqwl`&1IQL9OIyA-`08q}l^`Ul?GW7L0qqsZR>VHTUuv&R2M*f`sW`cEyw~qghK0;< zJrB}ic1qyUpZ3bnuJ+0}soJZo9J3N;<(T*aMdZA}%f+2c9ivwoWD}O9BF2k9poS_b zeqrjd5B6KCi1FqecTx{w`@0b_8jxZ|Od}hm^>oPuvt}WbO{26PEK9U~mDUcTE=nsL zK%lf^xx%j#);&(or?%d_#=xCnrq+STaa7xpY&{-t|I`4c%M<&bn7JG}b zTm)s)_YOxpVb6a|FlnejaqC$u=0y%@8C*{pK=07Z-nql z%QjvsbeXso6~Q`92!f_kOWJbim3jZQHR8S4f^9L93H})sjDy2d!H$O9gNjvx2VOQx zRIn<%{u|zYAkRv~L~_;(8S%B6+3?jC`i*FWT0Ho{BYd&#QRGIbbLIA;g0bdkl1j7} zBcsr2s9@S2xS$s**wt`)dd?zzv`Wmv3y>t31MtjXw!GsQ> zf*qs5x`uoO6Q>WX@3wXa)~q^rV0}YnOl9}+NZ}2v{??HHRO3~-!#Rt9C5uQYYsh4? zDfWM%!+cytF!1W}c5bF@s~iBZG)V6auzFmH)#GZ~QKX0Uz6^H_Io8NPcNtkOec?*7 zjXp+blcX?<$r{yLPp;&8a-VBBHfUA(kNb+vM*7V5Z3*TDViro<*j4575U7|bon_l{ zs87e>E(xcPV`uU+DT~S2k3u`m7$IV_l8ikp{VpSa_HYV?KPPkDK+DM6^cY5lYd6rz zIlBw2O6g^|p;1+?(O0`fbinl<5q9Y=<&6<##%Sw=yv$Me32p4C$jWE)78)08gRyvZtS}l#R6GKwh zp~JJxI&@}AULR$8{_LWU+sH<~(Fhu%zlnN@TBc!+4O8Q|4MxKPdf+KKhyecsr73_mDB3CCF-{dN8KVbs^$NN=q_u} ztEiBlWkTOTGs+rtJ6_D(Jn)k9aPjcfVwXS9^0HsLON!;#S5>@jYMLs3UO-iG;q+K? zenVVtWXbto$GfUHI$_{K|AvCxx|VC9H$T{`(49~ymz*h3qCd;gB@+rAA1qT)XF3s> zRKlMSC44X}4@m_rMIY(4UT8#O=abJ>iRgdHY~b{+sHM}jGq!}uyM&o;0ReIZ3`u4BX=hwrieKYUgS*;tNNUPMrtam^4Db-db!t`3DA`}8Sd-`v0d@Q;0 z&Uv(oM9+Ol6X5#p+g8zO3n|f+-me*CGKlK z=v7{%RcbHVcLJ^HJczt{FVwCBU-Ybq5jS14Y4qtzf$n&N`054?+#&0154#;S;e7H* z-)ea3dfNHJ-1kk{!}c`xFu>j#J5M}wHTIM7PK|xVMeK8K{fU(a4<3SFo}uV<(O;I*ZZ)Gk)LSn{NS(2WJb1c&8M8BMZkpEKKwI4 zRN9Y#rryN-a=mRZsL1-9EgN0`nz0QT_QOqNTYuS;gfrY9L zKjd?7+8&2*tWy=QX^?4qyBc@e{>!!AwEYH}XhlpT8y)@@wi?B)AEA619e%R`bohK? zAUga}EYEr9m#V|RD<~Nqp64u=rRnf+`KA*87-E&%`-~}#J`E*aa^M=xzDt0f<=f81 z<0ur4g=d^gt0y%71~67?^N||H{X!On)8){B>i69(Wrv`+%TdfYWso6Qt*>Sq$3;m8 zRTLKauTOyBq^<3GessQmj9%b+Pn~Tpi4ya@-s|Q(P49nMgI<6vAz#$7)B+)uuw7mO zt%2D&_XfE9@Hu4K3J3z_mHxnlYFua;0STJeToCI%6q%6hTKNspb?o83H8@bYO%7H6!@H>n5g)9! zej&>Zf>y89*47qeCQFa4%*grkbo7F&@SjZ}+R8?t!hf9mjY@0l0xJs_;uY zKtW_xZC{lw3V#nF%XbzdS~?ERnyZWv=e)NV&0BL7nuWrDk_PK2tY>0X3EWY*)eqQd zE~Bt6;*P>CuJK0UACMw-6uN7!6WRE(=30R_XWBHc;vLTU1gsc2LCTtI2f(y7SCFCG z;_yL0E{2rXAbH7%&(L(NGMjEYH}`VfiJ1eSGbS3}LOz=)kl z+!zt`r)Nuoo4;!>Bmtk0I3X>w$#3^nB)2Qbz;%v+Un3v;WAY zcecOVbMHH=5vjDTcuX!5a&qNq`FZZ=51iNW84VIL)kYJ!NMG`?LE5a9TLLT6PLGT9 zf|)MT^P(Qom5hq}Oc|28R{rpL9>TI#UX_w%*^h3P$9q}kCmp*FN7R(X{I-+2ujimEzJ_+C`U zb8sMm5gSw^pKwNUt^7t5L7qU3d?uNJc!o#+;Br4WyLU^k~Yylci)_>-}LXKenWfLZ~7nH zwenSmq^*^cTdZ)`%3n0!#6R6$p10{4v3HA&aXOU4=necgo)O)2y~*vV1ocenK_^kOc% zLqVtHh;e3*Vyjv8thjS3E-qpa-jiy&u9g2ze8Pc`r!Vzc?*seb^zMY;>vx&2xBk~B zy^*6_?seP2)X=3d`mX4vpZ_u7dVM}B=Q!D$ygp4PI7iXsSNer+t_c>ywN6Xv6f)YF+q5TBQ!65mtZ*om=0bcBzEWyJCIy z%f9$na-_a9_jjbzm=IE22nnNbp6baPwLZOeVs8z3ee}jj!Rf%EvaXodyBh0m52?De zhY;>H$mpl7PjhTLRgq0ypJx9+^AW+`2{yT8*LQuozy0}r`v)34>80hZeyg?K@#rEl z>cLI+FOl|`cv*k#p*p)c53z-+by82y2S`0V^*qEpMrrfTLmcuhW~F#cv^dLmFd?eT z2S99+Fzis^0-UW6`fH&qX$3=%38| zEIU9Fw(4{7aK8RTrdnc?oND#ZMQ=9n{9$^ZpQV+mD^89pIU>rlC-#X?6rISn8yMfx zL&p;*u*Pz=CwBS}YjnWD9G1#uQKp%%(#vy3BH=pEw-WMlx^a)V%WfwVPd~C#ikrlZ30QNgNErHH@?H9R(nfKi~IEp|9{(lk*=-D>|)}wZ~H8;VL(59zsQmQ{*JTN!23nsU-*;ui=2LgxFVo?``<5e z-Rp*^%zlyE1=cxnt>ex$kNw#t(TC3WNc2KRwSA2Y>Ahd%nbSBjH*~*9IaEGj3bya% zUwHdP#?6*C=2)kt5)X)JeC6E8Uw9+-i@f>OcN?}}V^F?iZf`Z3tynBpCydZ#*|M&ZO;2@Ky?XWP)vH&pUcGAU zWV;lnY1l3wRIioo@*NiLEQ-dmpT*j+@jADkM@Myv|3isbQRLb#i($`=IWJOsd8FZ+ z?HVTb^1Mhksib-!dS2v2ymZcsK;f73B4Vg<&x>%y58;S7=M63WaQ$LE69?MIM3HK< zk7lCld65lx2{T1Lc#38)w8^!B-LjFCg1mKXV6!EyhQ_C#xkKZjYh9AvM>(#f_od8g zc|&{?YXf`sGLj{FUgXLckUCDME2Gia2*{MjA|Nx7HO6Rkpi?sDfW_?3-EK^X0H~N2 z0noC+?MCpYYXg(>A_p8Bv4M3yFLHy7K&8x`8y02m3Fvq9+5owfGIuU+ymQZsRG|vj z26pS0ZgcLs1~`Q3L67sJhs~}<)|lpubV|lFrfDldNT$A6pa14w(n+eMtBA|-Tva3PByS)s67!I*elMry*V#(*OO5! zj=*nVabifQOrx{1fwhjK${Yz{xBhELGsH!Z5e_GnVdVrNPAb`Hu9FJ7NSsvg?0!gP zznDrFJt38Dy}L`LQ&A~xU_G4|;dyB2>c8W8k&N|wxBhkyhbW0*-*fcubY7(Rp}k(u zv)}B>o1%Jqj2r)+*c0S@MZxv~kx;(BvV0QPG3^i1F!Ec^i#!V`zt4G*%54LH&+l+v zL|aEg)^9#95)Y3usBpjiyvR+tztSFu^CDx`x^3ugwTi4~V;6Z9Um-HK#QE=bUZi79 zj{~Rs`nYF!m}-SzH($SZ{OYO(&iI@0fXjB>TNZIZk@F&44)=EFKoe|QIz!waxDqOj zx^3WJa#~kz8)(6jxRcVf6X9j64TBR#V?3r@ z`oB3-fREv!UHcXh=8)FqG< zn~Q0Hk1bL}8=`;=GL=9Xf-HH(n^i0#z{qI|MLo$NcmSsh%|^< zvH!_8TwzQ@O_BZ2^Vw$;ec}7@(moxj*r0gn{7T*kvQl-~z}ATvr(G`_=mYB_x;|M~ zmko@^^bcdWTsANqzoSv`&@{z!U?qe!#vOe2J_gnXa@oMi(zGs@4Qy7I4K&*iP9RXn z-d2r-I-?-5VNCDlwP>VkA>6ju}K~7bAe~a653omyoof>t4Hjy<( zy}L4|WDA1nD|Cw@CEw@zQmM|Fg08Ed%4GvL9vab4!`QQRE*ogRi&Rz0=@JY!4Cw?o zeRD`F&9nylO5u;Fx%~+MoCCz}m8^$i;{{xv3@+!D1OJQxcC`W)*9q9Q1U8?*S`JZa zU^=&M90v;Ofk#O?VOb?{@P3TP3x8t%dTgI`x_LnRl*${p$-Q|%jU>xE^`U5s2I4nb zbj|2Y$y4YTUL5f5VHxF!%(?S|7tl?z*i8^C; z(MLMlC}rG_K!aTaq~Zm~p2_*7TYjH?7q0)<^ZfPpP2a^RIub@;*YF4b3i!N(VDMQa zRwR1x8?-Vz8(>mfr}?&Xd+fiT$+>-3_%z#hHN?!nyTw1*9`~#Ax7j0NF6`Mo{gdqu z?4ER{f9LqVSrC{pyGYy|1W9lOK{UG1PLXFSy3qIdO>fW5<48uwTKonRl&?q7_-o`A zV^EeLg#0uB1Ms|J<>C47ztciIy%?T1(_(x(FV9D5+J?)fX+gNpP{?1gl42@1OEM%VpmRn5S`&pM+rBxW9E$$Aefh7wQ(;qctx~<&4^=~EMb8@ z@#*`%i%SO5c$qB|$07u;y;^W`?lhPyO4BVkUiofBDL4|6U=%VzDL7Jv z1QGz$4gStEHm3Iu_5iv!WT0Zv4UUwEKz~CLWL*rYiHI(|$bhn|K450y*kmqLg{$hR zP68eJjXm}%6$a@xTuC|)Z*iau2h`9#71P(yBQ2Qc@Khc=(khr}$~n^2C$e}@EFTKR zkr_Bl6gV=XQGBq%!R{=Q#T@jFq}9AMW}`bV#b4yod*@}5*<~fOTHdeth8o4&7z?gO zF^Kh>)6>H3cnMzS@kr%?nOhQZzkE|x+Gv60J-(wqITlwtcpoTZJ8($1DK{R3<O(azf4yW3A&nnf#iAy8g13TlX>b1H%;bti%TORIM_dXz_u1Y+>j<1S(pj4xOD5yibLj4Dzwien40%Q;aeJRnh`W?wwUDReCMH(tf zJiVKf#C!zMG?1zqUJ;AEs^J>^h7g_xXF?b6Q;^FZjv8WYR!Q)$pv_9j8p~UL1Dh^7 zjCBNUR%&pd$MbBcGA;Jj^`gpTL^pnGLCcP?Z&&(Dc9-)VoQv%LEmJde&&N?Z&Hr7? zJNPyk79I>cVU13qc?M2l-=#aW%9boVFf(w;fhtX!ZR3@DY=gwhq27=5K{ zd&=viS~>sYvscy948hs78;lt9K^FQc;?LFvAKkR*yluZQjJ=tQtNqSz0SBfjC97uw zx%=T_kLUu=J_>+--T9v^`Sn(Ix(5C8rhZ zsg!b-!_jI;)~*^N?|X31192LrhWI=TVG#}S878P9o=lGjoEHF)-9+%1YV0C{#~*2~ zi;3Xzso>~6VHqSfLk{3RCjNB&2})399ILdPs05)xq#8rGpG)*ImV?mR06mu58F&lr z@v1?n!vruDTOM+?85HP2hm_Fsjv5(ao=%!%3zQx<7ZZA$Zc4Ngpa{qka$-*|e6U$M zw4czpdNX@Lf`lc!5Sf;6&zrR4+V$?7nw0O(Yj;kJ&TD99V@PFo^vi`L6Lq`oP~9DC zQ-^5ryNcj}d*7g9ZfsL3=5oY0)L|Gmh({bg+biDRiIve8dB%?@ z&q|=lmdK;abq~yq!}UISR3*!!nyM_yEtHfBxgWXiAs;WZpZA1MAuV?q%i}Vc&KXw2 z``nMBb56t0)ay9f1Sf8f0~!Dk64B#7P>-BkjwIez;=vD*As~_CxgCnP_KO=t4P_2+ zEk=_H#E;SfsE!{%UclRCzr0E9knQ?)3 zZbf=DRk3BNV#`#;mgr^xEe8l3QRAWm2Xq$l(J810Qe;#Nj(U8{4dP%7*7VH8G+r-O zsl_U_P*S-ii~>>4SE&kgssg?1%OmPqPy(ENXLzT$+Ne}ABpiBHIIt-cj|%V~Ioii3 zeg=o9O&ktEJ-`|@73sV6{&wzH1Xdt=Cs_8DaGoN#?bwY9cP0|Y6Z&$h4KIM0@eRy01 z+pC$?^3LNMI6WK`!Sp2<(Hu28$F!<~{auyAOi zx`ca3La}}y906c)d8tVF<+JY41cSI%f@g{E-VG7$lSETHeLQ(dOOJB+VPtwRv}( zj0M>(_D_A(;*g;@vKw=;Ec_C`><^sRCr4f%i(i=MScAA6;K?;3M`&bP_1-rpTJfLp z8|qiil|omJBW2<4i;82`jGw|MfNMsjJmCHZIO{lT#)~wr8FyRh(J__Q8&auuW0y+r zJ_(@U=Rn;M9h#@>W);POT?X=Efn(!$bP>8-7NL4?@i=-qUr%IeWC=QNx9#~O+M}q_9Zu)hn|*sa#~<-{R`MXJnvIThB@jEm+iXy7wqYI zpB4-ed(Hp#*sR_5hl_|lJ!YFFN|rq-FZ3wJ6zTIF*a5Un*-t%O$rz7XoQok?pFQ*=wL<@!VCJmQpVPQx=EV^13Vme78C;^5 zAlDcy(bbiVrwYbcqFZ$7Hoa3_Jm3fsm)ll}Snz)dd z?ivGY)gSb+V~0~_8}$E$^3$~db2#LTKE`J)y!{0BxtO=bnyvv%8*Bujt8(JI)G5Xmpx-8 zK}@*5lbWlN6#_nmPO?HgjSobY5hzx&JgU;sx+u$yTk+>NY6}gIDaSAx{8dK$v zG`7*Px+F1yz#t$34G6!S?0louNKZ>l2AP| z9>k0YQ_Pfmj|E1t$3Eg-W{X%|M9#qKvX6SLBxg1YJLeKu2(!WSHLm15<8)VYKFdX( zS_^3?5i2>}a|t#(NMfo`7}?2gHJF)*~$lbAQ z7zQDUjqxVq!6*KsNF@si>TEn4qhO$tU+~ z5H#2UoFtPVrv$G?7DC)4&eoAmAk>HDJohuz= zdH*V0uhzL!mdVm+{Ux$AVg|L&opKb`xqsOg=yVnUh$2csk0_#ibe4xocsimK#Ae&T zBynsMpx{C4khBVNgl}Pa1({{VNMSHUWMH&G(g*%~#i-k*4_G?d2S|}LTBwPHS9Jk6 z2_J>Vl-=66lM7Y_P06Vt>OP|(7G)3ExCIY%rRvc~*`p{RJ?cmzpGZ)^cLY+BbfpNS z7$H=8kp19^&hQH0L=0HX{s&%+vHD@!t;h5z?||(>L0YN@&@`chmBj}HDmxznRq?<= z^=&a86ojhYdZttaAMh>7oL$?Fkzjv^+F;(%f{1fNSOPg!L54JCt!NP**qM+<8>-O= z$s-trSRK2J^)cWr-olP#asEK{QZHlxQ@99Q;JET($y;K{6Zv3yFQ_FQgaaTyIg%P#fNL?!U+8FSn~1tOs8G_% zVpjKG*Bo@)sjdVoIU^#${>-eF_cFereQ!1}bG=b!pFl}fP5mzXm>OiAJ8m9dLd{a& zge7o)Fk1somT%T#Jfb~R&y-cR2vp?MqPT;H!kbYQD4`%(4qRrz7_R37yeYK^K!?=$ z#%?2c!ZkwDWlAG+L4R+T0rVJi$?yg2J)G4_9%BZFFJz*j#fzDzVu&*|{6?ES6A9EM zHAA;T%_HGSJiHixbA}g;G42>XZJhDc@N#?@XZ(9Ol@?@YU%3JWIfKLJ;r$OI5q^01 zLi_@P#B7Wa98SPv7-4WmLoL6k8QB>nuds4#)!+kKNUl%}eE$ zswU}i_X*&jgG7Y?s$Aj+K64VBm&^`>Q_Be}KObwD#m)#OGp&7CBK?UxvZ^>>@sYfeUAqXRvEQT5znmUk!7j(< z9=?&cF~X6`SE)FaVmTT%WzzC>R=ky7t2Az67b{Rvqg1R^zqgxY(%~ztHvTz@iyCw}@{~btx8#hoN7Ne@Wy{pfq zbWs}4Js1cuEt~-7c5L`FpVDydNrHwmgB;NxQ0WSVyO_ZK!dHzjc_SWlhuzw$z8wy; zZ_Kv@hI>*PA=zUypppfIP23sD`>!ccEY8GlU=e_;A+R7@DZBRoU=d^Y{%WbRdyA$F zZ}JH`L5Q!^6^M-2x^WqW-wW|XyI%6{7K=k-u&5DNt%Q9p?1DuIr%u9Uuy3oS3&fly zi+r<+Q>k6_e_{@&^4!%NzVydfpYq++|={{)RJTK!El@cB1{zmkE$y>Qz@u z=bqXFO9*JeOFMh8$L-!e?;?_M&0gCB$p?19@Sf~}dsKfj#-p9Oq8F2X4o7grf2*5) z8TeDlu-6pPuu7{HC=LPwuE0C!9e0TgI?I~!G^Mbz`AKWj+GGf1O zfz-1G&-t z|F{3Or)DX8y5DGq5_9Kr=pcuH4Z`aW>l+vA0T+fDCH6(EXJQ5eu@DVxZP6A(mOb z$B)I5v!bEBY(=<^-lofxk(tR(pc=!nXQS#Y-k@?*IctI5^%?RL@RAVRo z;VU{k{)!JeVraaQG=3+$q9buK{EuQQZC$RVlHn>=WkmehSAu;z9_DoIU62bd{v)ck z_i5_`K|(%`p6orJXKD*D9`cOWaH7X=Z1Nkg>gH)^MCm?$^PKqB0{Z~e=0?__&9|T6 ziyr+H!$3Eh9GkD|(|FyQpHf1et!4ND`w?U=YMMDgIwIMkvB{^D+Gzly@AYj~)lA%?sl#E9yN(Ifj(bt%)AL@vVRBraFJ##+Bd3JCz_>T_GW7X{iDc;DNC=Bq(&&0p=JS4bdAu_UfMPdc!Ap?C^V<{0(1t z5h;&0+7kd#=#Z+)2#SOOLiliI_2htwHiRqDGkV1@-YLH9SOkDOdn{;^(lq+Ivfg&<)M0{MJgJ=+|W37I_085}5wzm?ghR?m|5ZKyQ6hcN6l8PkZd-#T` zgjcn$WMn66L!8dZ0y5=yfO3B0gMgN^l^vBvVs~baxhcwI5=>c$>a(?)S!@M4CEJjn z;IaCEH67gYwSVhdd6`X9lKj>v;g-?X{_S}lTexK`5X&>J{3hN70kH#iQDE88s+uko z05q3r0-o=sk)Ex7&uTwLOTgGh&%mFOdS)7onZQXt{OxxVt5Hpxo5E&GQ!#=_@GR$< z?7a8k88DB>l$ICzR0~#u!2%{&Ls+l%gn;oyERP3Mn4fT1KF>@^ky4kUlx_x6inW@T z>zScu6LFBdXsW8q9ex;#ph+-=bS=U_)r|*I7J$~;%qPLBMHL+w9&=L)@pZ}|YDB5o zbq?1LL$0`1omol`!{2?%ju)05mMu&U*+u4WJszwtS*5h!c&l{~*k+rg-25B@_WFa! z{W7D-uGtEth5H}JXWeMzOc5}aq!a@e84N519a#rM&rK=7TP00h*=pw<%n>1Mg7)~F z&h|N-?VF!ckMf%Dfs|%G6KCNz9X#&dD35zTmB)iW@`17~Do;O5=1jJ!0)(syi7S-rzpTk zK#qZHFnid3XZAQt4kfTCdXLNvUVR%=i7-pRnz~!rpl7b0a)PQHQwpR5j}bO}gkRt3 zo+g*Zn+Ae~1w-h{l(t=DBBjJWA|>{5>ej8%ee}$RTX4Poaz)iI9o5bn_$^xSI{J7u zbp!kM2y8TgBUU$pZqTrzEL#|Ei#@^{nwz) z#=@wBIgD2)za0)D5v6NS!X!&N)Dd(_$jh8Q0$wcJF)r+H4(vR-KSf$b_bd2{(Y;Ik zsNxU*|3|Xl?5`W5LHmv2esdh=lyfo7T!>MbR@78ZLV?!3Y8-*g@3hdNXq(#|747?J zvyZ_GOZOBRvYZehts8+xz-%D*bmb4Q#{tyF4k(0JZWQT(xlcDvDKhY+@_dfZlU^Ub zatm#`ozyCK+^TVm+?X+yl;@PGTNeY&+xphGsbC%Jw{lW|!&?|8&2Vx8pKbPg+GOS& zh<<_<8oHC;2yEp1si62ROiRRfp^(9?uJa5^wd*)P>Jhc;ODI}wrS1vqo<`~rv?)J_ z!;%5$wLOmzP{0f2E5}PAscLqI$Z3A-sT9SzZ|lapkzw*bc8u=%60LYu{R~G2%_vl+ zqcUg6_>JI3QZ)j*D6>hcc#JC_pqjaoB4tFp{UI70vB!Mrm1k*=Hav%9=!whyu_QDhG7_!>Miek&JaM;ZaiP?X(tP9aum?`bvXOaen_72A-9>#cxU`eA&)%=r*!jPe$d(xb^U zX6_hv?jxPvi!!f4>$Jx~s9LqQ+)Qfa^5pByJ_`~K+voAsU?#{4#X#dMwWFOXJ`G^Gi z752R>4COBNy@SyoqbKR+)M91Xo3O4JyioR~nCr-xH0zNh5wqUmNJL3cG%wb^cRV?W z_PsEEDEr<8WCCT}3vU2t&a^tfZn5hkAqw{OFzJ%a*TV7Y93?U(}Fzd0s|lHd!!AFdt15~_YkM7 zgZi5}s#rI(dilSEW!uZ%4Z~YR+3{Y(57&+-2i#4ut1qz#$atPMH89${k^iov7s#u}$ zkIp~&{8P}FlTygCSFi*#Uv=uKcz5-wh@q2`oCWy0i7Z%ZiFuI;6m)EiNE9$Jjlu{C z0CyIbNT8CSm@Z&pO-a*+Jdl#_WJ~53uAf*2T^3ByCE)-iz(?|=)7k>rnBT3NJ-|>9c>6i40-{q z1%^z3S$7Bs11fQ0z}{ebAxk0M;-u#}Y1Mm*ko-PR$~nEAI$zlxQXfdEmvRp{Sk3Q( zl_DL>vsE52X-<^b1?81E_KdQMD|MXh^ zx>0<55LIxQ5>6tErKD+P#GBuaZa{00wAMOMyJ6B1&Pw0r3GiAN=U*!X>jB~B?Ak0r`5pU4PXgY~7cO$&ZnF#I?b!J2 zT0z%%*KWr-O}$B(9%rng^nys_%LkuA=s(m zPSU9O)>3;F4kN~@{VyObYBK$WqPqaF2ZCI_0Q+P@N&&@6araeNP73sIF2jN;LaE47 z%x~=DZyFU3RAYCUZigCn=;yM%fBBX`DcL5N#>ag+wZ>#kc&C9=$(rh<2M^uBb zJ95sX(Prt6cZXHpdbPM&e%R{`^Mw4SV{rSJ~l4^U9N?3?#6iC@Dw#=7oy3TA8YCuOpWd=aKC~DLl(DK2O6bokoS%? zC0qvTM*kvXT%o^Wl+WWX^kTO+1ICU#a|Eb~oq3VVTJQ;K)y6Mc%_W7fr~Lr4|2dPm z&#BG46GNe>Vh6TOo=RB=JaueYP%ci<+>MhIq~cmLn5pz2{N1PQ2-yfp5pKo0r}c1X z86FD`R0=xi^URX^9Wc=L4lwhtB%9lT;l# zYYCE=GNePr%%W&$eC6M|ah!ExG8p_@j1L%3#uodX&63;EQpuL*X~7^FHOX)0e2acm zv!zo)MilIKb{=xW>Uq;7*|V4zaQ7=Fyk4vurxh8a3j^G#;Ct*;v|uYDgo&bkpoYW0*Ofd~ifcMwwHJfkf}K(+mh4sfQw(MHY|Y$O0RYb-7K0TUf@3nbiS z#UkY>;fz*&>jvF}S)gk}HUwbxJh2Kd8Re{CV) zYqKw77Gr8TM$CZ9%Ifuu@-5ZJ2-I-k+KGs z!^GcD_pGyL;V3zVlYJ+iI2Ujt&IL%8%w{dvf;!W;MD9=Rn+8Q7pKkj>U0iEXXd zppv)l1l|(%C=lJgO^TXE^3k&^QgBPG8^+aX;MJ*MH?zPe}Yj3#zOcqK-SeaQ02vAFX@a7_fY zytiZYsU?Dya5;tme6fjXF=PPXR(R1jQj0xKK}I}wZ~5jby08oK&L@Re~~zl+w15Ha)C=!na2Z^H5X z@b{>+XkIV=pOCs%UW<=xxf{WzOT5Qef8c8i?U^58@_^?_kN(sy zy)%2FSHu{NAD!g+jIV0xGi5!MQcfv?_*F;(M@&W(?3Kl2;?M#LFVp)hQzF|ZKwjd2 z7~eaJ6OQB)kh4?*Ab#UN{EX`t`N_j2oXXXK_AD+WnnL+_@Sl=N4_t#<)Ip--0g{Jt zzrW2Lq|bEhPq_=!9_7@ahWNE8(HK&hoiGN&1qe9%BcOrXT*t%**J`29*$$&R9$+g& zKEXG45o>PDT?`^1jJ;Jwyt8Xt@Yf#pBdRb*`app;Ag!BYQgy?!`7?8k7DUuHkOTWW zXLj6$7R}BY3}bS;GO^Z=MB}%8#Al_@at4G^|mEvcif+Xa?V7 zv&O2M209kaD?$`Lpy+{}M1mIJhY`Asls^5u{_0(VPw6GAEfR0nh(7nXc(bsZ1|ZS9c=5jfP$szP*gqnZfN!hLVjLqAalDm3-_yn zQnx1|w8LGPu88Y!oQkC{WWY)p?gVA*gEH3-?}NSS;q4%GQAhnUJmR$6HATi;c?GBb zJBhS){-W&KHg?CO-&0R*>X*W|Rcvf_?G}7sV+?-FF|He`SB&&|u-r&P1xpklb16H# zypA2N?&Ce=YO)x(<3O2g&VppWKwio5H8K)vNH`@jv1I<@a-EhL;kp!*P+SLsF=TYZ zC&}=A^ZEDvFmK`(GnZV4x72r56AfAW19d|Ru7V0Gj7@CmcTcxT2^C(>Q&vu3#m1#?pIo+-OVZ`aa*0i zfp+61I9=L}pH78tyIBJ5cJo++3Y>Q1jlJACSj8*;6pdRFaovO7qGxmRp2yyv>#>ef2-WtD)(eOUKq;Usmwi_ zA5E*BSejLF;7z*?XPG0+3)H|@+3|3s$pVW-^wXZ_qbJG%*zMDJs69Ps0Wtd_Q7`iI zWJAhw%X947#sA!iV>D`DcAY(YnGdCWd7YGz^Eedjc1J^xh{R05=;$8jSA`GmT_!&I zyTUUNf(cJe2nYa)_Yoapnt)N})`0OS>WTBqg+;vkJ@2S5qEgil52O%uQcR1y zPW`4UDACnIX2TSB)XdoG%Iz<>q*2;J4zoIHf=`M;(zOAZ>^Y<2DfNCLVnz!uya3*9 z2w8=DFXTlFRRNnZtoB`jI8ZvUh55o~KYpKDQ_c{4Mq$ow)@HqY5Rsc2yQahy$0x;U zq05kMv~~DN@FIz)0p|V_fSl>AAZa)&-G_OdZozenP}SLU;2=%q z6W^Z#(xWZBn9)E0dPOF3O_!JbPSU-EQjvjt4@O?qvOa6VNyz~djR}>r2XQ6GSSxsv zEU{(J!?M_w_Ka0H7j4wFlY$6xfmfKwZFyLQ(3-MqrvX3vB^;~dsTD2sHwa#wE?tao zIIfJqF-$E~sbr*Z@HNDO42&#~s&aW$&yzgWK9(3#1#2%JRvT&pvxZc~>S2U3h%xR< z{kw#V$Wp1}r^?*X0(2m;Z9JUWjEus6n9Zc1A=)kS#DkQQ2W^$_RV-|O0BsHt0+z}< zbE%NTp+?2Y6s|&z@LjCP^y-(%=uqKq1kDJ3$9fpYCM5Wo-s6!mV9EfYFFUU6`tvw` zf4fz87Wv1jG_^*=g&!6W(@)58d_!EwzJ4HgReH z7HN)z;5VqpfdV3W8_1>{ej)j-8w5_<782nUWFgAgS%3fHol7qbqwX< zX@WjRgoA#Xi~gyd(SP>a9?)kopugpW9?-89Gy-V{0R0E?+d==JFwg1PD?caIFqWKZ zmmw`wYa|aV+LLPk?5SE7L$!Bxt9DMp#i*$=kOq$i?}%uniQ#o9Vqf+&HO(%vDR`y$ zO;?k8l}_(?SHS@3(s16*Lc<|lX*lm4(6B$ywYPl6#w0}=^A*z4n6aH3bLTfb(2&K@ zm}iIdK*MT5Lp5gxemgWwf?JYc%9Ds~Jc-C@OmXc}$`Yt9iW#c-(!Tc)24vwIkEtYW za32w4OUaDEdNE=(T?2b6B%R`#@2CxadF+dv>BYeA6nEHnu7-Mg!~$k z2P?_LxBk+B{QyiUD>h@UXbk<;$v|S-H_l_IjH=DZ9R0$g*h)ry_6KlT!*{+x1S4jn z%x6)^e2((7;riUi_ruxRUH$sNqMsbV8le*~=|#jr#Y=nMUjWUhUfdlF+-Q>YLR{sU*04#P;C{5BC z2kbxxwxNPazk2*S9-*cdAH6=c~?J{c zy?EkaS$wUEf#qL54$J!nIL4cj9pc)@QpT`&OzC}Kt)P6sgKr}(ldBBs`Juta4MwWH zxLLryS&UT;vY|kq^Q&EqKEedbfP4@toT!YYoDp>c8xl)7Ap$EIIaU>0Yrp#r7ZM@P z!6?BwxR-gl$C=r0Y!6r>O3>k|D+Gp+MjRM<#96^bEixB`>it^oO5IG-O&!V+T*20X zBnN6Ww_$r|n^vLZ$|l|<6*>t74==zU_x!yaP}PEW@>R{~Li+ZCZ;MVJH)+ZzbU3&P zM^j!e(TxviYbm`TV2#}AH@@>%eAK`6pa9(x#$)Hc5&nn%t#1T8*tYrrJDmL;2| z-jCLFqx)XjJ-T%f5pZ|bjeO`;1wI^tfHSVKhJEJGtk-5v*&jxY zkNQ9BdGq7!_z7NiFG)=@h2DV@o@cz_e*;I4>e$n_Og+_aF@b7-`D}> z+z;uTBRtykqdQpEQ+YO4PAmJM`kkn*>Y^Myfc@U;8Qo+kc#&6Dd?PvI?5>AwP7FsfkSPFA?%~^LJ4@1 zQ^dp%Z&FOC)j&mEGeBdlF_kfP!vBkwZozx|Q@jXYf=`@!1bxx2U329&+Ys<`*=YbT z{fepQo{=DmQ-h-#Dvn{8L}2Z*nejcDV#-nEFU2^fh*=${)XXX}G`}B=XC`BNM+Nvsfu*A$izci8O|YHgOR`eqFc&?#FK-Gt1*43}#db zgTc+V@=DPyLaE>so|R*;$)(9i*w4N1P>rJdStbzwFgDj3=CWdQ%|)V1uS|RcRCc-p z4Z6(Dj*gVBxCk%G>$uibw$xY8b#$H}{Jo)+{7=%1O~%!$3MBRyS2t<! zJjoK4cO&0GLBerCr674wldB*F?9U!nHpxeEhO4o^CtQsus(VmXC-uxMcI&B()N|P} zpo7w&rZ6jYpgb`d1Y%0AA%>y@jg6FC^-~1HqmWhY0@kA03>wi9ILgk!QO126KxD3v z95<+@ELhLjZ3p1J>cCO)XxxZ#tXs{VruQ1A#0ati3tSpO&@+y`ql<~-R193dc_dmI z%txWnn}|32#8>;rl?}$y@E1GYK8GoB?#%uP`{r|GTF#nNhm9x>6>@#f zoH{j>s=nh(aIr|!FQC`hKfp{@z60xc+{@0;SXV&EGzvpw2V1HtuVYn;lPX9R&R!P* zH=TXttyiz4cmG}FHuj0nq_H4t`BCiM+!eavtr7}<*U>yD#A;f{p!l}2Lg${A({Fz# zM%*maj*Od;F?Km!uT=>Y2ieL{3p*7nS3aC>w7lbS$skgq3t8Ul`zezFsQ{A!9*)c< zdH9dzIUJ$cah`_B01wHdhz6M}65z>exiRAeNtX#sR9=u(M1}&vXgZR_)9EFaM+)bM zB1;u5SR}MmT9R7JH}Mz|u*g=iD#FLLDX~hp?p(;;qeedz=1B*7cCC83-9AEIR$-AV z-%zEqU)GNw%t7zv38M;!X5i8cwJiDRlUNR|K_0lCArAsGBJ##}cK^)@us3f41o!C4 zw@jGc>jfOxB7+zzgCP*Lora?=*|mjek{tpBbfPZy>$-eCtf~TeVE z8CqS52PWoud<+S3j-z==r8w0gwOFOfk%GrX>BX?NlpfCm8b?b+bglsU)q`shPx2HO zc{=(GMS?e#fur9movs_WKUeaWGw%&{|7c#^<&H$_MKQe?ZPS05&t;GE-B$b)ZxIns z5KgrO)6^=Wh*ofFfvt-Z4ujc}zm27icB0m`!etK4&IKe0o@NQYQn#58)Fux{EfTVw zAla72v&)TYA0AkksHzZ=k5%D*Cp@@JNy1k|B2Se^RR$hv6tV9pU1Ep)yfMy@hZhXK zbI8A{UdfRE?4|a&} zbkv-;Vz|s;0uwawWY-)Q+fSTY*=D;Sw#0?11f{+i7yjZa4j*@9t97v(7FYBTE8#Z3 zSiep|GHU%QdlV{-2QmEY0T0lm;t1y8)p+F?qAJy}wY;}(C&J1cm0KvLlzs53lQ}9E zCf)E$;2itKf2x`CCg@9?DX*qV^%!1;mvgW10q67msHkyNyeE7Qk(lMYy#*@=d3z&% zV6*9hc2+IX|NgQFJ|h$H?%58ZU%+_N9>;fn$|njuD6q&*_9LfJJSpwin-t@Z^8C@# z0B?V>r!j*HLl|tLhsiQp0b7fg(Ja<_#WL`8Th46qXjFYuA8^DU*~-xWq2|}| zgx-kbR=;R$}@e`owY_We%ynbrcfcSSAr5Vaw1$UN!vj^(YRt*F8cV(7}v zUj0ovk7xT30M-V$_TfDm|75d=|Mk1^hYa<}(-5;eb=T~+r}I7WoBZML2aodkK<#(x zPe&h_{Cn|-BXB02MtMwu$lj!ox z!Z+iUxNPCrW^8rvAHF~xUeDr5kSzBkNWi=~9`R0b#~bHNGhM<=nD}iU76aTK$OJdJ zBpZ(j7-cqK&@lp}&1)g1ZT1%fsPDop-kgpD1^b^43lv{J%+elgJK-%%>r+Q)HK*tm z(`{ZPw4^E0(#`gBNN`_Ft7$}gvgoUT1No?@g~ksR1I13~0%sz85@adBy~?bIxCW6$ z0c{c=6>wb;oqJ;Y&mlrGPSk-A@O1xixQYRs>{Qmp|EgR5RbBGCMDFBoHO&VC?)M^E z7WKEHbqPC!SR`N@j9%JDpU!n~Xft*Tuz(IQCB{W@P{u92cpvew+RVMhsFyas(PF9m zv0DfMdnS1BAATmmeB}JBXFmK>UfNq&i}KR`0w7^^m%#GM*0b-M?rObb6MiZ4>nfCJ z45`eH`_(-H1g0H2W=FiV;a-X6RIdTp%22c5o4bfL$9QQIZ|o2QJNxcNP)B)bFJ@-t zrM-wh>7{)ctwu3k+J3f?UfMMGikEg6KcamMec;#mYd?G4(idb8Iwwg%2!){fotck77E1r zYG<(dj<5DNV0qV9`){3mwJ#Qn+U-gHYqmOg?%Gv5gN^Czs?ArBS38NXxN7qj348mh zSXXVfD!cYne4wlL2mIFERa*d=OWEt?b?kLz6uvQ&db4~zv@hF!aAbBJ_v)#gC#>hUB4F6yeyW&)S>!^PTD^_nd0 z`4ao|v;PkBO9{;{N6d+F)gGi=wVA0h6o)kwM)OxT@OF$n1!KibK>sKh9hglSKMJ0Q znP4W`Zog*q?5ZscV1$a%7QjrsPpsV~Ds{HhAmA{>SuKSke#YY`|G}Om1rc$cIN_2c zH;*-F5DQxtaj}oO&T-Y2foFsk3jC0aJ-B(R+J^1EHgDW$v7N`%=Aa=EBh5H!7D*FQ z&9DT%VNGK-*ez3a6ECDHRo%%j!6G$JrCtCTkrL80g|hGx{Lkj;_~}&Ww7Y;M;P2t) zu?9WO!j{Ft_K<7Qn~tkC-(wnCj)&Sodfyv$)lNm*>8hPth5m9}wNK6P~d1!XKu$;yOvm*RrSOvH%BZ)oAEnpA#z-`qtdOH z^*ls}*oWMXO~rvQnfyDkyc5r~2YooE{2C%tdjlPl)#&W%nB3F))Gc3LCuPK6o1@GA zHod37`g`6U(`q9=3!ajm7kU{&FTJ<3SUs)>UHt5hCx4~Cwwmvp@$%61E?ItyzxH9z z^wc@7`q1foSM4-7l-cW^Me9534=}GP{Q)5%VMO(QI8;=KWJ+_>yYDHk3c=YEdn$bI zs1UC1fJ@9E67?4;I=CgOAGIU8btw@amrg@1S|}0Ix!)X?f}NqYgzLDW{%I6Mwrsh+ zx7HQe*->rbL1uN-glnZ3<^b`MHL5ECH+v33anf5m$T`f|p*OT9Yc(6-m{^Xl2rTQ< z_uY=2jYUZLtw4&;{2egf4zEXB!_qP5!+R&hqL4oA zFmUHo*2N(O$L;h2yjz-qgcdv*h{1C^Jr0bIB;syKjx_9_!k1Tmr{aA&_Sr{U{Q$N; zO?e_<6Vlel0-)x;MyG(b)MuHl=$ z6^(syo=cmV%xt)Z9uN3}<49FQ5}F<_$+<&Igy2?|Bm+YcAIkEvfTh#lteHPOjnpTH zgcdW=6spIA|EO=*@m=e=Am63w!enZ5R(J_=o&_v#=0-JLF=cvBXGT-8n_TT_`;X?v z>!vVc1kDS0zQ{Aa(leK`YAwj!*gWI2IhSGm-`Js|;)SsUgsK%J5^>E4q0)pK5mt2s z-z#0QWu>R|YlvuS|1+1tMX$y2pZ*NDPx1_d;@rW+!QtG0j&wQqs3w%lEi(oO84VirQhXZKd|oIwYnE{AkG;1%ms zn}`8I(rP~Jk%i`wUsKg%NT>yJ0n&@TkDjuH7o?}SDUXyr@M{k|kVy{Qz55|hz?fj0 zu`Mmm$2ljFMInwR!q$VPPmuJRw7+_%tpj_)w`Qy>hs=2KL4FX1E{Jyu$<`adZN>;! zj~=zkW$iOqRy2sO3mii)`2S;q4&c?>IX?Qu+fP*j1%}_!CN-B0}kh zEHgqA-#O!IWR^Rw)~<9bAHkKRD*gbo%DCc`6Mh6d2QNG&qz1wYu%cwNwd544vZJk+ zPML@2SnG^a{4<)8AxNRvtDF-Ug!~zX| za1dKjBZw&x@V$LAKmtx1nE@vhkq>Yj4p~Ux)>6p|zuQRTXt%gsfhke@_|azClhlVe zxQ-%HwNq)HdO43Ttt!<O%%>QTU+Hln;mD12~tW9DK|Vz>-oPSW2URWt z6>O}yNZv1=N7EuSDO9}AqJWUkqA+?%BUG8~_VcO$b8;J=kJAxoHuN-569Mrsx){Do zER0}hTJUes^e}A|C}0F_$32D(QnG@5@6yia3+5s%P;yvGiCc)7!OOdg8EzM2v`;|5 z;@-qe1|6jhB`OccZ)dzGg?X&RK2+*Hns|$d^SO1uPCuYX-F5Y8qLYAs9$W3-WGH(8>ZN zWM@c&F5q!>Cu@uj65^n7hrhZweXcsWSC)p<~oFcHQs1N zM4tV4S5-R9MdJ&pFTpBSlymyIlcs#udZc~Pt;*|LsVZIaa-kVB;jEW=cp?X zy6ue;I=p|(D3JKYZ08bwxqrYNDfBMQJ{F8Y}FaJ-0 zHoJnp@^7~G_gmwE(`(RA-#}C2{mTE+ZmpYB7q6#^PX79Ox>!5=&i>-+ZkLI~-6 zilT4PmC(<5Oo8uWdq6lHJy7pAvGCB*1JTn87vr?TJ6Z+AAb4m~crc#W-%%H6{Xv~g zK)4C#Y{D0C&gZ3GTCj}y)foj?yBl#6ng-i&z|wI};d?)BLQ}DM1I7l~FPHNQE9Jbx zn*m;ihDd1Ja2eXWSP{}bV4e;7_ZBZXpJyEyFZn4tsaV-WC;YJdR@#bL#Q}sCbKuy6 zLCP-Upc4rpP)SLVCg>`W0%}qV;M#2hlGy{`vIws5jbA$EC|RK`w-U+lYJ{SW1CesZ z6H-U3lngRo2rD&ylac2Oovl*N7#*)tg=)l3QmGQ8Fq%t{LJbT=aRyvgtIMTnODpm4 zGgdqPotLeJZXmyyp{Xnchiw)zLlV6LOBw?7h3JP#wo$3$nDSXPiE6&8QSzw1K(bW# zI_3jLowGiAr$C(jOMr%sxIw4Wc$N$Cn#;hSe4-Y9QtK%rHyN+!$A?#<24U{EbKzV8 z4U&oj&@13$2w*5-YJc$~(y6&bd7jiFonaT^3O+c8r{Wd8-Lr9kQl>QY4}0A{gD-G> zE#-d1HysK4U^gdsaSl?snqzaVVXp6RxKQ9RR~r++mvGlS+|iL^%8E6-7T3Mv{bIgv zzPl4X|8>i|_&miIo$;wlW=*r*oZJ?hrE`g)xwWnJ(lx5y*u+;j7f=GG>ix-)GPsHLt_D9d;Iq8aTI8;3(&IkvC9Ir(H|ucBbImE@A~&t0R>&XDM2NwpF1j_P$b4 zk4H=FgU}anZZEqHj_oYcC~`?0+ff0$T=7@&!xN+gS7u>pF}+;BP_M8HM4V*xvXW@2$w(bZ)a`cb2!OcbwX6NLd>ouULcNrJFJwQkcE)8i{xe1 z7Z~Y?l;~`poy(Wzpj1KUO>x-{F-E*_=-CPnhw}Z92N8Z^T(&1X0b})<_!AkkXE0%U zmko9r{EZ+fq?GlBVuA1McaaZ~;DZQIR}YRM;Y+obkr|-Av$r3%qoeg?#O;SVmJ92{ zJ|daSx-{v7stkF!s|9w232-8<9219`OGS=L5Ui+GDNB_SB@)MmSn7O~Qk_QbWIx1q z+D}J1Z5Yiqh->mt)hy|)gHCm+bof%Yx4!53S=C$pnU%eTZeZ)|<3RRsK=cYVKpcAy z4ys9W5~_|6L(gSPB8oKh{(LA}-A7KaQP#fRLy2NVdmo33CLa@>A-2}XTF|xuHfArW z2}dAd0Iw1za#_|0S31-1bnuP$w*6`P%8kd*`!=w5l})NitP*O5ku|9dd&7$3G83Ce zcsO%R3{%?w%ak=NCt}r`(tPPrBP7$aqXVnK<*Y#MAK+q5Ym&fLE;y?(~FHTVhzH~d#PX4}u)$lbP1A|d#-999>)S2`v!@4>IK zoyx=d6NnxxX@ct`J)vw?pWKfn&C`HxUj(SMJtxC+8hE+-Zz3J3&&0QIJofeMQ=DA; zp|`}P+QY@*5zgDkkwxAv#1HHTeZ7tth@Ld>dbq)E6W2 zWiY?Igv|TA>9|SQhnM5k<9IayuQ*%>@WlextNLhEro5^jfrEp@9bJKt-^jn=b+^8? zw}O&?fsTqcvQz0dE{*H7D6+gCF#wy>D|g{H+IY|#(nfNaYGd9%0PA4-LYNlSGpqxt zp4oqvU;q~9s_1^0@|Z5`jRn|H?B zYab;0V7H8`*ELL7V|DwO1F2~(X9xk>;L_pg*)G59OOJIk7>`_-;s-n~Q#@Zn1aYpiDY7(id=_wqV^R|~+$*?cYl2Me-T01ox$z= zzs_lpZYK6h3$6GowN?A)NlLli2r!}`ze0GXHILk7RxQB8E&w4NY@*6t_!?+|uDtAe zzm`zRPWDH~9imXj%r{-~n-;^G+J%)wJ0w*bQZ?js36)JHrvYQ0l9!#!+nZ)nF82=K zQuxf%WARbrKi2;j7k}wwndrD<00NG?fbn{+vC40}o@3;ufCJ~@y?w<#Sh!Wx@i#mn zR>M&QfiA?DZH5-&9Y$joRBnF90xXRz8N5qa5*Jb9%xsx%j-cqc!M^%_Hr$agg-kH- zvFrlgO$sj?f-Pe&1GK^ z68r@o=1y2>0rM&9s6vn^=Kt^x;feSO9L@Yb?{xUkV&^Pg!|zG*TXuj3>qcH-lw|o1 zEZsUk->!I-OH_iv5_O$yx=Vf8O}VlV_HzkcA(TRIG$NnvBeEnbF2=AmB*M?|^|l3U z6KyW4P2VCdZ91G#BW-&2q3&&BA+)LH{jP0-jPYQ>1@4qKef}EDN}CSeMVp>kAJe8s zDG|&b+B7`_)?eK$OS4B1Q^hB3_E|{0%$^MYT_ls&zdt1xtjeZ$Dt~eYI7v5byaOfi zziJvp|AqJr(eHaiWCCf5MvX59a1+IGwFHUEe|{Lb`HW5pfuq{H+$?c1a8Nf(oB}FL zNTA45lD2j*B&`@Ziym<$?fS(LNjrpDDQSW5Pehn9dmZLb=Vpo5@iTy{4-gp?Awe1J zIk4|`-Ixe<3^z+Wf@m{~;H-rJ*{B|Ywdr5x5gJZO(ycSb!z+z9OH`bJ3x+~qLbQ0; zS?+gwIG7Nqwx7@ z0W&wt6$bx4NVZ;NJoprw56Qz07Q5qoT+%CJ*UfxOPnqkHd2#kj?9uEx{tcfd^>`l| zAf2hEln0RD4N8bX`Cr^c92PCpbJ>Gs1154rs?A=(fBWmXz_UrrKB zSBNsqfTvkWWIzf3O|xIFis8TIOjJaf^WoT!44U&+%b7F19iej&kD{E~Ucfk! zw;Op7wbPM_iU$${o@59*j}JBEqlQ8z(y;mM$&%RVt~=3B`#Su8we6v z$3UH(%dl@PY zcN)uq7=)z{qzxa1KfCo`&zojn*Qb}oU68=rw7X_V+t$SGB{4M1z z;opo6J#}G^>yr#l57G4r1rXOKIb4>Q7h_rRo)&DwOWhnQM?ycv?>ytRTw^sBFLnp) zR}uZOoe8cp&WYzR%`L{+&=Rf=Py}`46|1wMue`w(##L}qnLZ86i!FeM1%y)Jn?=P-~%6Yrd-We5thz6Y=yy z)Owavt1nV(Ay!UW@Mypf|3aEH`YLu;^vRF;u>sH=ZtzODmBGtPF$_iF5 zl}JHD<4aWtO7W}Bc!$_>{DeiZJO&*M|87^)|9M3xO`i)sW`+`x=D@=0CCy%4Pfed+ zph(`URxeT?yuX2WG)fpWsuG^BoiNwwDu%Z@4+&BAMWrGSbXKXFa`W-dX?%gRo`bg% z?ksD;`|OHeu%3GxzwBS&%O1O)`vGZTf^*f{ryQ9;k;kArbfpMqEl}je*Sk^#Yq+QB zqUx;Y{wBX8>$%=2@*Elzp~$Cd?b91Y3b3O41bl9vMReU2-I+*)*UOCU&Fj#}!{{XM zZTuOt_CY_OFJS%!{+Qb@!Dmd&1MlT@DlgHf3oZd%XYGUTmM%ed6mUZf){?+HdDDFX zbRj-P7eMF5MK!k?M8H-HpsVh6wXIjU6jC;^RAzM+KtB>|E`VyH3!s;Fxd3|jRJ8#5 znP|ETp!fPQ(Zd4hUF3>g0L|wyAk#bDt#)?j1<<^n7C={`p|Su%7-SSazZeT39IcfR zV?VO5=<(&K2bvK_3Y2Mg0hEuNg>KGUd&&vbyUXeavC@g*BRi!#)#~R#B%IaHbKiDe z{XC-5YW4F03puNwixb^WXqoSJ!Ug}2x;KH3s!0C86JaDOI1?4c3!|bWp1}i9SxJ<@ zAc@2S#RHA52Og_YL0J_ICZi1FTe4~tT_agnR=nepH6D@S5b%!IZaj&2b;KhoVg!-@ z_gnS)&6`QcfxG|z@6Si`rr+zXuIjF?uBxu?PISVP%<8v(*!kA_nLbfX?_#q=d{g40 zX*G+P;6M#lTu7}pHg&T3hd;x;NVJTXPsyz`J!507lx*!JHEkG$VC`trikNb(9npHb zvUXSu>@Tt_>WJo=uy*o9P<3eS>;bQRH>@4ODqB0;Z|&eF@}j(rSUY{zyUb3Gf{n3* zq}4*1vbD8PhTLkqYw(@knECow6m^HI?GH3m=tS=o0(zl5FD%>ov8wi+-c}Sy<4Fk`cw+ky8(68 zd8R)=($tv^ajB+G1izl4L8F$)IXk$P2w`IY3!mhm2W27)y!cL{sz2g42v}RoM?tQd zHiw`d-&qd*1)R^)>D@W}bA9l8Dg3*V;J-5sbld~e=3uN>xTa0sDR?(4Y)zZTF-$N? z>wGE}4Nl!$AV116kb{wf(TFYVn8fm1WoP;G$cE<9<(h6|ET?12KvtyHop$ z3}cqB-UtcZET28@ci2a#E$>LV#bQ7OQ0oeR*hfd~`TOjn4`AwdxVgF;^HMzd+7m9=n`NE{M#R3FmMz9=67Bv5l7D>MZkV89UisgZYpN+D2=2 z&rlP=VY{G0HF)qJ6dHcem6(4lp`G-In_GjoB?>U7{d?2ibwGmw^b=l zRltMCm(ZYz;+(iYZh2Buxl@ zhf&psXdoC_p=W^_zfmP!N4x6R>sX(x2^q5v30eHYouF7?MVS=4O8m)y=r#u%J!!S< zYrZJo0PAWc5t<8le(S1=m644Wakrn>Mqw^jrnHf3T}@gfRQTEm*420h+`87)A@bW= zS6#PJ;S;_#DkK^kZzGZi=6oF^#=L`W^(?Kok!V2>5`3e9Prs+y18qJQ2>pPcHqM{~ z^l2aIL#c(q^%V+0U7&speTS66;QAc;2n;S1f>?#Y#qMJ5p239;&e8}zyQ?{6;psFK zf`0(z>E-@_$hdmn$bi+{zL{zDz7uGHspeM8tghX4trUZ1bT16xS!3jogZuHbFv_jz zy72TF7M@mO%+(M-XMD&E&Qa5gXoq8CbnR(419M|n;Rd=xaI5(jTzkUV21B8D&_Qvm zv)@qMS%X{6Tg-sxfl4fpZmZ_0*I4GjQ3gY0_30U;3!{I^7&Y_U5V#A>{R+%6{KDGP zNm8W@Qw$Ry;FyqSMz37Ap)l56JRT|KBj?L*SQpKY;7G04NtYU%9kr5Uev$z+;ZTAVkzrD&{5Z7DN%Bv#@*X7mc zcwVwBlIGuNdG%=;s>-X|F)MlX1$S|dJQsyOV!>XudgYQ7|$XojNvFlqN?;Ty!Pp$%Yz~ zZYjVAQ}_qC7-X8NIEDLxqwequJcJM&>kODJ$ENq2sqq~gf9hhGeF0nydl5sNlK_z5 z!--_5XaSD05m}6%1HdfO7$H}?s3NyR^k?yr13{hxL7t<6JV)1aV^x$th;pbWRMq>; zUw?wL!sB79$%RDo;{|YlRjbOShOykqvWi%uSWi$)!7Wq(`CgREYFM3A!n@-TP2iGk z8==H)1mKt^v^qj^P66l?6J!X^n%HG%BATEUlNs=LNfD*ym{`S7h725YM8(iJo>O<^ zjQSBIt2)un&6rgbiaH$8zPN#KbOhoCE>4GDaBVu`6RP6U2q&fwfPZx&6yx+P{20I= z*@r-mqI`ap2dwBh57^nr3hjsCK9L$yDd7c&_yE)r8<`4+dH^1F zJOEoTtLt$4pyhj>IUMWX*H^xEgQaH8btn~y1NT>-^~!%@JOrpPK8vKCgok=HKkt=1 zC{golW|c`8O_RWO2Dc>Pk}&qtsG0Lb`@;Dxa}xt|4MYKS z;n_8wF02D{&AAwuHv_i07+~qBI|15u{!(l)Zb1;`1~b8Z{dY{`IDKj;w!4RT2*6*F z_IdH{>E(LyST}Sxj&882Ftt^40>8u)2;G6-Ku1!aj*~gn+t@Btou@c z&js+7L&g4=m2QkLjz4qLYOhgKxD%m(wUz+qu7vtYhkjZK}UbRao;>NB$Cj{Ov~Xe;54gD>fN_rGL}Q3;j)X zn~cAdpYuD*Pu%44MHwcx_3!XkIyK~<>?b#se$>C!@2a2ExS8|uMqGy7c zViW0yzn0@?0OGo=h7*+$kBQ)yP1x^q{V8y~^kBb_`UB0xk2bmer2PkMdU+i`A8vB} zG)v5!4$F7wZ*783>WfDSBZ=t>mdBEC>}t3-;nMt@Xv8~-u00#S$3|Z_S6;*(5@&0Z zxs(Zd$tJ_^fqn}SCXP?!17{fF!GF*y`GFqJCNmU)+^~iur{2d159cuKyXVJMgqQv? zP9R{}V8x+C0Q2w)PU5mb=4Ae)<7z}$H^@{<1vW60OtdsYb(;rmvmB<#Pp8V{!f)_j z?R5m|h{@)f4CKSo91qg!;FyL8Kl9dWC?gT(;qT?R0UQ5c@00-=<~h;;ST*a7 zsa8!owA@pZZb1o~*>#dhd(paKguEM_y*qx&rEKCPbB_Q;w`H?x1ZXdfjAngi9ehfR z)9xjhxpCSK<8NZuz*ja1zOv6;U)f*~(2PRII)@BqptcKw$5?;Ptva#5>mQREAo54l zj(BZoUW3JDcCk3M>gGf7#`>rpASdmqhyPI_FT@4Yh49$mCpHbq8ys4M7KKvEBh&89 z`js(|)ZytUetWXVqOImFB%pQ;rrL$B$t8`kI_VRqioF8vBoF1+H{!3k-~yB_{|!{v zAtySx8E?_SX@HFs^UyVba!9cpZnF~6kK<`-`7u--mps+Y%#%~qhzI#4F3E^u{fO)w z!ug%sh%Mbl!koYdtBH4n^kMXn8kFHr63&0%Ln(-J1~iYcc^qdl^1_k9!seD=Xu8z# z6|gMTnguM5gF(s+!72u9622ny5*U|Qg!eQsm%-5d>g^0+Ilw2$=83=2q~;qqIPVaX zx`9n;L!wuH==Swj!a4nakhIz@A~Qi?Ik0{qA9FVw~gL{~i1AXFYztaf`IEP=hrXlm(T9 z#QxzwSFAK5iMLI7@Pj8)gjdiG1Yq`QTT7=a+k~d7wT_|%ahx>4w+JAsWpwWl+LGG# zJYnSWyL|1pl9a&w2EgPq8_K8GO9;#KT9bYrv36Iu(wHc3AhG23&(C$g6~S}(84|KA z($dW8V}ad1Kzmw0s8AhM9zq=Vbn$}<^Th(cy^oAB6;_b_RSR7~Vgj!++kaL~;(4#` zxQ;F~-@YQRRaE7$mSjm5KmY4HNEsaC3^k`i79*R1FL3{%nyuan+ z{d*POy$5}(7-BDY=y}!2Y{;&sN4_e0B z?$@J)wmUkN@%|fztZBKQcIqShco8rw#649l?BWFaR+7Oq0;UzC7c*)LUcnY*2DO4) zvAEH47Lq{f5ErSvfz1GqoPi!Ts~_~R*&>;ol{l0=g`AVw#Nt@^=n~r=CrtMM)S`r+ z9OeE0Frw$994{2d&NUUcfiO(?#2a%BhaCE6)a(R(A*JtuRQf`sZw9!-+z7C=^1o*& zK}_ZoTJtMe<%RCIEyQE*eOn6`J(!Etakf~FCuf8jm)Yw?9cI_Cg^!D3QD(#O-S4C9- zqI;cs83$FINX;*wvBw>b$i$WevBq0Ul7e-*VSiJUo2EB6P5*Tp!IiOQNH$L_5-p}( zID9wbR~#xNW*W}y*+hQ;wJ9@T9_J3KZ;--EvXkXhC;o(4)kGfT2$Z+(!#3(_LTLvi z;o_+5!apJO%-^u3+c51xbymKtL1>Ws1}ee7=D^0axVV6dc6&gb+z00QTM$KnvL?Hb z3>XofiV9C*C{%V7rDOLGckKT#v&Z$&-Ycp#7rkL=Xn)H<7yD<*8iaId4`JNh0ILH= z@eT{=0-mdcnzqX~&3|L3QdhtG`@NR!+EdnD{ufO${CbCE`Sxpf+#hxzF8{|S`U6xc zH|DaA+M`2zb~&vY$BQ2tj%2Dmd#$LWYR{J9H|^Pfo=y&Q?b*3Z(4L*6_AD3NVHjob z0c8^p{sVFRp!V!}HDb?7&R89@`0Ux8Cjp+=v-}XO*q#VrUAz(VoZ7QD@u%OOEyL)7 zo-Yp^k%>XFG;$~k)BIyO%)Z+~560}vZ?I=4?1wtko^69Fj_IuxkB;nFezx{3^Ca1` z#dv}Ff^#*@O|vmBy}SrK%*ZCaJ2d408!O?o&Iv zZ#d7+?&nVNX7@!Xu{5HYU3*sAi|SY*K)Cj-Zh)}6jT83l0Aerp>`>Nc_U)tg>}Mpv zhTS)8)Sg|4Yzcc-K+vA;e;4qBzWfHM0V1~ow%?w;8)z6P6WI2r&ud{mOL$Yp`aKh*+JQX);kq*glvfzn+SOsi()xZ?HDv zEr8b|C;Bt4sTxhO990mCa2TvL@(?{Qh~+@$!QvnuR8LAJDO`qyk!BL$1ZMUUgVkti z9~|BfiikOe2M?$KK>j3+{0Bysf;f|1^VHisxFCpclx*|KS7Na86>!euLz{<92RuyK zT)cuws$a@jlm_d`_y%VY$--B@hQT@$DYqQp^P5R`XmAk{4Aw7LI@Z9Zv`YSv*WJ!M zOuqHcm$WMR_pGoQ`Dy1^9zE_tk4Lv>Rwpo$Z)mWt16Zr*PsZ*w2UbWM6L%CR($Guzll{YgVfBNt+wgWK9)#}+4dE5E zgEZrxTf|_M_ELkDoPbK>Fj)E48mwjCh`}ng!eCt(^R?d)8Q6l#Mh({DlZmx!u#RV< zyn)2hU_HcsqrrMG5=jQ@C9k4At--4GD-St=ahHn0D$ExP6ft8=rIBl}ili_nHY6FW zGH_wA{y|@>ESko`$+GC1kF6}a;%wW`Ei~)Ya2kygepzG<*2&-)HCRjV%ba|YM|hJt zi3yyl3&MQ{>vANM6+tyv+5S4HX|Oi4ta877`Ga`{FAR^b=&(@x*9Y=T?3>n==x<6a$i;ViAe#Wv zZ~wA}L^qU;n*8asQ(XfAa_Uzxs)czIjgZ;}0U~B(E z7|{O3D|ZHV?O&WS>)F39Qeyvl3e|Ktc|DL1vFA2lkq?1WtR9S+)kka=+Dt#f+Hv=qZZUk7L{mbboVgI_{ zp4FH%`|TmMe_0(TpJ{kE!)vi1)Ug-m<$ad`*t@L&n(Mgr|N2RoXsf2-TohF$gsz{vq z{Vg!2u8vo{kDnLtuwC!Tt^cP>68ZNsQCqW$)VAYqwME>=mJ`pq_8C!ipJD9j*(g(j!kGRj1@G89rVSOuR=xg;uDLY zE}>1ARUFARIyRF;^JU|GX2p?Bco)pP`A9C)S$%3LJBV~H!b^C||1D|Nrw-QYN;7XF z6ER{lxsjj<3n8o(OfY9QkphRrig<#MfgS*{BnP9thTn-TJ|IxJ4@^}AhMLf7?!F#I z!t^XySO*Ge1=-8`utmbrF$j2{-lY4n8_gj^Ms0&ub1)JbLyDOORR=D?C9R_W_d((U zCSd5{$gJxgr&j-OFDm~MO)JkmAo;Wr_hKW8#`Mrhwh?zf*K5S=v@f?FE2Hm@tUk4M?E;de=w6A# z!4=(KCs=eZr=h7O-(XfxQ7wX8FugN9MRf&~42%b~f8ab(esq=Q7z{fM?xU~L!L2H4 z(I23~eF7TH;*h5q+9R1VA@afQSzvcCL|}*y*!p(_*zHNc(g^G}0=p8xs1FX|q~Y{F z(uOzgF6n;buV?ANG(4W&pZQN3K^@K)_&D!yXhH-FfE|O8AmnUI z7XGB@6Uk>(%CKJ%^ab__2l)K$?ed4rZjMiJ>N0=a1aZ8(H-B}RKV%jl{joaf4js#%Y65APx7~#6{q>Mh3keBv#hHrkU6P3AHtC{+hj zGn8X^Cbc_;r^$?(g{dSqeKO9P;4wTl)@cJv(>FX7$M8UyQMBhj%h8TKC6I&s$=RDH z0q2FIkusnNf0{gST*}hOBX~it@F?WOXkTC44=5T4@uRl~WFBxBEoj4_ZtcJqT+rq^ zf(u&xUSN^xlQE?so}H(caw3@2>Yx9clIH)y=()p#k@rR1^H4noXZL$fg&V?_5b7lu zUig}oaRZLCGVZ!FJQ?=^2(X!5H*XE8=PcPsZ9w<-y(`*4lcx=Q3T@zhXdt=V*tax- zkQVrO$6#HSF-vl_o88}cyC_Xp;cUrWaJJ;9dbZ?12!Qdx97@4J4OtQhbnzZk0o-DN zoh=f#pk~nx21>-z-;hMR)+^t7*hon1b8+0IM%jJGX+8|N9FT~1+ECvXGbP^0)aMupWV9y*#_G(ESa4KGs< zX|h2!lQAoRP4WPw;it{{GmM`X>aKg1qFTyl2oF>xOqqprHh?C0SQ?9$Z8g244oZBK z7IIIYm19ul3?^tA;Ok$^iY7QH8U3}^48rN2<Ll+n!OLL~AtMZKKAo?si5np!Yy^kn ztNNNp1;_gL!IkQ+r^=3sYuPuVsBCtu?VD+*dws)tyitK8K7r~0ATwz=PxPD~GT-F; zX!#lmIiWh*D;&LoXHHYVsh3ftB~Z9q5Ldn^d)@NIjYYQZ)1zM9mm#aGuW(2oj=&_P<;@)yPgseF#0~(C(gxXg$7`JN!fBzF{QY9wx zs!aLUUBXAC3_YQGOym%#9tg8eGwZN%+&gSVhGqrtddA`lbHl+k6A!TCAAL{{RH(~S zp&kgRt4hp#fUJPnsUuoW?}1jEnTIq~lnnT00Kj1MY?I89=Dt;}W)ERze;Xc&3{Jk>@$ZfoSrzp=< zKWfRKH^pCRm+e{BAk<5HOg1M15LL@$5S`NR9wc`7@v5ZxOD2feIQ~}5xnMq>VL2m} zk_50$`4!z7H_jtm!h)OWsSoLMBgdrnujh)9A1UX_&*Ws{JUpn3<*>CC12@1~$SHpf z1jjG}zL9BmBVq9d8G@0mq>ZbIiZ0zfLJD=w=V#;)I&i9^&ZPCD?QAyiL~k;>1|^n8 zG_$K;<_Qo8`rhz#x!$HN7zyAVTAm0aXdBVa zz)__t<}l!2ja*PMhpCDw5U66l!I0-zp#Cuu13U^*xu}@KM8%wdBvj1dP%-nMHMMFi ze6ut{UI<;&il5k9&Nn!30&fZskv~t`)R}-_`A^LHx!hANy^5}w%*Mp=*!Ve3f0lqv z(nsNB93~^T0Zq8BpqIKF<0!1EG<-H^;ri&J=$A#&)hN#o&}2*HA?xc1daqyj|F85GOpY89aU}7dyFAWu;w_J_7b72h=>(2nC938$ z{M;bZrr?C)q!NG#sgz*&(-BrKLlSRi;=vEz7?2H?Tn@!sbLz{YeKH5y3d2bcqOi7C z13y3k#nAZ0oPo$F3|y9@5<2%{Ss_OXdMG{8C`@cpRncjxik6xKhI6iPSIGBg0vi}m zfCj-f_8Ya)OeAz!j|V}5X9^#YA+ASTX#J69esTgi#neP2Mlo7Zsg_DQbD+}x}JML&Z%$CuhQ)6NfO8AGFyFxy_O-P=E^T39A zBsZDEnTSOUkn^pLQ|KnGjk{cDk6^c~B}`f{WDOdP#In&o8tOtXiW>7F5{1rw7hbT~G?UO)MLnog$#K6*NL^1elB+3AIt!>?j;<(Tpn4m%BFOes;Gnoxp-cq)x{s zu8>4d{6~%^P5cKL-^>TDmBkx;$hLW$qGB*O^P4{}1JlN)Lkbj3OdjcG;I(OKvASmD zvh9@v>mO{RhZ0AH`5_^)5MjgWE$_RK=U(9B|EVIg8zv)LUd0+YW#Nqn$xaY-uc3!w zQml})8Y3f~x5mgXd6uufqaatldWu<{z(;&TBXthIj*aylB&RF{4ka5^Q%1M^3x9wi zj;hgC&nareFpk5T9_F*AKubZuXEBCPnC`}3pK zB_tX9XiCU=YLk8cg4i}+;gzc}s7>}gmfKrS&?Y+6MT+Y`J9^~o=3(>G)gCrmCX@5& z{C4C_WE%@PA0KJk<3t*qO3vdb;U`Ds?qvm-hF&n#XodwTQdu zpQ2I|n`_C~g!lrXUg8UcaYq7{x89QA6`zBk3w%V;Gy{(IEn_6WbV5!~0@v#HZT;W@0=>a_`Pbp_Ij_RmA)Gl}LQ)~-zpm=upWF%>a zBR0ajPqBnqmU;In9`Sd~BhiQvuYcfNrf3l#1Qki%MI!@fjyw90h}(W6kqf4929#~Y zL)5|!UjHkmnR*VUjv^m|k?lBKV+~r%ZkUbx-mB9WC%y}CM6}N1dk?qs)-IgEbl&orK=em5M}tW+aOQ$d!skwKPAPF%uz5oFB^?_)+guJjKGU z5M+6u;_dQE6|bx7EtX}&ES4v6SFU1tkzffeA#c{y+F6qxhw1YKI*yg(*AHcQ))D); z*C-y!YZQ00{_+QFq{-D^K932m;qxZN`oC_be(DwnI6^HQNJ1Qkt(ae*OPF8Mi5Bbs zf)rCccgAVi+L}#6EuOEB@bJ8qS)IW1e1kaCz0nZTD~O8S32kWJg*VN-fInW;K?4)G z;uf`?MWzbQqxa#!&M(s|Mi!GDg${%w|iDD_27^4xvIx1MB1LU|Koi8?e-{Y74DaU>C zr5*lkGE?(!KR&KJ;8r7Dy%leeCllJmOWgo(29`^*;2~QiLO|lB8r_JMxOn9Dq#fb} zg4N(1y?tT?e^O9n>&<)3a2+(`TNcK17QR6|v>Ytrf#FggLj{4GjD>p`E_Jq8KsH?J zW7g`%f*ge{n5ylz9&GviqfO^EGNgHM>BI$V^Dy|lOZUEjyzoa$Wif_37TChp+6PtnqNPGe!d(TmdU^)pF0d4e zs5C}Ph3{u8<2JYQ#Wq0A zs}qRu4eiP)pxG{dKDsxK)|61H8}Z8@N_8#L!az5$9l;$4r8??BFm-AuRndj`o(QF? zKAj^*L#b}SZ(k@Cyn6t{7ST|uW^>%BHk4|A>8+GdDy$mwR>`<&*^5BMiPUNg5=o&{ zd;U!rTE*}{CNz}l;F~c#e4$i(YFb07&X9cCfbX*b#ri54VjJ+^G~~1a4`$YmL#aFr z$M`WEjYNl`RO!A-KgULB751xgZKVq@_A34NT{M9)DWPouKCxuQe`{@!|bp6b~}EFi*#?C?^86p zi2uQUmiQu{X(!n`#V2Gg?E!qoQ(YQlOL_5Byo1?VHBw!Yy_HPA`E+Z}fj)aK`8%XQ z+`Ms%PciR;UJBZ|J<8u~X5$JXJK?Jl-WfhwX8d`n?cY`WauMQ}?Y(A|BE=3DcVY#8 zbD?bi-PwY!1AK2LAjT`gn1#@;xrpl-jxdota5{u^QLY}3|I0b#!l`-S#N2TD_j0vZ zo2g^r^xqIkl^3iygjG3#VO1RHJfS>r4u5i9XB;$c%7aHx_o!PhE$jsggnfx>R z=rc)f;6PjJ&dD?TAf4F_W_B#&*^6^=SluPqk?EV+GZ4Iq3tC-|Ys$?2#5`|iM>HG( z*_l0#nH}f$b~LkB-{!W%+d@|VM2A#h}d7c+g%+4vsA z71j`sn2nw~k$kNa{hj#jo9L;5W{67~MtPC(}6RmDh!Sl*b{_!tiCZ(6+w1ety9AylLuo@*Tu=xD~?382tz!nEF-IkaU zfW)aj%bV)A$1h)GbPNiyOz0sDE+~lZ;Bo>#T$re#62F1&V9eYQU~!$$&q57etkEy# zC;IG8Gwz8u9#~w znM_bA$b(BD+^y)DrqeOIZ7h1mA-wj{GkqOO2^tfD!}!xi&xBBAtEeCPu+cN$fEGrb zupCC-yL)ovNdyA4hh+v*jFyRBH-Y7*I6!n1J+mC|ZS)NDbTfK}S-TTG!xfugge#m_ zYh+!zWOykl)Fnd*0HNd6xmMg?y{8qy&56nM4=AxTqM6n13T!4oAi91=po6QT{3vETzR;UV4DcA;hpyB7S%o7*6)y!NU1 zS}?89k<~#=&7};|dG^}&AvBR16!W5JBsu^b1HZ~eKov{p@;S$492-PKHZr@^uIH*? z39#B&_zd%Thkonf{W*Gge<)JJayb#|EPzt0sXRQMOmFGO#@u-Hj9Cb;n_sti8fh6> zv!vG)r|iJhPW1PPQBKFiil%2qzhwwFYNplCqZn2;OHnUsgT!f=cnvk@@`B^9`;x{?gY zyMb)*i^PrzlV%r$7-NwY%m$Eb0O>s7MMt0$*snnXUevc6cpXy73gC@hEFxXo!EHfK z4M@l00?9Oy`)GXB@fF6+d_Izfrx^s+wr%1@bVFgT$Rdy9FeMhi*)n{1_luP9n+t#5 zXdeg1VJ@LE89{pGOIgE2WY*=Fmv3_A)q0NCSpWAWttQQ|JFOzTdRNOqUr`<_2R+8D zPN0czXwobp2C<;Tz@%>j-TEuMa3$pc^o#ZYq7L-3j+7{)v9VDjv&SAWYGl^bzLck# zbJ#|sF$Hd|E>{`o3}50%N34uzppC}KMIl_Z;x<%Fgsh*&FK%>cq@f8K*GO7vJYkNd zG33#>d4k4Qm=*f-9zx^Pt$~fuw>vgGN~FyDK3FeQTUbkWvF&x){Bt4>#Ph^yKT zGh6`J$iU%_+WM^_PU7Rd)`Rl{jY@6z@hD+4izTsU$cC0Pq41mO|M4t|-tKuJ>=P&O z;RtfB8WZuqiFW<~Ub#Ag+7ofKPRsesAM4-SN0p%*ksZL_?rf=AaDZ4=Dt$*bD^T2&iTP0Mg89h54{_G;YQC5D`wf2hY@gW( zL7?p;b^3#7(txTrfCFYyqOEVlZ|GyY9xkYG+^R8jB2e*+q4&(80(ogfL_2Jv>VpVE zY@!EO!Y1k{h$gwxD}0q^YNaLlDV45B*B~0sdFW)hlo#uJm28p4f0b9w_>@@R6LYI& z8y=O?{(jZe{RSE^ZXUc&je?%X+p8w!MicaUm(eu(Dmm;wVeTdQGgJ46gdMrp#vJpA z8d*KX2jwLW=Bwiv)}SLZ8IhFpLsQygRyC2gQ0@binbm{>k8H=N_|_lg{K6D?mY%C0 zWm(HoBFr6%l}Rnd`x$>RN*(B^<`U7NQ(Qh$^4lK+T}-LrUE}s z1`x!Hmh!PIG7S&@Lu>Pc?rO;^61!Y-#_E{Gx2xqR!V|lkAA%J}5CJDJWjZIjNSdG% zxQ8j4<|D(1kmS2)q&38h`alXsH>kP9js@Phmec@|S2NKQFCA@fe|QH;UiSg0c!Z&d z&2)k!+-vMnd=-pr~W^73aVX#f2i$JdsKu!@~Fi*8!`>>7A^P=XLU1j#DZR!38-9 z3pum+$Z;Cs4*{3P=@vnpl*6&Cn4BD^QA3=vwP>~i!ey_7hfsfD7|e7A$fF8Qc49?z z0AV;^c8pb|7+jBE%NHaZTM`(Snv_(_&E(f1sB-NY<1Yo@(^5tnG+`mGm9spSQvSSBQIMTaNTy#1N(vO zVm^|a3z9>>Rgy!o-H60QBQ;Dpfmi6Ifgcg6df_OzK=Hd}7^NRJJK;B9>rtYmNO^mu zw#b!|A|{={p8w)@sYnKkIf2&@tb&w?C2%x)28J5prEMY2f;?70hVX_QjR!08gsZS9 z85@PoURBE2!fLLxmeGMqLQ-V|n-1AkYX4&Pyc5KuQ%orkojga(jik)oB5iF@&+~neI9SxZ}bv1BB2IOGrmF=Z8c4%67>=(7IyHP zoy1EdgFhDd>MCT6sRVa7P>FOk-%n2R5{a;bm*{5EHE%7$nw?I56}|KbOzCzfkh?$IQf>9^ZWjA-y@UeD_)uGY zyVdDwEyszZND>$r(FlbERe5|Q%TGAPTF=UWbLWHBCP0<;b>FLeD0{?oWy*9nO|Rtel+vaa1u=U`t+&$Sn;YYpMb`G zFgM@19yg5(v{l#Pn@$f^K0v1fP@<4<#Z^S1le%-8BEyCn;KmPBfFt1%9P!PYs!_XVWiObHTd>qIyy>#<0@9Iqm~tem-&F7 zk1w+wmc+|OC>KDZS@aJT%aEK*L4&7%3o=mJy#@)h{|0zEKA3VWz$i3FVJFSa*$OmU zfT9YPM-^lRjyA%E+(atx>GT$NlVLJ6%4rs)_>9Snz!DVI(K?E1ykw)`WfpU4g=9C9 zfhj#3Z{gB&hHGB8$}m%~gnNCpYuaS!^-3xKkyqpu;S`z8-+gc|ZiM#tOj+ zf`u!&RLdFybGxA)ZONJD{9oOM;jYv3kQmS7nagF@sbKCZRM5VJ&zwf(GwF6{6F>sk zr3sBr2oDe-n4$NWavYt+MTv9~d=b^;RbXf4QV2I-Dv+UVXazlH4^~e0VD0VML$HWO zls&Zh-5z%WSI-lVy{jtkGLy1Y1~O)q-=QkMjo1N9B+^77)lSiYpbo9U{x_GCn_>-F zGFDY`jON!BNm~F5Ult>xT}ubRS}nct2>ms zc1tUj=UD&2l2&(i#SH7L&EH%`L>O^reeo+esxchx|LdhF>IC-U8@lX25-wGj{ju0> z32SdYYHi`!V(ogdMkawsB_G(o%&x)yWj;Wud~;ekK|l`TC7L}=2%J72)RAM9Rk%_8 z4papY3dwQdG7F>*^)q}EfY}0&LxnA-N;kj@T#OdVP;Gt-daW4&(Bqt4VDc@VI4*YW z;E4$En{wf}@@of|aBPgj#z;?S9D~F-98u08#x;g1JB^DyKe!etv!_4b9&SE4eA~-6 z5$h^u9lTZ^Jo>`eGD3k8l8KR?*$X(w(X8X5qXthK7sUbTltn5M$3<~OI+7?yB4r8& zT}NXN@Zd_k|M6(-3Lade1xPUq)FL^K7lzGPSkEu2_PRsya9|4=)eMGV#{j6ymt_s) zC|&k>`%f+p(VGGu`c%@&LwVO*9$McE)QL&|;}-a(JXFuDPT&*1;SuU~$!BX&@A8kV zjKqB)@2jvNAM`+$B|@O9n3Zb?>SnmaEr}%&>b9t@->1Y0T!q^ZJpd~*J)B0Mgw5(T7LH%J@A$xO}Wrc^iJ=bS5|&a?u#! z94res6VVi|o)o+xiv`_$I%}BTms3%$CP$%0D`-S5n9qv0XT{~~5F#*$@*3)|3VqRn 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�*dM%#Co+ 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 hB0Rm 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 z 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 z z43m;`Z0?Tzyn6f()Z?F2k6*pG%8H%TnMP6s>kF0g1bk1?Ssh$H|3B}-RZP;cJNw-H zf977<1{jZu=KsC!nU@1RS5J@To{HM5EOa)w#xm7gDbrMM_w|Z!yy9rbX?wxld2W6K zpHuga``(`&*PHX>v0FfpWUs5+ZXx*2ukyW2>rc%8%vB_R5IOz){EzcxRq*2esr!G8+&XC1g_!^xHqmkmyVnxre{+iB)&Ie?rYB9D+mHpr*Gq#hqTT`lx~qTPD^|Bg zv(>-8ic;Oqg{O0FYjD(;-#qTie^}t6mJhkER;sUJ@#4Evez&9UtcE+AclUm3d?hO? z{4gV%f8<{UyQpVDm_5ad!tDL-`MKmfX|z~-=n3}ynsR(J`XK}7|I2&94r)=!-xHnD zM6MS0p+s2aYlx2Jdkd<=tAG9WU>oI=*hY8jifxn$FKnau5yaD;+^Jw2-Rx4^M)&eF zxKnO%=}|P(?l`3pqGDZPb5#Lh%-_^L$v3^F_^N%>*YGwr(QVwLOL2L4zjPCqGUDs) z+$V+-?WK&~jL%EkOFi{3+g{4_miAJBv?SmR7@2D7I{zd8vh+xkoTkEPG8NM6CLjG2 zZZVmLU$~cf?c}4MBoNWGQX3v&73HI!0`d3J*)D%rf054=X%g!P`g0@YlFtI|;eb@n zW7`+BE$TH{V}Pib?NI-@(myaUAv5m=W%*YVte@ z4byg0{FZ9+LNOvUf1qlm{H@50I6JnJ&RHzZVIE4TjXP49nItqXvXdIBK4HM`wg7bi zQvm4o>D`pNFACfNpf?sQe8(YMfEHK6&5AtBQ)lNQLAZ)C_?0&OC2WB@Yd0o zGu4W@gXfw{-m6^1y)xiTPo04{K{s087z5sl+ne4nM4PFig7X#Uq;1J#XpN| z2)1g@*JLD3TXNirs+T0aMPXsA9P@5(_01ie4;q(dZTKyt}Cu-U61&o8i0N*1L|fPU*3SqBi6OQ9}K7; zUW5ibY7MBZP)>5kFx*OK{RWAHnZRsH2fbFZ#ET%T1?Mhl;qzU^pB3vFcNwp2vKEDk zP4C6g3Mq{-i0__j!PC8__vvD-t0kSf%UV}UsX{cHDmRdS<*wUoDzuAxENr71bg;ZI z;W%uJFk!*h#6TsC81)28JPbk}dXD+AU4fTbtp+U+`6_{ktL!Nwyi5$3kL=6?zXCh+ zqhjZZ9C0tj%E-4-pM|CQ2R8jlty>2 zle{flasRWs*DAgt)`V}t()gQ#58nikvcvlSfCAD)u%ID9_}3;_4UTcFyP3@ zFy_-_WIW+7n~`BsOGd_%yV&u|GmZGC%JUapILgTIFf%f|UNAD;21ds1F2l$esLL=i z2D-}UAYl0n%gX{%sC*?xX0^T;&#)XJc$7o%nBYwCmNGJaEJntY8wlQFM#eo864Ub) z7#R<0MMj3HXc-yLtyY*jxUP+%rzb755Ez+I=cO1Kk3n?J9eqhg#)ML4l^Gd779-=vmSO6?7)6GZwqNWiphdS!_gS1%WyOX`Z6316NJiUC64Bho-fDIP!31q z_Y>t?%F+0-I2upx@*ItOCM2fkD{wR()QTJpQ_*rXOdq}lbkNz^S<50%^rYlyu<3aa z;!zlUE9s${Nx2(YCmw^8JMQSZ;Llr#Yau6NRL#U%`o8=8@;Mqe({eP76^jSl z#A#*)HO;=6Ild**dKmKVmjd7;v*Y1$)Kt}bZmHJB!)uuxMs+G?=K#;e>`eI@_CJ`N zgSv;=c`aHSDrH>_H|-e?yS~q!Os%fwavCahHT!)ZbTyxPjb0sLari6?eJ|Iv&>t@H zv2-3lo#Dq~aoku~=xrVsqED|5d2+PS{r$k=wE15`D?D0RSL1Pw9d%q?7(Xly9ce)-#hMWus@RlWFyU@ z%it=Qnf3WAe;mxnzz|mGi~r+GN*22MN8}SLRiH|^*~rcMctc5_^b0>Tu0KB~ch@WI z_OP-wO5&d0B_sO+Ycm}8$>Ny5R9|DHtl4VcesfmJz%RA~tLpqO=`qMpkF8_!HH}cb ziC$x*bH0j~aWmp=bR~=XFTFSLb&Q<%H^=uqnVKn7-+tfMpc-uzT~Eya&G+O-#F-prS6@GEva!u+RT)h)Ou zY{gMQr|9WHa^wwgiLOUD@G7@^rMK~6*xkxBPCfEYjd;n~n=wAOHE~Sp?9CXT+iDah zb@paVd-{Q_cb`-ek{F*`I2av^@wqQEvAf3Ym7*i3%#{_VZZ4bB{X$Lc%XH{HPjj2S zd8M>0-H49ZjmTcynnmk(bp_yFLBWw5@1nCnt80!`D5Loan6)NcYBTAUR4UDkOKnm$ z7Do8Tq;c9bz*Vb=eGM(LAZUZ#hqvIzw{cZpHn=?8n;)kOqZy9x$j;ntqHA&?lSp#Z zr&W%w(a-4pEy$G%IrPye9U<5XLPmWEf%|}7J!$2 zJo*_)Kh9hD8Qt3Y8GWGjGg2DtlmI~IC_5!X_A|m{7jyZ0P7Qu}9ir}6t4mJj^hEDN zQzA#4H*XC`a~^}jh;Z?+nY=z6&B^PL9mf}wTRd6AFOG0Dy~>u-_XU{BGxZ4cC0j}Z zfyZOjbS*0w741*|RDRg?Zxq!Eo~)*O@?`Z~221HkNE+pkS--zh!t@yLqAfTgF>Kvv z+VrdZ2Vn z391@=yQ9Omr9(VnBRoXy$c(|3(j~PMm&eJ6T2HPhrogx${vBA1HgCp{JnriP1UH$z zFS&-g=R-YV4&01RVyM^X)WZn*JkStDRu67!CdGwHDFV&+Ap_wQE$>Iu9|x{V*{Qy; zFHPUhy-uf#2AOFG6)R<@rozv!;<-sTarPVw@1P=JcQTuMB-f% zFB5qj@iOBeuGZ=XuKMn7W_R+Y9r84~1E1>kx*z_Y{-m2--`Ah~RCoGP6=7|STg>!D z+`oa4b6V6{Sp1#w@)+Dv8rtJ_rca)wDk1v9Cl$6^Ku3P#A-O7hoZAcg*83Bp_%OWK zd69<+J{H5%TZ5}$b%h_v#j)ai*f~f{;Y)EV&exhn#)#a)*sHPYTPu7_4C-?AXoH>) zIh7dqwTcOzKJPKS-2Uq)gZtV>%4RB+4KH_*oVDoM^ovhWt*^h#*m25cp3vR!@486g z6|Q!z+~~TqXX-E2rd;h0aIO2o=P8mMP(>HKp*wrR~)tGq)j2OkSS1!ob` zwcopYwBko&#eVPOqYrN90hkZ)e3*<-f|+jCoa_&jRyp7vILW-TlFrcd){{5vcoY2a z2FGlu1QBbyY7EK4x$F}TbG#z>>xBfxYoTr@^-g#C2E8ON0a3RF-;dek(nJw}{EH=U@MDT{cRcQPHxiHEjlaqOe%X~NRFqd1~42aS#;&ZijX zlTzg;trI{pmW~~%+5;v!Vj1Jg)n;~FA1{H7D-hUMPP@Y)5GymJIir)2L(=GeS$i2U z%Ylb^8?BXK+-N^r1&r)dBrLg&Uuk@`wy9>#QPTZvKx?$#Uyc3O>ch^$x(Ak|3!9)z zyLHo@UZcOebOvrCswc>;SIw9-<6RUw71@LfIWbahUIO>H??1u4j(`-k@%>%gzm71U z)!ff~SoiJbRd;j%b=bV>HUcLDT1Sjg%bh+~A4VrjLa*W?~u`S&0Z7!pZ1Ifp#P= zO!88|Z$`&;YF=*Z#7B$=LIBjm#gP18Q44nPwpy&D{wYK-c^Xyta8X24! zJ}tnqe{!>HlBre}yJ)AGegG^m5x88ByVDNRG7;GC`)l;PJ98fABQHk;LdL7WjV8H8 zM|jp_4*-?3Xd)<`GH#3zfxFYwWrzTCrZ~d~QkB+E?8(e|c;QI%bZyFPTy>6?gkVY? zXjw`a$)H3k=e_3Wo{|u#R@6|%A&l0xHVRaTq>M9~nY+CNWsbJtGncx^qn#jAYy?X<>v-}TdB?I7c*tULWh z04g3E+p>6r&~v{YHSI#qYvj6n)V7=5!xB?si(-)2TG7z(YZ~HoH)Z1q#Dg& zfumg+QVD-nIKwVs<)fRR&4Q6uXBw*)dziF(pbU#)eC3NZX9NU|rRd;xH48o5tSio) z%zjzB>SdIWwn&WarD27elB@pFym#e`G)Y73|8!q_PYM@ggK1cb_Hu4-@zvFExS+)i3_fHQ0 zR7laj^4)A2j2G58za`>(LXFaHcjnz_#R^IIWHpdmX<&F8#dkQ*l2N5NOz%iDri4i6 z(C@$!dtVvWIjFPl+yluS*Qc9kcDlaKy6Q+-CiJvDV(ixXx8vEE1)^-E$ zX{rxcp%=(yGx!KR6?hTP8n2px*c1g`-J_K-*K{6g1~SShzDMaC=dfonFZI~G9`-vY zRd}*_y`ramdzWsH)DAUm6#u_LxAQ(qO)+ux;OV;eaBIH8t?@$5MDNbF453G=7<(Yr zy(c6D@Z!O*%d}O#)?{qJIH`Q7mWAbx5zHOvhJ$P>T zcU;qmQf9&2an|CR_26^EGhO+4$YN%H8u?UDbocxb%i;rR?-7|$<9ekY%t_21vZB!y z%5+m%%xPn)^zPrjk#g$O7?Z31{ z8e=_pW_X4=QRT2z5Vq36m!@q|baI?2GP#J^M73-PtMhP@5vITq3;Dbu1gaS6HP4S6 zrS^do%vyJ(BzTa6N)slz>X$S6S6KsDcjWEss;(lFkGA#?r+l9^*+B1C^j^S2*ei`N zvF2Mn&|g?Hb@R79`QoSR&Ld0l6V7tnE3=WJd81}Gh(Q+H6@`i@TyumW1&xApHoM@_ zRB*NZitDkDtAOMEjI*kFx=U?LM|Z|^AGEf->L!)V^XJ_2goxGTLsCO$xv8G~Ojbh| z3**VGhHiPY$tSWJI^%jZshUv@{lRpwLp^yy2XPzy+aqNkUW^XCN3(2Q31316*sAkP zgd1VUfy5XSLPjUq6-3*NU4~sof<}=CKC(fJOMJTVWxkZgTO?>YI(zYc;S17f-IIK+ z%)nU~%qudK(#z_S2Fw8fStT)W8YD6OKx7f*@b6+AxgZ=u)(Fobr3<{NO}sf$$-T zwnPxXL!G1_wSZa~SJwne{56|47_^ z?h!GFit`XW6G(srsH4Sui3BtqX^-y_NF8UhSwnSB=m(3c$p{h7%%!TUi0$I^zC;3= zj#5sC#KUYwmG(Z?e^Mc~&?nEVCVU%}q>ni$03?&}Dn9SDMhg4WOq9uHqDlBT#XT|Q zzD5OdFBm#~Lbl4QeKMGG6_an1@Uy>-N%$$7^~HtEKd$>^q)p%6h~_$e+xE<*=4h;< zE8B#x3s%#8SH(!hA)m8NcsHNZ!RETR5qO*KPvrrz-QN*T`YP5}SH|S4f_b-TGvoc^ z%)x}*L46GgFFiL*X)h{fnHyFFbHY{5c@y#d1?uVjRsPO7U(-h54<;hqOb)x#-!KB3 z$45|F_E7KBGXjG@D0Am^#R%**(;9(ow+KdHV^}Z(`u<8_jsl*Y)&;IipOoQ=Ro zXjMDMc^_^J6f{&4Wg+$~Cgo}Gsrjcs2mULXE|{%}O2 zsD*x=_*KZK+Z|kw{njL_$Y|s)sC*lwW%>)6fsoP2U{|?G+jN`J$Y`PREw0~YG&0GP z9oW;kOhzNhP06{gYzKDpvIBd518!#KEFR+Lo(Tczc~Xrf?OCNwQaI{CWh+n4Q(585 zrNarX_JsIcZEM^;!&+YMqqOH9i=vhz&j%{WK z2mG*3j1xWRF>NP(GK+m)AB^T-c9`kMxPK?gjU&FlhkL2cbaaL9Z{R+t&U}x7B$|J@ zpZPuAw;J@SWApEE3avL=Z2r{=0Wy_@d5#+NLen7yYM7dOX~N;U^I#fkGkBmHyEDC> zR2>3!JT?_ zHpoO$)~n)eHTevWSwON_A!CKc>1;Dxik& zirz!As*fee!f1fGFbZC^6PJb6o|&+)aU`!ZbT!8W8%Z+cs2RuaYyFPZ{lP=1(~s)O z5d26tyVmz7k6-W|yL#AlkLbY#W4pcsS$IRGWk08a9cSp>;fM6AJG1sIEFTY*8nC!+ zX=sO4rJw=DDKED$`F<=KK%3gNSX@PVVh7;zh)sxoL>f>;*SZxR)noU?269%jx^o|D z8-_<1_u<5?M9RU0RD%U9rib*C)k1fg-jkaQ+l(-RmQH=i^0z@G?9e7JaPq2U_3$?Q zGy8RB4v{8g1OeODM4woM(XM~ zLeWA-WOt!&0@T`{?c*UUXq3H!kH=bcC0kUb8+<(8fPrMQ@A2{I&Yb^7b?W`C71TCP z#u*GCUfS7LukJi7TNbh{lNZ-!>)zLq`6muHFYVSNQ2GlYK>*+F>D{!h>(0mX;KuG~ ztzbSGBn%cS(?<0?EBP=@uaov#WusH4BUzcc1h4C>bz#h@<7I8Rdr{k*LV{+PvC&$} zEah6wx-h@l{&{;4vf{|B$=0dyqi|w_M9!_mhwX+~PfGVK^U02H6)~$bt`67gS$n(d z=d43zci(S_eUl#VsvzTgeQz?q0WywI9?87C1EjR&rQI%cu7uZ9C=+TB^Sd*E4?5Zc zUOL(JRmadju7{X5$7}oIFGc|+I=d4=eSR4f(6K*>?6uTAqkvRci4um9jYYjek-1I^ zF}pZurl5s#Bi(+K$ zHKA;h{eh^+Wu{~@*QOPb*KJ&(34xj0GxTX*f#CEfZ>R@4Kk1*UrJcXr`q|Wzw_mV% z=hiysl5Zw&x!{L({$!hCP^!BNCU(BF%2ttj^2Q6^u=8(Lh34(EyWUz4OUR3Hwn`nlo*NjHNPB~*1EK4aa?2a|55?x-Ln!3*RZ-Hfu_COVO2gSsQ~8s@qk zKDe^JlCEsEv4G{>dM-#*J4?k{l!2x?WuOZR9o+;k4xSFFZt)~uuL49OZ1SK8sKmk> zXL?xKOHt`O^x1O$@Ujt7$~K}#($eft3N0?yvzB&CN4N8r>ztghnUw3&1zUC&`6%nj zPh9ZBJNxB{T{mCwy*q!X2=-7Wm+=eE+SzOIgtRZ~cb&y57_SF6*8>q1wOkKAI(((S zpY*nY<}HsUP>}Je@OWxbrBd*SAjxcKYNF&5oB}mFvU)H(BBwg=NyygvOjTK-%i(7M z7t$=c>T+mft$vHT^NZ0&1^6^4?jN+lzOJt$)0}pVnMSOB|BB@P9Nt!NOrzI%6;J!F~@!36ag?t zJR*?j81r#GNK$x$a`b*;0mu&~Jxx%KR%&`wk3E>4CJDag$iRrkT6=gpq$-IAtnjgu zVS=YxXGZSq{}SDX*}}d;{|DqR?bdU=83$GXQxaH?!eU^qEl9a`5a32@Ted4?y1bDe z)hlM^vmZ>}dr`&9{2aL$Kz$JUyLzKTq7LxcsQBLR-@2_9=lb=y}fr zpX760KDY1RF$$7fuF@?DK$S7HA!f+~5Vyuhd-4qNRYIn&sKSmC}N)x~cX;z=>v=4Y5Zmd&D>_ZI(^&7!TuhS@zv=VT$TntU{yMMnd&7Mn$n zMSeM3$xE}Supvr@GKYk<|USi3~S0k6VMMd*{f?jXybtZeMgfi`FA)BzzE?~ z)Q-%$T-9AfES{3-3y23o5gHG<$82X7rc?x0&^U5&STb70z7MZ}9S6DB83w!Q88;2G zKE5EOCA`8W^9~B%-MWZW7`-Pbd`A&6Ud(#0r`}gHXK93>m^Kd3-ae-oWgko3hmWv5`gBb5wm&AaYwUxu*67DItp~j z^#SOfQUjfktUyLuYiRWU_dF{@0;^S)%K3}`dWg!J*LuVYJxu*`c%N#kSI2TxR=6c9 zEBrzOa~v8_L}j@PlD?0*9KY4k^94)^i>R!-drDV4+|3573O zLfP_s0Z&c~=%7b;^U}nbur9BSH$Dpg0yc+M(q0~QYCLMj9t)JO^hUEL?tq>u>v=!y z`<`AY-KJEEuOb6sjnR|CLQ&D*sEQAD@MmW5qm0yCRK>G^JO-^!cU1d3Q&`p>jp1@F zfzf~vYX=}kYbRAatQ_ETuSU22`|P)&#p+gYu~{Qu@(6VmGf4beckA^&srf&n0`%}# zM)QnSbaG)0rhpWNg)7xoy%JS)_GI7U`xJ&Hk8|#qgkeb)5T>hc<-r_=Rm)=-O--1p zxWi;Uylp0N_A*aigkf17%Q#z}e6gfItGbVHNc|<2i%F4&wyc=Xda&|+n<_|YQm9@-CXJx2HFgKLirqpn_Fki`wD#Ne zVjuUJsDu|}QNjmMhQlIkhybGK-$7S636PBJ${HF`-98lXBx5&} zN_Gubz7>?qlCk^5&Pstyhnc)R|Kzn4E?`3VwfW!>SgBrj`fn&KsyjP1#BVTYcDplY zsciU5Dr)@(->R^v?(79mQ5NL9?#y3N+3@EnI-9L{&zt;>Tb!Q5)6(xfw8=YFi#w76 zdJE-xu7a0ye;|XDtgvF~qvnxm)!c4vdh^TFcRgv5RDoLCs^TDT8|>r~V2!?#T(w_E zxhEAe&REhV@tVXy>_?unH#evnm|f!SLH%t;qC0lH9%C@r+n1tP{>t;3)6t?`e+zoD z)l+JhcS-g#WBMMK_YnBB+4p}4L}va4flq}O{5>8&gPB+!o#rL&wR4k@W<>70kgNj7 zlnyLCZTE`#Jmgj?Y(+kKVKZ*YeOV3e9hrHMl)h2@Zf6mb8@<%#01hn!odilGKE~X3 zyR6)r@ES>*TiKA9u(C>Yo(J06E|}gL0|2t!gZyH73uf~3sBN2(OI3m&j@6gzg=SJm ztypLO(`*6zRTNFL?;iZjbZuPx&`rD~A^G7NysKF&t z0mnUgN>9RO5SDKe-Mty;Sdp|Dgr^CF#bzMud0P4jgi-Vko54-#X#!z1ydB`fW4E+W zj5&RvpeJ65kUUPX2>mbjd-nO=Dr;ek!X)kU=;fJ~@@d@H-3Vg%x-gSyGRGnDQ~F>D z$U1~4RjaKTi@7)Ak3c#pNX`@CTP^|#e3q(6$C}-n zo+m(Bf0NMGG(NPSKxs`}T#Z@r;_TINH69lsYGI+76j&!v$IR=&=rlT3tXz!?s$$P; z{%jeo+(sVd-fKyoT-503l4P>u{;eg+uXahYgEwoGZn<2N8}x#fB#sjYc^8IF+c*li z1G?^bLv=|bjdGT)hp*Q6+uL1U#On=WsPHZ?;tAg$XtmM%#Y>oBwun(IVNuVkLuOX(Y+9d} zu4Ql~^mBBeNRz=4NmN+c%rEQokRC0+UQ_T9OLn($EG%7CInXMH5dhHJiAe|L#LTfV)Z0PRjc zqn394`0}+XF<3yhJM9qKwJX({!UoS7))L?nrMQ@_9K-O1s#xx{aJc)ciD1G*n)%iD*K+ zuAw8z;|x$(3>Dpn%VMyV+@>J`MzrUZ;;BD)vJT3?dhqAnYZj!}1_S1(JNBe|eKKyC z+bI)P0Dx-jSv_+QDiKqS?17SRkMcyj?kas*IZ9Nc-#Mb^%2m;<-Pl*aZ|^pG5lGo( zbN$|D>cIFY9Su1~qB_ zAMwt{R4^sY8kzGnTNcjLnP#9f0j~@>&BPiDB9{e-q`6O13@KH8*=lJjOktc|q5ejw zhNoB$JyojDG@^!Q%CKW8>@3Vup8(&GXRtIO-qsB6-=R|+Wv&|@5{xifbJM-}*VICL z^jRhAVzr~&8cm1)O0~NeZ=qI;K$G|HctcxWBMF<4^_C>Yp!TrPU z@(bw8s@BIzc?~yq_pl_EzQM#Y0mBn~#4M~ByozR~-{|nE^BpS{_}%WzHxWMdvqV&h zi?Fq4UN3g1cPmt?JA3Q1$U?#Cp*!rXYfr91m! zOH1cT&Y8I%>FK}gUgmfutjP*LmVbcZ9lO@9JP3Bxjl;JwlJ-P6kwm}RxzX4h_X3AC zz%xtg-KbqUZxQ}(t1XnGm!OH*hOUPgE&>_v3EODY?Sy$0C(t(a!B%^CB1clhdTI)U z)2oVJFJ4JHL`CYySKRvfS_4G6iv(3EeWzh?pB!m|f8-W~6sxw&7NSR#GNE z6zgFLx1=sL(zzsblisK}e)igg2nt@sY;K>fb4ln%_LHLJ1pTU093E)-TyV|NR1WMr zDzqe7o*2rY{FC~=1+^1HO6`Nsh0$Mo0ZdFbCQ08fItcvCz$Ezj{769o4r4dhu^Yl} z$jOIVN-!tNcpAzoNHe5#N^|PX)7Ql&11%oPg#z#}@_{6>Niabr6O>hH?718tewzC6cy zsi(xCd`eovt$+;n=4SjYQHYbkbFfJYzi18`Y}!I|9^shI##>}zRf|*fXzXXzqK=RotJPwMH&(?C z9#)H~gA1-2I(UfR>foHOF~&>H9p`>g2Y>x0j_MmZrSNwCTYR41EA{H?X%D^kzTkJX zdYX=Obvw>Xt)BMxR{zFPKC}9_{I%7;b)3QN5tuFa3#5UM9OM<$dp%&YHu9^G-5MHegv*wkQ0=o}i z9X?2NlV1palc}_j-8!i^f<3)z;~30T4an-k>`mcNs(VhO#?vDk4d zJW@)5)Icl0y8*iFlbxmSiGv(Gc;bzz-}sLr7D!nk;ic{%MM!Z?NBT+W7V5!0 zbw~x^*nS;7EKCGk^2iihuEI~r^&Vn1;X=jz&Z%%7PdCvZK2VQ+);&C_196oFKn!vd z!4}DYs4c5SgR#Z0cT~o{HisJeS#`?Oq2(E>w2N7F7pk5JxMCpF#0u1|u2wyJvN!aO zqo%|iBS0{PZdsliIyXAaWA%!Qr041*`^Gaf@jdrt!U+%t+cwJ14mG@>TF|KjBTTG1EXj4#gCdQR& zhM?w5jHPObP)NJ4#D*3wdG~EP_e9ZwtuRRc$RcsDnzp)^T7qp##MsfOZknphhpXOH zd?FF!rt?_K1JtC<7Og#;Tqm2(d~nCRES%XQvs-Y-55%P0j86gzPce&}HNWpYTcLZ9 z3g}uw4ue;9H$qv3ED!?Tt)Pp&OP!b4{OcHwycFyIx-Akg6$B7feXoIi+)JwNLCEwQt-KPH+IX;2?}! z@(?lIYl8$W8H6A~&vFP7WTY`j5FFGxBuLPUA_NJ7k&%ALv6KmcY4x{+r+f>3oECu} zZNN}&$Y72!s}x6n$f zc|OYe;!#7kq&YXx?ptnv&M9F`zat$^7?R@L*tbHz5qVj#-97K`GU`B!(EqbMQOeQD zOkYrcM0!P($b{--p3$U8aedh7R4cscJcx8~-PI-lqqUU~Hr5XMOm(2bH{h77r zg;>~mh&Fn|3>sL8!zwF4Btg#h+7Jmbrx2_VZ!?#m1E(?Pb{@?dFoRKm3ED9M1qav1W>hL(y&!?`-nCZ6|kP(mB`y!TY`g1iSM ztvM(M{=^l8bMF7T^VhB~umwj$u*F5uA1%~7S6Ua)2wy~p?y>0s8gi&KZ+dbqqGoSCi4}qilKvIP_LfB%EdZ4^pWGx!>0BEY_Zd!Z~0WOC8AU>sev4`{T@}`1La{MnH6R45e33`nZ|`t zftQo-vKRn&Ake5vzRQ7D1>ZpPfzt9)MJIYm5M}R5e4spd{BB^3S%Ll3cA?d-< zEOA@yRzh?6n5z{OPI(B6YIoo|!Z2Jd8~m!y3vIP8b(+rOpsq)$YZzr<9$4P~ znwGzt6KeYvIaqJa-0xHPTgwMdxy^*xA47#J2PJt^=Kq8bh4*UcK9 zKlG)OUq(9h$5(<>43}sTyab z*+HWB%;*d0d<<|tR18o=w7cA2Z%u=VXLrsOq&x=Ehbe?Vo3nG6I4T$@MSiaN7;HIJ zntdr?VC-Gv6tcH&>W;l@H8+e^H?JU;+FK@H_IRD{l%sZ};uORVs8IW%P4wcA4l=CH z(~X|6HzKbYDslcpO=XlkS56$AdofPGUIdngBTT%v%CarHULSeuy#%huJ`BW)g>E2n z@MWZ(@SWI-zU$pn-8IrmrJyQdLp@JHuZ7O#Q)s9*Iz0OvSeJ``KyYuZdiCVZ-94{o zIO@a1@NHC?-GKM45XNku5N1NpyVLV3?asa>J~L~mXXAc$yPvI!&*n9)m7bmDXNUak zPvzxHo2ykutio~C>CWD%(&4{xg@5t6P=Sx}vRRPnbqjp-=i@Uz#xpZ7(_KG%SA53D zcm`9VBW_gMo&BEl9%I%=`FMABOr^tnT;YhCXcOT4f~?=&*{fX^D79#?%MQ3qQ%nQX z?{wLvE(^4zJNs+88-AA`yhoPw>g6y7lcf(iv!6na!=>g9eCJRL7IVCym&i?cGj77vV^N-Wt>1RJ3pLs><8Oypm z{R%((!T5}2!1S=JyR&ao>F_cA>0XSlTWkAMZ2pcPGoI9$tsZ`kDq87B`L;OFW4gXw zT>N}>=E)BTcUOUxt2X&>_u^aRkubdSByIm!6_+92H|%)pqPFWCrZybtD?2Z01tuxE zcS!gW%C=o;tG5}HhnsovU9H6#hmiHVzW+2{t@FM){1#6`+8%=sr=rIO&wEph6tY#A zr(N-wl%Hv@>~}uduF9}wor~Nr7aGk&RC!#^Pb(Z^o2BYp$)I`9RRdXY)!EqzX_b%karSMRHJ9w2hwgnrr_APqsaqeVb4yLvH4Y3*P1kaLSZpn^S3{H>vb6*Y%pz+^-TBBO;AkycL?@{UAxB$1s;|1EIE~ z_S((%%4TAOS%8sNd2rZfUxg_K3m}NFd2UOn>-|T&>dEx#5jgVQXfG&2&|JlY1yz^C z%_%hm22qNTdIndCr-ADZ_w|PpLN${ovq0KLyG-7l(JDKSq1H-h)hfRy@6Bk{N<)(m zW`XqDHnZ^2h*sAo0wb^d$S7q`snSVGTCT)@Czrr`%zt~&z&$E>UgNv8f=3kU6r@b5 zdhV(EK<3V>4<@uy^5R(4t%=zdlT|oDi5-7XXcZM}*;o;^WQ&azOgT9+*86Bf=3mHh z7T1Iv3yreK3992NH0b6VW$}}`Vj>kBX*!Pp-1VtIK9x^_AggId2;%mH1vEj=coVqu zLN-zfmr>+2`~G+iv=E|-x&;H)R|5I_mxH{}Arh3Y4&<{2ymWs0VwA^%DuS_F6%lOB z-D~b00cTD@f^%7dd@$n~tc4F;PApDTK&X03$VWw40eR2!V#se$2c?P#nFbcbjd8QF z+ta!ZH-+g`k3zbEqi}LPQ5|2!X!$C>URREp<@sX$MpC{9MHe4H#7ov%9S1gl!;>eS zrI%Jv+NV|zsf$o9uHfZi?nNK?O2$vC(vIYOr}Mpj{5DSN?V{W!c$6F1jo3Fww-i&xNnFcG~4sl4F3OL zt4d?eqbiMwu)@6v{$D|r=6OH;;Qvv|C7TYED=K1)zqpJ;vn<8sx`*Kpk}-P!lPYO(I@WVPnAN@E_wSgOo* za?stL`=u0Kd8y$tNLJ2s#RmppeZ|Id=DD4X7YvZ^f}t{ zO9rsQ-v!_8QQt*5G`@+#ceH|seHULz;Y2H5;2AT$paG6LtIl<29%j_pU8e5LMP4j% z+g0iIQmBrTqbUp^SOfPb-(2x?Rj=UZ3QT`7vwl)Ex?aKRkE+MokK26Qvid>)Dxe!H zq=dzXM)YIKqiC7Fc?!)4?ZazWiZNE?{J$Tf7`rO^6I|vR{Yhnt3mOm-n z&Db*mqY6!+?aOVY`?}9<-IIp1Uwn(<0w>nDtr=zH4h1H!= z48&m0UGXU$ngT1gK;1a~e(+IcbVB~F(0KX#@NA5&3xmZN2Ou$>3%{b0@yUK1Z|9gU2x9-D&3%7hOu9skDm6n!Z3lttJiAt;k%^j&h%_ajgC6$3 z;yrCTU`R7~p|3Ny>=V#7lwAxX>F2Ll7Prd8uExZe81oIHf<9qi}jhNW)lqeye~` zl=HxnD~y-5BlXeYP*wONB-QB{0J&g(x`$mYmPAFC~j6X=@-drjaJ{Iz1&WLWg-oqf&ctDDHMYWw0elR|)&I zb7mV;jpZoroCRL$X!f{swvIv=p5o4#xfY+m5_isQW5OC0cFq=AI%!8t7^0Ju3uq>U zki%${e*#u$=X98dcFxlZ^U%&IxVPDrs{-!gi`tKihRWERo&6TH6Z0u8P#bm(cbQe<{Sod(>kj!U(~+-{jgq)}(Dkni!j8xt z_ZUW~&mKm7_NX~u-)EMn8O&*&H3NB!-K%Iloo+>T&Gp^syY(qaY3wP#dqZiix`QR_j*gv-=$!dY>CPJTdlGr{ z-PUC&YM_{&6W_7ifG*-BvY+^NW-qO#2D;Mm3#Bjfu*9<8=}K{zw?-UC}hD| z;M~8>{jon2*U<0WY@3dvLO1GGic{UrHOhiA;io5Iywr%|=8pJ$nvDR9L%$pFl>3vFQ7*&AUse2)^M3jW71Ea;-Z_Lk)vhoX6%{YIwp(-NEA!!;l z^tkS6wZ{pJ901z7{c5yYW9Lbxks{^=CCE`-*^H0*dbKGdir{(QNBSjZp7~>bPpZ7m zC#E%%3*G5(?9#2HG(*A5?-W%j?b6PIu=v=eYEpz`@v%#_PFR{gtz(z&tvZQ>w=4Hn z?>8pHSvA&l?vKPIL=ZCWsPAIvR%x`x#i|Gvl4(%iwObLJ_LfS%MgR4Ypcb(? zVj_ezk<;i9q(hBtWG;Hkt5r~!T+XQEpZ# zsTE&P<4YWs=IP04=Gm)-@IfzJcb*D77&}-Ru+%FMEyf&97--dsqe5rQt$6j?1F34E zi7DhxMd!v0kJ4Q{VTLIuw$|A#fnmDN&@r!M?3e2RL%OyVJ*9(=R;7KKd8E#_J$zCO zvxujri5)LY`vV+$6J8&N?d(rzDHgSR8txbduoe@8l@uN1krD58f7OpAQ5;Y;;S{94 zRp7_?e_9YK1RS#(u28JF6&_V%!`MLH-{KJk3|EfQ{m$nhyA0)oQuf7ZAhKG6?Cx1T z#!#;e7`pDt=z|fQ_M(EpdGNB2z&aU5^k!z5tRG13<=`}9!>8o{*LCuNSXA4t)Y;M9 zwX}#C3)ulKUs5}_y)N{5le;zgefe_)-*r!kv)k%SmN3>d`lL>f8et{hb)Q;m5GJd@ zT&Weis~(f5Oc-ID^p+(Jr=v5RY<6rtrK0b809Z0mX2y4AfW3|oc+qN*P5Er5_ zrZfipq>g~8lXpTrgRn5PUv|9d&iqflQ@|ohAcELIFv-qrCXIzmpwu;iw}mtwQZ=jQ zo-Vez;LOG&R6vJa#jUj4qfAj^Jb9E87!-P$&am(GY0Zz0F($k9H%2Sy*rX99CTc-`9C&;kqHeeT^oy1K82 z0c9=rv@rrUwXSuFzBRrfMSg$HQ)yUf81}QGYiJ(o$+;ch%_kNZc*}Q7tlS8vreN=j zl$wMz3QL~qK{(Amy&qo48U3n`Y#VA8vU7~EKXhEFTysL1+ z#v;XRkSVHoZ28@-f}slMj50zARZVB1igE@UV;Ffe`B8ViJM;4-CG1`2j(fh_b-{_@ zK_LWYyFa~@Us!2w?2T;=DWM`!bJY7p`#u%qS{g${Vhd`K5jDMJS&z{lV)gYav=k~x z03$j`ypN(6&;W(56UET1nM-3rue>FN3+xY}0~@vx025>}M2vII`g-npN?=&hr?RoFW(clo8Ze>Rg(eZ$_l_dlL?=m7TU zGIYRmTWGY7FyR=MW#oLd{{1A@&VMle7cq98cgEM5vGWV1?GclVx7R_~RoV2+14$%g zF`+jzV_{=wMM-JwWSu!aU@jUvx40k~JH2>L%hwBrtM(Vt&=ts*p2$%*ewzsSsILOlI@s??qNl3{jN$yk}m zbg_ z27*01@Jvopmt>i`Cq+Cvt(q~9#SUAqxudjmvSq!5ozuIT0LfmK7p`3mHa8ueoGMeL zRypki=LDS0E?dj)6)l?q2mDli`%)g!d1Z={_aeJgnp;&s#T*P+iVzU@zY&Sh-^bk_ zM!}tcs<`=RAPV1-_dZ;wn-u5ljZU*#F{|MeJs0I9UHI=$|33Y`_}5jZDtr|sp>?Ne zwR`a|@cMNrmbaOFuZ#kFo?;_FjvM}CD$RcKarwe8V?nr3s`2T6BS-!2?1xsUA}e8c z`g~XUk1JGp@7fLB>DpCxFI%NjsyuP9-%UiodsIW-`%ea!_k+UV*5&;^m3C*pB|bY4 zpILmJKI~_o`)qj6`kn0Zeubai8=p-{zQW}lJwtc)gDM^V3s<;6aREdGfSldw)$;1^ z&i*5p1>BYgry~b`Tz|>#JK=isF8e#K7yJjhvlF@--sJ~pDlVWpTfPe2X)^3}XHTkK zjKSNx)4%J2$6X-nQFnTu3m#BGqxA1vyE7N5V0be z{UMhv5G^qM*IgE@M%~$uyX=iF3zAZI&k@}XU*QMG)C@z0vF*<8Q-ZC&18%2pQC+-t#3LKZmW#Dj@|lsIU#Z zivnICWW||O^l85ksgYmUxUOxY3Z( zMVUg7tig)9^GKq~t*|WJ)lvq^DU+6Ziz!^-O@dnKE#^E*ucWZ>7E@fza^7OMEcF&k zo?Zz7r0dJ-iZ;-=>7>Xyyu~y|gFJYPjih7BW-FPyGN^H*@q#a`U#tcXO$&t!k(C|MMkKg*Nl43o(9DRAfu44QXm}k zZurId-@oB6Vh8(g3rvTa1C=WsR(2lEtbR4(Q;?p5V`*|5Mg?F|Sg!<;_=)-9>%L(I zoBoCwY+p2ky=e}4L~2+eDta1hN6q9%P~=J#shbfqSSTvxjsGoXFgbx(Hp}sW@$hK+ ztKhGGXMxQunZaVG#Zq{6?)ii7V5lJ^j$=O3+JuqI)XWk`s6%Z3{}wZtkA|kt?p2nT z?6@K450V8%i;RU85zH{uH25TEDn)jKt8tOUsW@BJXZ}Lmf;ui(A6t=8yP-~cN`|oT zG^LA?BMWXF2R!ZRbn9%ntULJ_Bb@~m?Ix|{pop8;-+Jsw2{IYNlGE(=x2Ri^rvkmx zzFwEf&h)^1F;1`%RK2?j)hoxP6slJ<>a8`bs2Ie|prh4VfGO8`H_Fa9TE}|aG++y? zVfqoz5z8A~axjQVUHhbCCDG-WR7V_?VN4!V{WZq{5#BEeR9}gWEQr_r$zDDP7#RiAK9Ko`%8{GinxdMu9f5c;^;ABD0v(RB_RLI6C=U6gs$trfL*br76@Hb)x>jx8? z%Ie<7Q7vRMSWULHG}%#yBgBI7ki{9=d4+ZBLyj^?Hz5(;DDB*6R^+O{k_01|Otx-36TAV7{WFWK)1T$D1h;|No)AT;l!VO+gmV4`GV9L#e63GO#s4VOurzw7AuXJbr0HlbYYtcc{8^mC{Iu>kZ zUNqU`m6IaIPQupiR*gtZi>0V_dR@@;jD0LExEB0At8Xt&kIg3ocCu2(!<+W&sSdl2 zE+ov)i8Vl!@@De0sl(lw`{WeK$w`-M{G;1t?EN}9cNZ&EP_YWta1GsWM#2tMA^UyA zK`8UqXbY_c^|Ks89;8uz0bB-FJ6Y~ymiOo}R8d##p77k+I2L{fZ$_KU&$y|iNiXe=ktfJ39SBh-k*>t;H&m4xBF5I`{(C($M} zfJwK=^Qi&|A#)CTnOHt_P?r6OFN%$_Zbm}Glu$GpDWLv#ez1{3mUx~;9FmD*LA^py zqZHejg|H%4kbB$CV(e7j1u6~-j?!>zuIHfOC=D0hY;tjn(r_ElGx>=u4UeYXa_rR0 zOuL0(hZ)w=uq?QRZ(8BTraeBov0aEXjDuRHhgA{cD$oCy&bB4BU1=}#d@i<^c`}93 zpc4G~Ze4*rs2S7#m zgfGg`_LaLcGO49IBOqDg&ghxmckbG)^FQ;)M9_s4(zxUCq(6BKMntLz-3%YmGW-=C z1sZNo*!#ncW{VPmKHZsr_=a*nYj-F79hnf^I+%@9xOMn3W)4+b!L5TwHhj3svThwl zs}=w{5i zhyn$GrBMRpV;Yr!8xHJ^zHS9Oe{&wC5Aib>acwKk-K(T2X@rsP7v8A%=iy{^Xcp(< z5nBN;`JBd_Wn{?w)7u;dq&~QcdrHJX?rThrtRWR|B+~ z-fg23WggEJ0-zk9qIcv@Gg>ia{z4Es;tH+NF(@wsw-|AiPlnlty*ug`hUlDK_xhlH z4)CZQ1(`>N^foTAvOTZ&HSq?Cjfm_+FR&AECEL_Kj#%H$qTI?&cEpOlbBl}P(VHW# zRvYyqYqH{+T&*51PKM&--0Q+)*$_9YT3nL_?(k6piD&BW3_JFd4vX4(80CEvMijZn%;|}4;~F} zZ{cERTT##wuWJhcvl^jI!4T- z8OS;RUcx{gqFCd#wn-4T(GaF;QT5YcAQ80YcU=`2NVh8t^Jgc-IL7aQsVi!H#f2hL z%zuAugce)>tR-Az5I@R{t`;|XB^QaHDAGZM{1iWN$Qt1y;m5mQ5f_P&Q7ywonuQl` zeS?cUO~2prxmM-6pJ~yav*7~Q!+yit`)=xYeCC!Pi^NZSyIa0%kfcRypAQ6 zd$M|N)oqF`lki$Fks@afHe=iKd=pXnvQBpJoaU4{+IgAF#SvkiXqd=m@X`6#9&GtY z7%CptlMBJCo7aa3SKXNp>o1t&77N}Z!dJT+gOv?Xc(GWYERIQz1w59t7!ivNw%|gX z<=vPeOkfnLl~$qQRy@G5=OP@{frk^?9@0G#EO3#fqQXHFG2SIEEbT@Ng@cM=4MuA_mPM=#7G&3g zQ*rGp%|(vZ)>)ix8-oOW_I^0Z@<@?_Ss=6$yo5FAIMg_JB!pwyGwR8wS7IW?c!ojN zZE#FxBBj^2{W}bBF%wyL?oXqs$MS*Q33Bnk?f|)4(z`7uVLs!OkP{QB;jrcHxm#Io zl5erH-e({=oQoQ8bTOD7i1my7W3St4NcqrZLxiv0DPH9C+XAm*t!$padKw^d&QUje^-kfBXV#TW9W zM5};Zyc(h|<@vQ^bv5`Hj1g@(&v3el&ir~hvW}-z4=!~t*Y-rQ)^jDSB|jF^xL?&G zFtc7do$F2u=mlKGoa2WQHpCUlqiXE_rPyF`gf1@^ox!kAyr!CbqPyoiUa1BcVBEws z>GZ7W&6TWX)4OMsA@pGjAPbx+$NLKW@S?#fhVHP+cr@sno%0 zeq;Btn-zYQKT-9RZd%d(BAgzqm|NK{bW-HQc^Owdk$KjzW3kugSG_MN7F%yC3qbSc zCggcA6htl=Oie0=@Zn8DUKE^k8^@F_NLX~-XFlULNPn4hbG5n;lYf2HSst73`(RKCYVG+28ym&RiifvUSdT=VLf}?TpV3 zK$~+u+P~^&KO3Lf*p;30Ugc-+h|fH`**WifR66{${&Z&_RkhYR@1jppcB{*hbKW+W zU8AzrIqzJT{hsRuN3!nhWx5-l?FWNDRpqng^U|IE&QH_gZWo*@&EfRxT=0u7uyfuE z`q`a*4+XM;ZLZEnAvyE7?uVOrH2o)f0}s6|u-dcR{*1E!$7Rt-G<&Yg_NlDV_$$}j z=(1mR+10kdt5F3s@Tg_ zs2k?;oX^mc!3pL|t{CxIn9B)2`=$77cYGG+@-=$aYD~JbKNV|*S(VVM+TGc+RXY5n z{&X)M(6Kh8bMkz7UVeM4r;53CZX5n{jcMo4*N2nJjNB$$cD!Y2O{^jth8wBAzWQ*R zX!VP`-nK$zEGk=uuVd13hMKw)c>Kst}Q*Jtx7Gm%1V(`4xpeZS=%&3J!-#nj`x-)4?%On&QO-Vu_ zPBZQ-oWvo*H4&U3;%-ui9!GL2qisM{cGZfUL;wxS0jTRBJ!lhts^2-uCJb^|61cuE ztGISIFv$haHJ#(6ZP=d*l>AGaq&!<d-&fv{BwFz;8)0ZvqX26TFdOnK{9|9!cGsu&EX(0&1km zJzXr4YtASZsQ@g}ynsiBkMO7&C_6PaeFGrEt3V~QB!<`}T8|tlBj=BaYH(*g(7~@a zDX|;@C}NR>5SZ`D0`oCVQFjJ+D2lT$6xn_B@)jaDSWX)mxRsrCwmZgC?J7{j2eA-~ zopq_v<;m-bW7SzNZA3)fxgF7Li{E`H2(@)~!;s)a^trO{Tot<PhUfZQJzTiMJ85 zI~u>Q~-IJ>-YA|(aMcCXQ)-^fT~kEpxy z1?XOL199%_b=P!`YWdw`w+AISlxj9OePDOjQ4e{FE}I*T+L?25flp|116wM2sNZ{5 zOlxMpEqYNQ?#-9!m5=AAmdzwlQ6Wqh64ko%*M{@d^q6Oo%JNj{*2YsfMP_%+QBa5p zgA7&NKwFp^uo-oApaa2v3Bp4MnpeoV+^z$2*)2v-qi2%KZ1L}J{r44#JmTCfp=H#$n}lZ4*)q|R zW}LG{eKDcU7H#1cPzeiHj1~OpYMol%Qy8xL_m`()Kd!IMzv16X@uk3Lv;>O-QeVPD zLkjlQ8tMGqmjx|7?V(^_;M!ZFV5jbA-1Aw?_K1Rg6(vEzKF-f23ib)VB8E(WJUus! z^?HN(=8e)#V}r}Tp;dQ^FMEnc6>^6t(e?)ME)?sd32c-Gk;kKo^;YKTI8&nK)ltRz zXfhimzNQs8ggLf>#a{OA+}MpenF`r~p`cE`I#>g;>-P7pro&3hL|f~0;-!Xe#jNh> zrFS6jO`#5+_)FF4$#alRnYYj+F#wWHEmfyyFG2OowN6u=E?tOvXZ|Cr>bwEkA+=Sf zbH1QLsN+!M;FC{AjZa45VqfS}eJmRj5tBsH_WKi;F9}e&rqTf8oM)?5s(uqy~#DfT-X5d=?sDdwV*U z1GTu4Cg2Acmy3dVJkkyF*)1g zlO3T!cA_=5TY!;YxgK4v{CclHDIZ&_&c!DqS1H@kIfxbiKYQN-9#wViKNE-q1ZDJYY4w>1nGRV8Rksx=$eB45hL?w{T~(wdFSP>-}`!x(;aYj%=Sz&b)<-EW$5 zTQ-ntZ6~Y~Fv^-uGRu5mshWUu3ljG7N!tn7xClDJJtC1Rv#DdD)04InaKW**6Y_OO z++}5NCt$Fk?<}_yf_Z+WLX-j3iURg_g4c0_enc_-_T|$*WeCt#Go4S%j)_nIY$gFK z*(k1WxEtzy#}^S1`1H?qj<{MY2Rs{~6tK2a?2$kucM&ouJ71ovh}gOqw!ZfB1OmPS zgCAq|;MM-#s=e{o3*z?oR-KiY?2$uG7?G^C{M7#5sMm-M3ciOwBN%eUWI*} z6G(y73xFYEnyW+VkqL8kaD)fm3T&ql9b6U$M$s+PtfD~QaN#M`e70g z;$Qhy2PZHmO4hG;cH&8eX_+Rl4gvFm+oaV21_xX`gsR9KWID0ZVk!Ia!d(fC4)NVt4p9`U3jxCaMQwYe1>re==R zJ-W~vy8@xN<&53^BSi3pCz&zhEW}fp2$HWf6{(*|6iUb`)-7I+(+swt@UUQ(qaKi8 z^W}g<8(*ACyalLcw?SDXQ{5HvO9j*Viy}d&W|CdXg@&?nY-j%rTNSrAm zRPme6+PrV#m-jYP%0TlGioYlcAH#`&waE^m>QB4@XZZj7~hw$Q1cp@M-Nr0u6 z$KciZd;8!E+gv2ToVL-*U>|%DFwVgj^039doVGhrUJkzWtYi`APU)p{oA3%}!@W`G zh29D`=1QLAaw?E^-)+$#H&8 zipGai zfnz5cc}F4Q%yTQqZeIng@JacBE!M+0?u%!)k8~(9 znPkg7QaE3TNzypsAtju5usmq&;yeTzd-w#6g{L9mlN-6`Ay-o{QcrL>fkbC0AcQE4 z!Y4Ajkhv!borO{*^cE@ch?1t0B=iX5m4x2PS0#c`)^;RxL(2YFCG-xICL*)gu_Ot- zQ{t6`p8OH#|9ziAB^CZx$m--lE^(cBaw8XE*!o}#J`s+fdDL0BlxzXVYKfI&C>h{c z@}}^zVwx67m7~tW(YD;)iUi8-QYYkg)=6~_A#>>vSlC>!6y^3zKG7ya@~NZmjOEYsFkA9WUlf9<2rL~8O;XF+cFFSiJ$VQDAZ8uwJB%I!?FPdl@CiZKoI z2g#IVjWG#~}omrN?L3EQ2Ky>3ep7i+- zCA#s7T);GQVH8SVAZQ*b=)o+LD(D^kgtH085%kzstM|tt=!ua>FmW_N50{}R+#D(B z$pI7T^Z?6VmUO~duB1`~JDQ-U2nIpVA()(NmPIjepIFnn3wj%tp@&K+rb=AV=oo?? zJ`(_P_;R}(+EOMnCk0NkkNL0i&mMUU-+%nX=zvLs0lQX}GNN8>bpRC!O$@-)jd6O1~2 zoOH}Y4$m-&JLVS2(u3@W^EJ$JRPnD>QjZk>IJr~&Tdf5?l#hU@R`(zQn(f4}WMhsY z2U_}dirSWZM`M*NW)uY#1P*Yv`4|G>HnFP`h<%V*dg&ZwZUq2}GO|WNCd?vPQ$Otx z0Y@B5VOxlFV{fSv=SqYvpf8sv{H*yr4wlU|Awv|9>*!*SY)VU)0}{2PqSd$CEN4c{)iZ z%ppsv>u-chl}wl?u_6;rVSdPj!pn+jhD)l*gu>CbOjw8n%7juU#whFL%u2`{r~(Tz zAxlvvoX#iOq)WOA|F4t@1vyG4WS@^yCKQB!ZJCfrO_m7-x!q+#!L%h4UjLI+nUIOL zOvvKhWI}~WlQN+++?EMnkq$WGfRYJS9w_QCS-lE)=pjEIrKCLb)*Drli;i?)D3qGW zohlPTRcOkDW-g3E>C4xb2?dNJ6SA+A3EBJOl?kPnmZ2Xf${pq3X1Vo7_3%iUP>z{O zr;jQVDuNwNCS+qp`6n`=5G+Y1oDSQ8t^CV%WI;<2C<2_M{SIr_9T3 zs4C*+Hf(jbEf0nshNrX>RAXasl~L;Gv+kUNsGpi3_ttW!z~bg6Ou7SVvCU8>I_gJ` z<76ei$`DV52p0=@Ye1nZosv`M-~n@?Li=dp(!8LPJJd) zZ=G=a#G>1ZCKOF9y1h=D=i+fw&}lKJ6S$FaPpDPhr_4ukauv>JW*TP_m@_4<$xM5~ zOyiuSRSlE0=dE&x({T#17J0OJzea5QRLO7>PNeeaC|O*qdR9L4aR4bi^T=_bRsBlh zc1RrLXNj95acdeMww{~ z(Q2ZkLs0Tv#Kw2y2Om^HHlB!8D47jIkv4CQ#Hp!5tNMw=Etfb8B{xajGZKf5m7wG| z2*od#gmExB`7Txa53Q;XKDeRHE0Q=SNOjX~LI5>f@RaGBI+#rcl+azu7n+U~E zkc1FUqOzM)QqVJ;M@i>lXCP^lQqVJ4(ylkt#+zx1V_cFZ2VMcwLQe_uX!FiOiTDTj z(dKPJHU~ZBa-emk#No?>!X1+&?gfc^#pD>D#Qjd?wqdAxqTw+K1W0S2}^ply;*cq(Tzppif*{^rg7JMLDkTA^krXm zeS!b>1@+*ky7pp6lIaU)7Y}ULgLU5E`Xu4SjNObAcm7@^=4KasgZ%?Aq2tZ5VVe0i zc!M#2uTl^0q=ZW@KE$yB{t~wD05g5)4H{rEZ?Mka>-J1S@w)MqEdsMW#~ok|{$3Ae zDPRc!mUOb4?L}j8%+lZMo*X*2<8E(0d_jDa%m0Ul;yR0H>j39dFC6cEI12w2MC;b4m0o;qP5Shnf53LTE&r}Q)%)F_DO%!+ zdBKhMWvfr#13SFIO)?_ZdSUYdE#>j5#f5(D#oay(W=E$gOad;H*p!jQ`9rvHGsv~U zI{>FR@y5a_T|;&FjQmPJJ_02*9^yl?rBDpifC_wv_Z6E)XuNO(25!(0zQ-VK^trnP zg^CL$rIp`m0@vvro|q~MR!+2){zf_;6#Yk|g_J$i6PrXDQH@&$`> zQsjCOL+l?}zp#2;53yv5%-J|0e-*@m>vHsP`sEk(!o16C9vb0t530HNG8|9rB+?h_ zDphA>2_t=B?`tLG;Ja@%t@vvrq$LCP%P1cbXG8oO4ohW*(WdP;yq>npCsr$Xwr2xrQa>$~ZFDXZNuu6`GTa;v#MtIS!FC z8DGirr9S857O?qbZT2g2_z^F-CW(w6xWt983I-m^a8(S(!{e$Lh)1TY;$%HEDoa0U zRE}01=#Pxh4(KSL-4xLVREr{!PuZ?DDpj`N4(}Xe_#dXE6ZdJQ*Lm=BOBQNWHafhR z+dp^-Es<>f*L|;-SeZCaXrBK6&kSa%;A3Xcqk`pTQ1<_x{^QJGr2=)41bsY9fzi&N zLjL3to8!?E-1vZBpPC@@P{y@Sn~flTU%~cc8s$H=3 zdF}N;UuLhQ_PI<)FiX0@<8^E0)?XiU+J%EJJoAIk@;{+xSC)>`Kdgto>7p^_v*flfLut?% zS2|!iEAxff_b`3QFfkUg?nb0Ozbcdr8$`2FyULC z?0*-p0p5idDa0Lk{-JKWes3<;_7=3R7+R}{f9NSmN~nl@p%;slX)5*wJ=APMqa_U) z>Q7cpE5A^cS=trMkj%=HM;|)CIhaU2DO7D-hl1Gl-~a%xANux_N)3a`$fL9iJGZxeGx`$mjE+``(rp%^d577FfE9YBQbb_^OQQo zYo7>Bx;@4gw&I-bYl(^{@S@{3m348BKM8@xf_80D(@5&*7|?Q{kD9ENKA&BQ&}6Ob z^Rbh4?f!$4wR;at)~4+rspWj3HGDZ1?;G8dwYE<&2(7%+4va+f+l>AQ(FCMxvp&+- z?MBQS_^b13i;R)zRGKds(`t&*FVwJ3YIp}VY(Vk7sG&t`*uomt0o1g8BPV;cSrNQ7 zy9@U>y0z->L45Mz5*=Z}0lX>!{LJQ31{A$}CuC3F)I^pu74i0AW?sJo3z~G6hhg_5 zT@>hU;-Ni3&T@N@@xw=ZlJTZ3Y5=}We`sw{ryx(bTU+!20=U`)^#X~4%Ce88fBVos zAZyy^HjX}wZ%#m?b7mpvf^mrL^08@Iw7&t@UEPbX>Q-L_%*8L0O3oZjx^f(2%#%Yf z{V2}Bi+RWcv?Vts0r(t2j&T<7;&S4^326ia;AFIB#Ks~1xU@4X@rsV-vmlxjL=DPa)Pis8-Om~4?UFV*E0PJ$_z{=9?M@*QlXYC%_{UJu~YfrAc`!CD9sy~JoYr^EuBO@BoBAXY7xYbh=9;io5BB1PNT z)|(G^h2cZ$f8=G8b0a{`78Z-=QaAjM!n+x6}$!iJbh&#M7dR52c z2XSkrHa8%goXQeh8_F?(kS|n6qUgvvzq>^)bnsyYDKw8{+H11cy`DFw74jN3OfO>G z5AI9Sp1QF_le-42G+XVQ;dbM)g&XjF$^rGx<&R!>Jy{cbN?hfbu!$@4A=p+Lh=ERV zZ92uZ;l8lBTtWydWG4;wA95FQ#c->HP4TVMOcW#`vBOM+_$J~SZm@`7Lj=uhhLM3> zyqGy~D5(igm?-ceac$n;&9sGPT13)TOWG4mgB6G@uT?FRxN5}t3mUY*&zQ)CiQ?N{x1_Q`TGefm{YRJ<)sTea0=uDAT_Fi2W-Q42fX0gG>JV+ zVuh`Vx`G4R+;k+wPmy@@%mc3K)U~Qr{AB#Re3>z;1+){@^2>*$rUNsRtJQ~L}K)5_z8{MRN+?vOWPcUDbMKsGEYCvHI zTq84K4u^U~?Ui!WeoV_z8#I_zTfn&~>KDC{6O^lMgWBbdb2fO_V}B=8PgqHHdSh`O z`VJC6v$s~ZY{jj5a9jrxIx_(SPl9qa6ld_=!*@OGZ`H%&JjU4*)mHjt$P&I#1vawX zGNhRqekU33J~G3$2@1jt$#CD186swe@yvi>q~TQHc3s1zgmGP4rH6_=vVF%R=!Y6* z9LB?h9Q`$Y=2N-X|K*cudcnS_Kh|HK_Q_wa&wXm9?k#v{rZOS-q=& zc|sr$ByZ9JlNl8r`12u9ScUx_-AQ%^DI>Ce(~{moi#aimBaaE%8)eUNc3GC~+K z67;|cG-le!pLu-9|1*XL?RUSHrEKPrsH>3--d8PLXf;1UR$r(GGTdUqx=J#1CnUny zz+7bJIhXYX4<}J#&(SI2D`-o`KM#-BtR#SA;-4pGkAeTUw3=DWA)7ZF|Lh#ls-0!x z|Ig`1;$II{2x<~}U3 zrf1Y9x$y$^;c@w^9Y-L5sh2f5uT^v4d;{F5YHLNx- zyYL1dM9;dQWNU#~c#}t8zO3qpo1P#4jYqIvqCc6KF4%mffD^dGm|gk)Aa<=*I^<(29g6`HA?mrY!zHo8t zilG2)%)|}V@!PnX{!4IwodkD?g1h02WVq)LZg0X3bGR2TL1Ww`da!sIUN5?s?ZWKU zSZr|m!;@iAmafATf!zqQBuZ58IR?F;XpuIPyc z6njC%8CbFvYozOWDpl$H8L4#s%YLbJ{$v?wz)cN(c-W-Kk@kgGE(Djz9^I+|_Js#g zErqt@urK(`y8iog_lJJ*->JKQUZDLubobW*a@%u`cruPABJ8_1(hDW@5PU2KQC7}` z^-^~7`hgu}R!mh8#c0q6)`ue!=0?UtZsN>99UkN00CMvTXkV9B`)v9#&B_6kpe9)M=Z?9n-X9^Qvi-zb)GXb1(4+ z<1NmXp+;U3hle+G6>!j`s)Of(yu+;kmfCu+`wKSFPy0JAOh#jw5w$=jgdrOrIH7X~ z?AdVcUwb@$KOKhi0c+%qyaP0&4%n}8 ze;w;k?I8fWDdLr&L+c32`Q11zwVvO90ltNnRDS_gK421fLbDxt=D;>#3NUG>_ccqR z0PUoeF(0nOLzO=Fr~csQK75H&ItM2h8&`r+abv*shBW!hL+WrEhdMSCYyaE{WvYL` z7hYRPK@(T%Ztyx?NcPF28|J_q7SjR|@Zta>aGm}h?`w7o*d%v5xR!h8Ow~f%VXYM!rLC9rOGW@~u9&fNDQ|GPP zk7L@dILps3QSk!}72njCSxD0hVpFGAr27jFPJI9dn69q;RY1S?SQKz@U7c*FZ6kpG zvLkF^@L1Q{$<33niY&K;EuUMioAU<8gIzlzY`p_vOZVu@9AOK5Q;2TY>nR&EwPYpF zcQQqN#~a-3jc(8Kx_5gIyrYZo$F}+~GjOzlTOp)v^1fpA3JEe#V1P#ay^7EZBD2jq z0Cp3+*^D3nOiw3ouLoe)AWg*s%h?wQFt~HUmj}LFH)kqmo~_CQOGdNL-Kbd97yMji z5W16)H|dxJ4<6+bLLP7Q55TNvdJ5Uh;IJcHcgT+N5CEtbsKO}Pz<~wd4?gHaBqL+3 zIWp*N2ELK;VAcu$64S%xE&l-cmmH-|uq9~4I^|*2iAKX`rd~N_eWo0yhB z16y@CuCPwk&z6)qLFUYepQD;{=4Sm>tDkI%M4O5Tzqb*_%LU> zJNfVx?55|+#`q#|uFm_Keoq#@Sby0fwzpu*G^}_=yow2DIP8JDV);!8*cGg-Z9jZf zcpP3NhIo-|5pAKfn|R$~aIgCyaMs=b!(wpps!8(yc-3NT`7Rs2rrKn8P(~#@wKgWEZg!h4`sQ41RA2j2|#Ou*=duMaC%A{!nhV|artVtVy z0Ce=KKC!?(f#}Ivxi1@9`*Lo1AVOqL*0x3`Ynvk@x^V5jR(&@1?_NPOQSZ>gYmY+j z%hsgpef_>C_wK8W|8|x4m9uaA<-9jPxp%1k`p*k)nELBeH>B$?59qr2KelYq`xNKz zkMEVb#r`9+lt6bkQqU~)2Hv&}Ul#4<7T>$5SG2x}=d7JdAzQ-BYnEAU!4yv|{&;*; z!B#3#pFQ+02sNz4o))25_2xn_Fh5DiI)*-+?MY6TW5v8Q?O~|YX*eB(8>j&M*y!wr z$t}Ib@YxgoVY3AOCv_`87jCg{D?{qrz!(buW=z!=jx62_;G zMv{~zY_IOup8v@04SqqRgFBWiK1-KsCO>N%&qT>YpwW_E@ zOu(n})X^RB>4{7L*cdsRgbp%L6D@RRdsF#s1-j09fm!dqYP&0 zh`!E{AqRr`vNM$RQMP)YX$$D+3%&@Pv){VB!$3ZJLB}^Nz&eX&u4GjqA6b1)GocYVNA!WAdzDv1*f8L#VvF+ciHn`58WM1J+nEPR}sSzJCp`gb;hyj#k zau25F07`jf0-DMTmAr$*Oq3blkCPey!c0iBK%pMomShyL>@J!Z0}8Qb$$|)N9#_|t zHP}-A@38eFavCHA=Yng|%V^hi+zcC7d0pB`k_YL-+2|bdWge7TKOy15{cqUP-j&-l zFMWQ$bOh7y?wjsnaE=O2G=taS3BBv>Dt3w)J6dAXuLUHQ@k$U?7_a{(&8pXGptKLF z#!z3qW>rNx6;(JAQIcU?fYL^ys(S&+AMRh>kIK39Kj5)qFmqn`W9r3LuO5dCg*1f~ zLg&{B4!{WWcfdGcbbSN507DN<&v2EWiHB#vv@?wn(4Ye&`Rje5VhC{cg%jczA*xnh z>x?s)9P(p#U(_ktRF+c^ZSfC>Gb_!~!#^%$UOhCKpa3osr*@Kwcr$t& zgrrJzKzEEXR;vx*J&X=gM+rryfoBV%eg}aCudR4}=_E;AjIJ)hf-is2h+=*@D*ui%f-bxsF}uH~RYUItN#I0}Q4*aTSj(+`mIY9i%7 zc-B=2@%chd>yQZP@d8m{3WYxKy~uW0m81_CeQn=0cFjq1NtyE1;4+us4gx3rNK1_P z5VQ-BvRHB9Ud;m!6IrBkxNc#Ax_6z{@a9-8=k&Et0oOU_wD6I8S9V?l3c0l$c-)23 z|8!!y>ux>N@0{^ywI1pn)p8e>NCy_9t&oU!B4Uz6EG$!xO7)d~wo zt?JRC9-J;vz@r{n>OpNNA`8`{L>}|Y@PL3_J4u31)XJl>5)Zwxav>i4r-$#(gfe+T zU84(BsN{rl5!WG^P;mZi&c~U^Ia@dAn~Fuv&D;Y zYs8Cb1_qc)WYKbYF&}kBa3D`VJwqs~RcB)^n+(>NpUdq;D809pP=&CNP^+1zlHz^L zGOdz?wre#Xs-(B9q{}l{Dve23;C1q7t#l-R9`wNH>z*vi9j$s;huCpH?Iu}xMzxz( zX*X@v8gP42!@C9qk`2g_hLkN%j!(1(1IZsV8S0NnFC-hVuMj{)YiI)2OJiEYYNkh7 z;W_{L`!v@wcoVuN7i*;-^XFauyv?7t_|t%Fvth}eT%?tD@u!_X2a?6D(b9v&F62yn z!k-UNFtHi=+IRA2BY(E?XC>-y+N+h-FWsk=wJycmO1?Got&VRke2elevXqcM*~(i8_1kkyg5z<*`Yd`dJ3bZzI@^01Hnm6}^|y>lnQi(P+7f-pA-I zj21jt(H)E?HICU^l-kdt27lN(>524v5FqXE10Z{>E;HtH#$d0Zy2h9TjB$E92#^>j zz17P07eM&^gg;#cpr84>e=Of4^1BKim1V#@A3Gnz&wBoB=bb*SdXSZ<;S$)C*^SdqcZ#&PPqiCHw7m%uG?tS}>g%n5R~O zNt?8sK3rW30q#o7iO5)jUqHqvIj0M9(LT&d=OR>k7cv!L=)eK=ryeGEFzKggLn&+@ zm@al9WpbVlJ*S!nQE&TBtzjE#+{OCd&~DsDK*JUTEGMo{3E+N#`Z3DhkD5#GMg4#s zW73#O*yt%>iMIiu37Kvz1Jw0e!#WolbN?!>VUwZVxQ@`?XOTZ}X<;+JK;GB5vJ%=F zCOx{4AuWM#Uq%QN8UDbl+<=DiIjXJ2Gn3i?tr+SSw_XwPxm6t7wk3uEIZvuuxpBy}Wk; zYcY-)8>h?w60uMQH{(z{&ceX0W=lVZWF|TYe&X$U~$X9>+ zf2!i2W4$(>dyqY*2vPkiJt_YuP`%&da;Xgkq%W!iV$&fuwW=4~SD{{>7g-BQi^OXc zFHdGKR7uKF5;01O#tUTXSZD6T*^}^#10As$r9aB5FFH8O@P-TB^*aU(iMTi_>UYc- z((FcDT54Pq<9emWMcnnT?VrT{F?Hjqv5iwd?-%<=+b6MC z6Oq`)#H!dQ?alSu?{mE!`>=gW{r0J9QzWhH-7ey#ZMQ1)<1@yt&iFDeA&J}_Idl(9-Hn@yulh#Q+tOr_0_4HPK~XB zJQW+!)_@nQ;TzjNjg3gOqTzdDPqnWEmRYSLPlFi_ z&90I$^lYM@@2!mAmLSt2)WJ~agRxG`;3fFwn3>K!DSoZtgvnd#oQ}zRA@fhiR`pfexJ4zebN4mUy%V3?Pt8;@Eec?LEtd1%F+`l1=7n^1Z0Y;*8)3c&m_qjU#< z>W~NL&a^WX`k6Qhkaor9U=SQG+rKpSP+4MWebk*;Ddo#zPh<2)r>;LW7N5GeUo76X z86-7`r2Prtaw%+tao+_FJK;nvT}BM^Grj zLQP9!`3b1U>WgyQ-vxI}-GB+?+Nt~c#eR$7auADqVQfa?E^yFq+jpYS&ZV(KWuGmL zc@vGXg7%jB=nN@Q#1hfArdS-+pg-*W95f9mItM7K|6!4<1xV`z%iRU;*@9UCT^htL z^^q=-SpPLZ9~ai3QP}gFs?l?AkaLRfIB!FKYP&d!<>L0Dtm*NYXNn(qWFeW*|dJKZEdSi z5KUwG%~JkLmInt2Z2Y!&DS{m$)qa#{M|aVkBnZtY`d$I6-aEFwXa+>&Sl`4x_Ocx% zKOq6|;{{sgpyaBglKW8-zA^A&+wX9tPksc;6WOLDc2=SZ(W_!l+bBD$Z3`&Q`NhNzr_R$aK#}_GMXtlKUtqG? z)4md9ZXSd_cJztOXxkkNCK?1H5Z%79e*2GHZJ5Y5#^UX0|I|%`(53x-V&1k6%%Jrq z)_&XGQNP{m+6UH0S#)Lj)VBwr@t^gHEpOY&{%(DYzcKmU^%xL=t{yj;h{p%E{m zve0wRyQA{#W#(qgHWWP~u=q69*WEk>E+?F%?)(v?5x^c;PH6GOV6FkTV8yt3$UB(V z3liH9{$YtXx*HSP9Yfx6gPI>PY7)r6d45!xj82)@IURB>h$6m9CdnvxG>@T$oGG78 z#P}`~Du*-|kg5n%mLgQhP%}e8>4sW0RzX@pdTt9y9<3_In)9)ExMVp19IgvkJh)EO{X4aB*nZ6rm6$9Q2sL6KbcwlMs8=Y; zFw1e(;7+a!+oDT`bjo_7US;t>$<33I(2TuRshf0^kefxk+&Y#Hdj{HJTN}0wWlOgp zUN*gW|CA98{MiHu9r$a-pFw*gawR_J&&T|s&MeW4rnM)KVJ%+JCarXEHlyC-PbYuY zL))=xDYkEA2dVgYlZuZmd|S&miF<=@tCpgB{a9G()ZDQ}fe<`fCTGMSVqO*=JOLZNtuWFJgO8p*12J+hwI$>tJS4QytOKVyzvba_I)v zOGVfRd}+bUWzyqAcfe(rR|;9sHEuCH+b4 zU<8$pPHzS|nk_`zybL)N1ax*ki3zV9MLz_4;l*LQA+fCO)QT?*rg+0t1)T)5u2-M4K^L_g5p)- zaJWl!#K{zApAuw_#9;!mdt*~PwKq1!Q+vZ`(S@e=#-=#>r<7fyz}#=@E5uK`l)QQGCfL#8e`4RTCB_bAc+91qvhep!6Q}u!^&4VmpicaUpXdbdaGHTx%HG%+N0* z3;|pNz{x5Ga9H6?jg_or0vbn6IA61t7Pbw0BIMzzZ&~C1ohl~T82$IGZ|FA;)Dvs8 zT2;yu>Nxc0EF?iv!JExKg>H2B{s{Z6-PEPs1lvx$7yd*G*o?_*qnLy6UGs)_pvP>h z1G4}vvItrQ38AN~rfJAhiDIg5drxaP3{Zy&YBO8&y(eIuv=_tAG1jr3;O9O5Z03)Q zZmv1B9BHMTQNA$k)$FN^ltepT6Z`qIhClD3N$psdN+BEb^?m+C`LjXIjTl@}b2Y^4 zI=(h>Hk3$bIpj=?^^mh_f{SVCdcJK(6{YBKX>Tq(FdR`VU>tc0wM#{uFpsnV$%NQ_ z4Lqt9sTDA~FQb6pQ)gwp--On+W4RN?MQ1^*(*D1x#DG>SAa+ndBUeN&k^EJ&$#7Sc z?LN$(?I^?P6?jG8)-x)Cw|2WfgE05mHXf%xjJC`}?YjX$!~({eCL+MF`@?8+A;k(* zfH}^w3t^FT7Q#IAs0>RZh_qf7NZdG}mEnK{r&QR4)FdfSWgI#JH|V31h-CG6pBSPT z&|arjMF4n*)ECRphNXK^bt4%8CPKWzN`wqzB*a3<##l%KS}2Iv<{}eAnE0WP!;jef zME)YfHdxWJ1a=UgL{ZF&mUR?kq8(yVX}uLI%V;YpklZU_J1tQUAXu#dX$^li@rPn0 zm=#vL2;ihYg~Np;H-sw+c6bBR4tFuba94zI#r{ZkMrV}aQ@R9DB)4>iq!Mzcc81YH z?$pjOTIik98O92^!2lFvKyLnsIS4BxNZe(~KzIkCWua6OF$JLlM6ARQXCA!P35PVR zRk2fbEIpcm}pe^DLUyWN{EOQYM z)P_F{ixPkMieBax4OS8-VUjmc2Nhw9675w2y)7D6Vq#k<^Cpl%aX*{{m?A|cXg0d4jP1+8T`0-~78Ovd2nr0{#1f)V1fVu# ze@+%$z*39nLM$Kx4$Tp8qE=+d78C5GWU#OtMG5hD5h<700f>KWwqUS#YZ0@cRmI5a z&o(aTK*Ya$fsJ)~((rec#~Y%K(2YN@2maqVdSg)*>hGqWTn$g6DaxNLoL+`xy4U0- z-T3#&mvr;N89biIE5r^Jo+YERbwZ-+o4DhQXVfI7@jTveBj;A-jWh)-KD?e*eU#=} zk%LqU0Q$1e@&{)FADOP~O?qy# zuOOlY{(}{|S8B4k*TcR0>IKcZc4af%`Fbm7B<;$TD?G>r7i~GX)TsR;QdUtkA7>NZ zg!@`~FFkaIH@d5rUbD6QbUnJQcll|?)>3vD9%fz~0Kk#&9SFCbrq^_pXB)3OX`RM0 z25KTaaE)7X_8LRnbZ+wDd`$2JJ=zWf?JW3Cs&kyM=bbIdtJTp@^Mb{~6vsCloA9!e zX}-`x>IOpTKiJhf)#oU%jCgcc^wqax%s+ycEtct&Z7t7m3nwwhfxqhi$fJ1-VmHoD|u+sm)nXi%(}@N zI#Uk?D$y}L(AueoPyyU0p|tXy)v8BZd&B(-M-6XC%kYLD&c^iD$~s>ru9l&7t_qwz z9Es8OF@tvTuq4p<2Sy!?&QW zC*-wel~!{nOfhsg)oO0$TQF*@dIo2uZ(tytUgK0yWmmA9Ei)?^%nEqIsJIn>AX23w zQdIAXYSl!h9tcmzNAvh?3CA#(zera zxtd%n?%gSaGD{po!~PDVSE|54k(^K#sd2 zJ$`L}Oqpu-%aYj-m$+i08mfmb$LXiu@smJ=THHnfqSbYj!=t;8D)}*Q&O8-`+qUbW zF&P{<<@mz+MTYAq_SA-)_3T!>^T*12mI2PlN3Ad2t}hGqwRW#>5$;d4>q}Sl)dIFT zmD=^q?^fTq8?gfEG7mzCsNi)PBx_eF{d^ET#o-bQI7WGOr2M4I+~pVMN8(=s0ipC0 zQ}bOI)x(dravX#Qo{5KXN$)gQ!(0}1htl6`?FJOf{u1WKGksDyg~{2KDog=$c4Y_C z;D6JB397t84-Ndu2N?G?>&s8ZX#M%m+&Bidvi!ofTfuEBI>3N*^*0`@KzvOEIqqW) zZFI999C$tphSP6VWh!zZXX^a`MCx$zF}eVkL*vH&Sesz(1nKIBBe%7>!vEUAzn1HuAO5lV6wM&E zxEqSH(rgsW1`4LU@1Wq3hb$D_%>mbLp`g6nLcx!jL!saYEErB7ugX+hkcxtAqTv21 zK*3G;u~6XjG44aO6GWFsbGGAn?B3tW{f^e(Q2IM)L<(PNIq8$9(&M)>0-Pq{hFB6r z!rwlN70D48INc*?)KvigC#^2g0|l<~eiTb^pGj+)>*l)UoE_KX+Wxh?;31T+hi2d; z04hM6LGK?JQ}3bEG{qC?sxbv>9k=Fi#Io&j7sFKR^^j2nSD zH)ioFTFaW0$i$NhSipi&8gS{`N$hO z0c&U;=)!f9Z3|Vp7|L$~w$k+A%vP;xF5)rJ?$2?{oCA`KS4)CCRN z-a7SHcYQYvQ=L}x76PLh&|!DH z4zFXw7moA>8u~J-ulp*5^gx5hS(Gthtf|oyH<0E4Z(b-!sWSW)YU&ldkUH=uJMNYWj7S%kl|CX8^7l+G&0=( z);4Qy$z&I#{U@`qR*VZJ(l_8lB65Afskfl1d?~8wU=$?ua}ocxJvqNqYL#B{SG#l! zS;2DM>(;wkjd#9-aT<&jRetWaIInsx{O4^!kFoQ3Z_6vHkToal8ur` zzIhYm4&C@UK#`~iF*rcM%R$sn@tOo_L=vP`p9-YCTazKJzLByG#GN`uW1+wFVn*2h zRY~%Ue1(LUbmU+tmtYWg7!%QeTFxjb?CY>kiagXq`4=$SzE1h~b9<`anZ2+cSsT8> zlu3;DP=s*TTERQo+`n;n@>BuN$VYed@r5ru!-th)<

MDE+US)iA0Vifs9j1->&f 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

J>KKY4lY@^A4%@ABXKayt2o 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