From 0b3769f7f42c632f6ce58abeb7885edf03a9d168 Mon Sep 17 00:00:00 2001 From: Andrew Whitehead Date: Fri, 7 Jun 2019 12:40:07 -0700 Subject: [PATCH] add automatic parsing of field-level decorators (field~decorator) and update handling of field signatures Signed-off-by: Andrew Whitehead --- .../messaging/agent_message.py | 145 ++++++------------ .../messaging/decorators/base.py | 59 ++++++- .../messaging/decorators/default.py | 6 +- .../signature_decorator.py} | 25 ++- .../decorators/tests/test_decorator_set.py | 15 ++ .../messaging/tests/test_agent_message.py | 4 +- .../wallet/tests/test_basic_wallet.py | 6 +- 7 files changed, 137 insertions(+), 123 deletions(-) rename agent/indy_catalyst_agent/messaging/{models/field_signature.py => decorators/signature_decorator.py} (86%) diff --git a/agent/indy_catalyst_agent/messaging/agent_message.py b/agent/indy_catalyst_agent/messaging/agent_message.py index ef2b93671..22c5469d7 100644 --- a/agent/indy_catalyst_agent/messaging/agent_message.py +++ b/agent/indy_catalyst_agent/messaging/agent_message.py @@ -1,7 +1,7 @@ """Agent message base class and schema.""" from collections import OrderedDict -from typing import Dict, Union +from typing import Union import uuid from marshmallow import ( @@ -17,6 +17,7 @@ from .decorators.base import BaseDecoratorSet from .decorators.default import DecoratorSet +from .decorators.signature_decorator import SignatureDecorator from .decorators.thread_decorator import ThreadDecorator from .models.base import ( BaseModel, @@ -25,7 +26,6 @@ resolve_class, resolve_meta_property, ) -from .models.field_signature import FieldSignature class AgentMessageError(BaseModelError): @@ -42,19 +42,13 @@ class Meta: schema_class = None message_type = None - def __init__( - self, - _id: str = None, - _decorators: BaseDecoratorSet = None, - _signatures: Dict[str, FieldSignature] = None, - ): + def __init__(self, _id: str = None, _decorators: BaseDecoratorSet = None): """ Initialize base agent message object. Args: _id: Agent message id _decorators: Message decorators - _signatures: Message signatures Raises: TypeError: If message type is missing on subclass Meta class @@ -68,7 +62,6 @@ def __init__( self._message_id = str(uuid.uuid4()) self._message_new_id = True self._message_decorators = _decorators or DecoratorSet() - self._message_signatures = _signatures.copy() if _signatures else {} if not self.Meta.message_type: raise TypeError( "Can't instantiate abstract class {} with no message_type".format( @@ -140,31 +133,20 @@ def _decorators(self, value: BaseDecoratorSet): """Fetch the message's decorator set.""" self._message_decorators = value - @property - def _signatures(self) -> Dict[str, FieldSignature]: - """ - Fetch the dictionary of defined field signatures. - - Returns: - A copy of the message_signatures for this message. - - """ - return self._message_signatures.copy() - - def get_signature(self, field_name: str) -> FieldSignature: + def get_signature(self, field_name: str) -> SignatureDecorator: """ Get the signature for a named field. Args: - field_name: Field name to get signatures for + field_name: Field name to get the signature for Returns: - A FieldSignature for the requested field name + A SignatureDecorator for the requested field name """ - return self._message_signatures.get(field_name) + return self._decorators.field(field_name).get("sig") - def set_signature(self, field_name: str, signature: FieldSignature): + def set_signature(self, field_name: str, signature: SignatureDecorator): """ Add or replace the signature for a named field. @@ -173,11 +155,11 @@ def set_signature(self, field_name: str, signature: FieldSignature): signature: Signature for the field """ - self._message_signatures[field_name] = signature + self._decorators.field(field_name)["sig"] = signature async def sign_field( self, field_name: str, signer_verkey: str, wallet: BaseWallet, timestamp=None - ) -> FieldSignature: + ) -> SignatureDecorator: """ Create and store a signature for a named field. @@ -188,7 +170,7 @@ async def sign_field( timestamp: Optional timestamp for signature Returns: - A FieldSignature for newly created signature + A SignatureDecorator for newly created signature Raises: ValueError: If field_name doesn't exist on this message @@ -196,12 +178,12 @@ async def sign_field( """ value = getattr(self, field_name, None) if value is None: - raise ValueError( + raise BaseModelError( "{} field has no value for signature: {}".format( self.__class__.__name__, field_name ) ) - sig = await FieldSignature.create(value, signer_verkey, wallet, timestamp) + sig = await SignatureDecorator.create(value, signer_verkey, wallet, timestamp) self.set_signature(field_name, sig) return sig @@ -226,15 +208,15 @@ async def verify_signed_field( provided verkey """ - if field_name not in self._message_signatures: - raise ValueError("Missing field signature: {}".format(field_name)) - sig = self._message_signatures[field_name] + sig = self.get_signature(field_name) + if not sig: + raise BaseModelError("Missing field signature: {}".format(field_name)) if not await sig.verify(wallet): - raise ValueError( + raise BaseModelError( "Field signature verification failed: {}".format(field_name) ) if signer_verkey is not None and sig.signer != signer_verkey: - raise ValueError( + raise BaseModelError( "Signer verkey of signature does not match: {}".format(field_name) ) return sig.signer @@ -250,8 +232,8 @@ async def verify_signatures(self, wallet: BaseWallet) -> bool: True if all signatures verify, else false """ - for sig in self._message_signatures.values(): - if not await sig.verify(wallet): + for field in self._decorators.fields.values(): + if "sig" in field and not await field["sig"].verify(wallet): return False return True @@ -341,13 +323,8 @@ def __init__(self, *args, **kwargs): @pre_load def extract_decorators(self, data): - """Extract decorator values.""" - return self._decorators.extract_decorators(data) - - @pre_load - def parse_signed_fields(self, data): """ - Pre-load hook to parse all of the signed fields. + Pre-load hook to extract the decorators and check the signed fields. Args: data: Incoming data to parse @@ -356,39 +333,32 @@ def parse_signed_fields(self, data): Parsed and modified data Raises: - ValidationError: If the field name prefix does not exist - ValidationError: If the field signature does not correlate + ValidationError: If a field signature does not correlate to a field in the message ValidationError: If the message defines both a field signature - and a value + and a value for the same field ValidationError: If there is a missing field signature """ + processed = self._decorators.extract_decorators(data) + expect_fields = resolve_meta_property(self, "signed_fields") or () found_signatures = {} - processed = {} - for field_name, field_value in data.items(): - if field_name.endswith("~sig"): - pfx = field_name[:-4] - if not pfx: - raise ValidationError("Unsupported message property: ~sig") - if pfx not in expect_fields: + for field_name, field in self._decorators.fields.items(): + if "sig" in field: + if field_name not in expect_fields: raise ValidationError( - "Encountered unexpected field signature: {}".format(pfx) + f"Encountered unexpected field signature: {field_name}" ) - if pfx in data: + if field_name in processed: raise ValidationError( - "Message defines both field signature and value: {}".format(pfx) + f"Message defines both field signature and value: {field_name}" ) - sig = FieldSignature.deserialize(field_value) - found_signatures[pfx] = sig - processed[pfx], _ts = sig.decode() - else: - processed[field_name] = field_value + found_signatures[field_name] = field["sig"] + processed[field_name], _ts = field["sig"].decode() for field_name in expect_fields: if field_name not in found_signatures: - raise ValidationError("Expected field signature: {}".format(field_name)) - self._signatures = found_signatures + raise ValidationError(f"Expected field signature: {field_name}") return processed @post_load @@ -406,24 +376,8 @@ def populate_decorators(self, obj): obj._decorators = self._decorators return obj - @post_load - def populate_signatures(self, obj): - """ - Post-load hook to populate signatures on the message. - - Args: - obj: The AgentMessage object - - Returns: - The AgentMessage object with populated signatures - - """ - for field_name, sig in self._signatures.items(): - obj.set_signature(field_name, sig) - return obj - @pre_dump - def check_decorators(self, obj): + def check_dump_decorators(self, obj): """ Pre-dump hook to validate and load the message decorators. @@ -434,28 +388,23 @@ def check_decorators(self, obj): BaseModelError: If a decorator does not validate """ - self._decorators_dict = obj._decorators.to_dict() - return obj - - @pre_dump - def check_signatures(self, obj): - """ - Pre-dump hook to check for the presence of required message signatures. - - Args: - obj: The AgentMessage object + decorators = obj._decorators.copy() + signatures = OrderedDict() + for name, field in decorators.fields.items(): + if "sig" in field: + signatures[name] = field["sig"].serialize() + del field["sig"] + self._decorators_dict = decorators.to_dict() + self._signatures = signatures - Raises: - ValidationError: If a signature is missing - - """ - self._signatures = obj._signatures + # check existence of signatures expect_fields = resolve_meta_property(self, "signed_fields") or () for field_name in expect_fields: if field_name not in self._signatures: - raise ValidationError( + raise BaseModelError( "Missing signature for field: {}".format(field_name) ) + return obj @post_dump @@ -492,5 +441,5 @@ def replace_signatures(self, data): """ for field_name, sig in self._signatures.items(): del data[field_name] - data["{}~sig".format(field_name)] = sig.serialize() + data["{}~sig".format(field_name)] = sig return data diff --git a/agent/indy_catalyst_agent/messaging/decorators/base.py b/agent/indy_catalyst_agent/messaging/decorators/base.py index f2269773b..ca3e55a49 100644 --- a/agent/indy_catalyst_agent/messaging/decorators/base.py +++ b/agent/indy_catalyst_agent/messaging/decorators/base.py @@ -20,9 +20,46 @@ class BaseDecoratorSet(OrderedDict): def __init__(self, models: dict = None): """Initialize a decorator set.""" + self._fields = OrderedDict() self._models: Mapping[str, Type[BaseModel]] = models.copy() if models else {} self._prefix = DECORATOR_PREFIX + def copy(self) -> "BaseDecoratorSet": + """Return a copy of the decorator set.""" + result = super().copy() + result._fields = OrderedDict( + (name, field.copy()) for (name, field) in self._fields.items() + ) + result._models = self._models.copy() + result._prefix = self._prefix + return result + + def _init_field(self) -> "BaseDecoratorSet": + """Create a nested decorator set for a named field.""" + return self.__class__(self._models) + + def field(self, name: str) -> "BaseDecoratorSet": + """Access a named decorated field.""" + if name not in self._fields: + self._fields[name] = self._init_field() + return self._fields[name] + + def has_field(self, name: str) -> bool: + """Check for the existence of a named decorator field.""" + return bool(self._fields.get(name)) + + def remove_field(self, name: str): + """Remove a named decorated field.""" + if name in self._fields: + del self._fields[name] + + @property + def fields(self) -> OrderedDict: + """Acessor for the set of currently defined fields.""" + return OrderedDict( + (name, field) for (name, field) in self._fields.items() if field + ) + @property def models(self) -> dict: """Accessor for the models dictionary.""" @@ -47,16 +84,19 @@ def __setitem__(self, key, value): raise ValueError(f"Unsupported decorator value: {value}") self.load_decorator(key, value) - def load_decorator(self, key: str, value): + def load_decorator(self, key: str, value, serialized=False): """Convert a decorator value to its loaded representation.""" if key in self._models and isinstance(value, (dict, OrderedDict)): - value = self._models[key](**value) + if serialized: + value = self._models[key].deserialize(value) + else: + value = self._models[key](**value) if value is not None: super().__setitem__(key, value) elif key in self: del self[key] - def extract_decorators(self, message: Mapping) -> OrderedDict: + def extract_decorators(self, message: Mapping, serialized=True) -> OrderedDict: """Extract decorators and return the remaining properties.""" remain = OrderedDict() if message: @@ -64,24 +104,31 @@ def extract_decorators(self, message: Mapping) -> OrderedDict: for key, value in message.items(): if key.startswith(self._prefix): key = key[pfx_len:] - self.load_decorator(key, value) + self.load_decorator(key, value, serialized) + elif self._prefix in key: + field, key = key.split(self._prefix, 1) + self.field(field).load_decorator(key, value, serialized) else: remain[key] = value return remain - def to_dict(self) -> OrderedDict: + def to_dict(self, prefix: str = None) -> OrderedDict: """Convert to a dictionary (serialize). Raises: BaseModelError: on decorator validation errors """ + if prefix is None: + prefix = self._prefix result = OrderedDict() for k in self: value = self[k] if isinstance(value, BaseModel): value = value.serialize() - result[self._prefix + k] = value + result[prefix + k] = value + for k in self._fields: + result.update(self._fields[k].to_dict(k + prefix)) return result def __repr__(self) -> str: diff --git a/agent/indy_catalyst_agent/messaging/decorators/default.py b/agent/indy_catalyst_agent/messaging/decorators/default.py index 4c48493d2..2061032bf 100644 --- a/agent/indy_catalyst_agent/messaging/decorators/default.py +++ b/agent/indy_catalyst_agent/messaging/decorators/default.py @@ -3,12 +3,14 @@ from .base import BaseDecoratorSet from .localization_decorator import LocalizationDecorator +from .signature_decorator import SignatureDecorator from .thread_decorator import ThreadDecorator from .timing_decorator import TimingDecorator from .transport_decorator import TransportDecorator DEFAULT_MODELS = { "l10n": LocalizationDecorator, + "sig": SignatureDecorator, "thread": ThreadDecorator, "timing": TimingDecorator, "transport": TransportDecorator, @@ -18,6 +20,6 @@ class DecoratorSet(BaseDecoratorSet): """Default decorator set implementation.""" - def __init__(self): + def __init__(self, models: dict = None): """Initialize the decorator set.""" - super().__init__(DEFAULT_MODELS) + super().__init__(DEFAULT_MODELS if models is None else models) diff --git a/agent/indy_catalyst_agent/messaging/models/field_signature.py b/agent/indy_catalyst_agent/messaging/decorators/signature_decorator.py similarity index 86% rename from agent/indy_catalyst_agent/messaging/models/field_signature.py rename to agent/indy_catalyst_agent/messaging/decorators/signature_decorator.py index c833656ce..23c5249cc 100644 --- a/agent/indy_catalyst_agent/messaging/models/field_signature.py +++ b/agent/indy_catalyst_agent/messaging/decorators/signature_decorator.py @@ -13,13 +13,13 @@ from ..models.base import BaseModel, BaseModelSchema -class FieldSignature(BaseModel): +class SignatureDecorator(BaseModel): """Class representing a field value signed by a known verkey.""" class Meta: - """FieldSignature metadata.""" + """SignatureDecorator metadata.""" - schema_class = "FieldSignatureSchema" + schema_class = "SignatureDecoratorSchema" TYPE_ED25519SHA512 = ( "did:sov:BzCbsNYhMrjHiqZDTUASHg;spec/signature/1.0/ed25519Sha512_single" @@ -50,12 +50,12 @@ def __init__( @classmethod async def create( cls, value, signer: str, wallet: BaseWallet, timestamp=None - ) -> "FieldSignature": + ) -> "SignatureDecorator": """ Create a Signature. - Sign a field value and return a newly constructed `FieldSignature` representing - the resulting signature. + Sign a field value and return a newly constructed `SignatureDecorator` + representing the resulting signature. Args: value: Value to sign @@ -63,7 +63,7 @@ async def create( wallet: The wallet to use for the signature Returns: - The created `FieldSignature` object + The created `SignatureDecorator` object """ if not timestamp: @@ -74,7 +74,7 @@ async def create( timestamp_bin = struct.pack("!Q", int(timestamp)) msg_combined_bin = timestamp_bin + json.dumps(value).encode("ascii") signature_bin = await wallet.sign_message(msg_combined_bin, signer) - return FieldSignature( + return SignatureDecorator( signature_type=cls.TYPE_ED25519SHA512, signature=bytes_to_b64(signature_bin, urlsafe=True), sig_data=bytes_to_b64(msg_combined_bin, urlsafe=True), @@ -120,16 +120,15 @@ def __str__(self): ) -class FieldSignatureSchema(BaseModelSchema): - """FieldSignature schema.""" +class SignatureDecoratorSchema(BaseModelSchema): + """SignatureDecorator schema.""" class Meta: - """FieldSignatureSchema metadata.""" + """SignatureDecoratorSchema metadata.""" - model_class = FieldSignature + model_class = SignatureDecorator signature_type = fields.Str(data_key="@type", required=True) signature = fields.Str(required=True) sig_data = fields.Str(required=True) signer = fields.Str(required=True) - blah = fields.Str() diff --git a/agent/indy_catalyst_agent/messaging/decorators/tests/test_decorator_set.py b/agent/indy_catalyst_agent/messaging/decorators/tests/test_decorator_set.py index 41051ed50..4c7e7e86f 100644 --- a/agent/indy_catalyst_agent/messaging/decorators/tests/test_decorator_set.py +++ b/agent/indy_catalyst_agent/messaging/decorators/tests/test_decorator_set.py @@ -59,3 +59,18 @@ def test_decorator_model(self): result = decors.to_dict() assert result == message + + def test_field_decorator(self): + + decor_value = {} + message = {"test~decorator": decor_value, "one": "TWO"} + + decors = BaseDecoratorSet() + remain = decors.extract_decorators(message) + + # check original is unmodified + assert "test~decorator" in message + + assert decors.field("test") + assert decors.field("test")["decorator"] is decor_value + assert remain == {"one": "TWO"} diff --git a/agent/indy_catalyst_agent/messaging/tests/test_agent_message.py b/agent/indy_catalyst_agent/messaging/tests/test_agent_message.py index 581b80efb..e86f029af 100644 --- a/agent/indy_catalyst_agent/messaging/tests/test_agent_message.py +++ b/agent/indy_catalyst_agent/messaging/tests/test_agent_message.py @@ -2,7 +2,7 @@ from marshmallow import fields from ..agent_message import AgentMessage, AgentMessageSchema -from ..models.field_signature import FieldSignature +from ..decorators.signature_decorator import SignatureDecorator from ...wallet.basic import BasicWallet @@ -66,7 +66,7 @@ async def test_field_signature(self): msg.value = "Test value" await msg.sign_field("value", key_info.verkey, wallet) sig = msg.get_signature("value") - assert isinstance(sig, FieldSignature) + assert isinstance(sig, SignatureDecorator) assert await sig.verify(wallet) assert await msg.verify_signed_field("value", wallet) == key_info.verkey diff --git a/agent/indy_catalyst_agent/wallet/tests/test_basic_wallet.py b/agent/indy_catalyst_agent/wallet/tests/test_basic_wallet.py index 8a54274be..aaa53a5f6 100644 --- a/agent/indy_catalyst_agent/wallet/tests/test_basic_wallet.py +++ b/agent/indy_catalyst_agent/wallet/tests/test_basic_wallet.py @@ -9,7 +9,9 @@ WalletNotFoundError, ) -from indy_catalyst_agent.messaging.models.field_signature import FieldSignature +from indy_catalyst_agent.messaging.decorators.signature_decorator import ( + SignatureDecorator, +) @pytest.fixture() @@ -276,7 +278,7 @@ async def test_signature_round_trip(self, wallet): key_info = await wallet.create_signing_key() msg = {"test": "signed field"} timestamp = int(time.time()) - sig = await FieldSignature.create(msg, key_info.verkey, wallet, timestamp) + sig = await SignatureDecorator.create(msg, key_info.verkey, wallet, timestamp) verified = await sig.verify(wallet) assert verified msg_decode, ts_decode = sig.decode()