diff --git a/aries_cloudagent/issuer/indy.py b/aries_cloudagent/issuer/indy.py index a8a1eebccf..c546eb6711 100644 --- a/aries_cloudagent/issuer/indy.py +++ b/aries_cloudagent/issuer/indy.py @@ -258,12 +258,12 @@ async def revoke_credentials( delta_json = await indy.anoncreds.issuer_revoke_credential( self.wallet.handle, tails_reader_handle, revoc_reg_id, cred_revoc_id ) - if not result_json: - result_json = delta_json - else: + if result_json: result_json = await self.merge_revocation_registry_deltas( result_json, delta_json ) + else: + result_json = delta_json return result_json diff --git a/aries_cloudagent/issuer/tests/test_indy.py b/aries_cloudagent/issuer/tests/test_indy.py index 5a5fd71198..58a804d81c 100644 --- a/aries_cloudagent/issuer/tests/test_indy.py +++ b/aries_cloudagent/issuer/tests/test_indy.py @@ -122,13 +122,15 @@ async def test_create_credential_offer(self, mock_create_offer): issuer = IndyIssuer(mock_wallet) offer_json = await issuer.create_credential_offer(test_cred_def_id) assert json.loads(offer_json) == test_offer - mock_create_offer.assert_awaited_once_with(mock_wallet.handle, test_cred_def_id) + mock_create_offer.assert_called_once_with(mock_wallet.handle, test_cred_def_id) @async_mock.patch("indy.anoncreds.issuer_create_credential") @async_mock.patch("aries_cloudagent.issuer.indy.create_tails_reader") @async_mock.patch("indy.anoncreds.issuer_revoke_credential") - async def test_create_revoke_credential( + @async_mock.patch("indy.anoncreds.issuer_merge_revocation_registry_deltas") + async def test_create_revoke_credentials( self, + mock_indy_merge_rr_deltas, mock_indy_revoke_credential, mock_tails_reader, mock_indy_create_credential, @@ -152,20 +154,22 @@ async def test_create_revoke_credential( "rev_reg": {"accum": "21 12E8..."}, "witness": {"omega": "21 1369..."}, } - test_cred_rev_id = "42" + test_cred_rev_ids = ["42", "54"] test_rr_delta = TEST_RR_DELTA - mock_indy_create_credential.return_value = ( - json.dumps(test_cred), - test_cred_rev_id, - test_rr_delta, - ) + mock_indy_create_credential.side_effect = [ + ( + json.dumps(test_cred), + cr_id, + test_rr_delta, + ) for cr_id in test_cred_rev_ids + ] with self.assertRaises(IssuerError): # missing attribute cred_json, revoc_id = await self.issuer.create_credential( test_schema, test_offer, test_request, {} ) - cred_json, cred_rev_id = await self.issuer.create_credential( # main line + (cred_json, cred_rev_id) = await self.issuer.create_credential( # main line test_schema, test_offer, test_request, @@ -173,7 +177,7 @@ async def test_create_revoke_credential( REV_REG_ID, "/tmp/tails/path/dummy", ) - mock_indy_create_credential.assert_awaited_once() + mock_indy_create_credential.assert_called_once() ( call_wallet, call_offer, @@ -189,10 +193,15 @@ async def test_create_revoke_credential( assert "attr1" in values mock_indy_revoke_credential.return_value = json.dumps(TEST_RR_DELTA) + mock_indy_merge_rr_deltas.return_value = json.dumps(TEST_RR_DELTA) result = await self.issuer.revoke_credentials( - REV_REG_ID, tails_file_path="dummy", cred_revoc_ids=[cred_rev_id] + REV_REG_ID, + tails_file_path="dummy", + cred_revoc_ids=test_cred_rev_ids ) assert json.loads(result) == TEST_RR_DELTA + assert mock_indy_revoke_credential.call_count == 2 + mock_indy_merge_rr_deltas.assert_called_once() @async_mock.patch("indy.anoncreds.issuer_create_credential") @async_mock.patch("aries_cloudagent.issuer.indy.create_tails_reader") diff --git a/aries_cloudagent/messaging/agent_message.py b/aries_cloudagent/messaging/agent_message.py index e8e339fb5b..b62bb175df 100644 --- a/aries_cloudagent/messaging/agent_message.py +++ b/aries_cloudagent/messaging/agent_message.py @@ -1,7 +1,7 @@ """Agent message base class and schema.""" from collections import OrderedDict -from typing import Union +from typing import Mapping, Union import uuid from marshmallow import ( @@ -418,7 +418,7 @@ def __init__(self, *args, **kwargs): self._signatures = {} @pre_load - def extract_decorators(self, data, **kwargs): + def extract_decorators(self, data: Mapping, **kwargs): """ Pre-load hook to extract the decorators and check the signed fields. diff --git a/aries_cloudagent/messaging/decorators/attach_decorator.py b/aries_cloudagent/messaging/decorators/attach_decorator.py index 7f5ca6f635..7c64669085 100644 --- a/aries_cloudagent/messaging/decorators/attach_decorator.py +++ b/aries_cloudagent/messaging/decorators/attach_decorator.py @@ -6,12 +6,11 @@ import json -import re import uuid -from typing import Mapping, Union +from typing import Any, Mapping, Sequence, Union -from marshmallow import fields +from marshmallow import fields, pre_load from ...wallet.base import BaseWallet from ...wallet.util import ( @@ -24,14 +23,200 @@ str_to_b64, unpad, ) -from ..models.base import BaseModel, BaseModelSchema +from ..models.base import BaseModel, BaseModelError, BaseModelSchema from ..valid import ( BASE64, + BASE64URL_NO_PAD, INDY_ISO8601_DATETIME, + JWS_HEADER_KID, SHA256, UUIDFour, ) +MULTIBASE_B58_BTC = "z" +MULTICODEC_ED25519_PUB = b"\xed" + + +class AttachDecoratorDataJWSHeader(BaseModel): + """Attach decorator data JWS header.""" + + class Meta: + """AttachDecoratorDataJWS metadata.""" + + schema_class = "AttachDecoratorDataJWSHeaderSchema" + + def __init__(self, kid: str): + """Initialize JWS header to include in attach decorator data.""" + self.kid = kid + + def __eq__(self, other: Any): + """Compare equality with another.""" + + return type(self) == type(other) and self.kid == other.kid + + +class AttachDecoratorDataJWSHeaderSchema(BaseModelSchema): + """Attach decorator data JWS header schema.""" + + class Meta: + """Attach decorator data schema metadata.""" + + model_class = AttachDecoratorDataJWSHeader + + kid = fields.Str( + description="Key identifier, in W3C did:key or DID URL format", + required=True, + **JWS_HEADER_KID + ) + + +class AttachDecoratorData1JWS(BaseModel): + """Single Detached JSON Web Signature for inclusion in attach decorator data.""" + + class Meta: + """AttachDecoratorData1JWS metadata.""" + + schema_class = "AttachDecoratorData1JWSSchema" + + def __init__( + self, + *, + header: AttachDecoratorDataJWSHeader, + protected: str = None, + signature: str + ): + """Initialize flattened single-JWS to include in attach decorator data.""" + self.header = header + self.protected = protected + self.signature = signature + + def __eq__(self, other: Any): + """Compare equality with another.""" + + return ( + type(self) == type(other) + and self.header == other.header + and self.protected == other.protected + and self.signature == other.signature + ) + + +class AttachDecoratorData1JWSSchema(BaseModelSchema): + """Single attach decorator data JWS schema.""" + + class Meta: + """Single attach decorator data JWS schema metadata.""" + + model_class = AttachDecoratorData1JWS + + header = fields.Nested(AttachDecoratorDataJWSHeaderSchema, required=True) + protected = fields.Str( + description="protected JWS header", + required=False, + **BASE64URL_NO_PAD + ) + signature = fields.Str( + description="signature", + required=True, + **BASE64URL_NO_PAD + ) + + +class AttachDecoratorDataJWS(BaseModel): + """ + Detached JSON Web Signature for inclusion in attach decorator data. + + May hold one signature in flattened format, or multiple signatures in the + "signatures" member. + + """ + + class Meta: + """AttachDecoratorDataJWS metadata.""" + + schema_class = "AttachDecoratorDataJWSSchema" + + def __init__( + self, + *, + header: AttachDecoratorDataJWSHeader = None, + protected: str = None, + signature: str = None, + signatures: Sequence[AttachDecoratorData1JWS] = None + ): + """Initialize JWS to include in attach decorator multi-sig data.""" + self.header = header + self.protected = protected + self.signature = signature + self.signatures = signatures + + +class AttachDecoratorDataJWSSchema(BaseModelSchema): + """Schema for detached JSON Web Signature for inclusion in attach decorator data.""" + + class Meta: + """Metadata for schema for detached JWS for inclusion in attach deco data.""" + + model_class = AttachDecoratorDataJWS + + @pre_load + def validate_single_xor_multi_sig(self, data: Mapping, **kwargs): + """Ensure model is for either 1 or many sigatures, not mishmash of both.""" + + if "signatures" in data: + if any(k in data for k in ("header", "protected", "signature")): + raise BaseModelError( + "AttachDecoratorDataJWSSchema: " + "JWS must be flattened or general JSON serialization format" + ) + elif not all(k in data for k in ("header", "signature")): + raise BaseModelError( + "AttachDecoratorDataJWSSchema: " + "Flattened JSON serialization format must include header and signature" + ) + + return data + + header = fields.Nested( + AttachDecoratorDataJWSHeaderSchema, + required=False # packed in signatures if multi-sig + ) + protected = fields.Str( + description="protected JWS header", + required=False, # packed in signatures if multi-sig + **BASE64URL_NO_PAD + ) + signature = fields.Str( + description="signature", + required=False, # packed in signatures if multi-sig + **BASE64URL_NO_PAD + ) + signatures = fields.List( + fields.Nested(AttachDecoratorData1JWSSchema), + required=False, # only present if multi-sig + description="List of signatures" + ) + + +def did_key(verkey: str) -> str: + """Qualify verkey into DID key if need be.""" + + if verkey.startswith(f"did:key:{MULTIBASE_B58_BTC}"): + return verkey + + return f"did:key:{MULTIBASE_B58_BTC}" + bytes_to_b58( + MULTICODEC_ED25519_PUB + b58_to_bytes(verkey) + ) + + +def raw_key(verkey: str) -> str: + """Strip qualified key to raw key if need be.""" + + if verkey.startswith(f"did:key:{MULTIBASE_B58_BTC}"): + return bytes_to_b58(b58_to_bytes(verkey[9:])[1:]) + + return verkey + class AttachDecoratorData(BaseModel): """Attach decorator data.""" @@ -44,11 +229,11 @@ class Meta: def __init__( self, *, + jws_: AttachDecoratorDataJWS = None, + sha256_: str = None, + links_: Union[list, str] = None, base64_: str = None, - sig_: str = None, json_: str = None, - links_: Union[list, str] = None, - sha256_: str = None, ): """ Initialize decorator data. @@ -56,63 +241,60 @@ def __init__( Specify content for one of: - `base64_` - - `sig_` - `json_` - - `links_` and optionally `sha256_`. + - `links_`. Args: + jws_: detached JSON Web Signature over base64 or linked attachment content + sha256_: optional sha-256 hash for content + links_: list or single URL of hyperlinks base64_: base64 encoded content for inclusion - sig_: signed content for inclusion json_: json-dumped content for inclusion - links_: list or single URL of hyperlinks - sha256_: sha-256 hash for URL content, if `links_` specified """ + if jws_: + self.jws_ = jws_ + assert not json_ + if base64_: self.base64_ = base64_ - elif sig_: - self.sig_ = sig_ elif json_: self.json_ = json_ else: assert isinstance(links_, (str, list)) self.links_ = [links_] if isinstance(links_, str) else list(links_) - if sha256_: - self.sha256_ = sha256_ + if sha256_: + self.sha256_ = sha256_ @property def base64(self): """Accessor for base64 decorator data, or None.""" + return getattr(self, "base64_", None) @property - def sig(self): - """Accessor for signed-content decorator data, or None.""" - return getattr(self, "sig_", None) + def jws(self): + """Accessor for JWS, or None.""" + + return getattr(self, "jws_", None) @property def signatures(self) -> int: """Accessor for number of signatures.""" - if self.sig: - if isinstance(self.sig, str): - assert re.match( - r"^[-_a-zA-Z0-9]*\.[-_a-zA-Z0-9]*\.[-_a-zA-Z0-9]*$", - self.sig - ) - return 1 - return len(self.sig["signatures"]) + + if self.jws: + return 1 if self.jws.signature else len(self.jws.signatures) return 0 @property def signed(self) -> bytes: """Accessor for signed content (payload), None for unsigned.""" - if self.sig: - if self.signatures == 1: - return b64_to_bytes(self.sig.split(".")[1], urlsafe=True) - return b64_to_bytes(self.sig["payload"], urlsafe=True) - return None - def header(self, idx: int = 0, jose: bool = True) -> Mapping: + return b64_to_bytes( + unpad(set_urlsafe_b64(self.base64, urlsafe=True)) + ) if self.signatures else None + + def header_map(self, idx: int = 0, jose: bool = True) -> Mapping: """ Accessor for header info at input index, default 0 or unique for singly-signed. @@ -121,81 +303,84 @@ def header(self, idx: int = 0, jose: bool = True) -> Mapping: jose: True to return unprotected header attributes, False for protected only """ - if self.signatures == 1: - return json.loads(b64_to_str(self.sig.split(".")[0], urlsafe=True)) - if self.signatures > 1: - headers = json.loads(b64_to_str( - self.sig["signatures"][idx]["protected"], - urlsafe=True, - )) - if jose: - headers.update(self.sig["signatures"][idx]["header"]) - return headers - return None + if not self.signatures: + return None + + headers = {} + sig = self.jws if self.jws.signature else self.jws.signatures[idx] + if sig.protected: + headers.update(json.loads(b64_to_str(sig.protected, urlsafe=True))) + if jose: + headers.update(sig.header.serialize()) + return headers @property def json(self): """Accessor for json decorator data, or None.""" + return getattr(self, "json_", None) @property def links(self): """Accessor for links decorator data, or None.""" + return getattr(self, "links_", None) @property def sha256(self): """Accessor for sha256 decorator data, or None.""" + return getattr(self, "sha256_", None) async def sign( self, - verkeys: Union[str, Mapping[str, str]], + verkeys: Union[str, Sequence[str]], wallet: BaseWallet, ): """ - Sign and replace base64 data value of attachment. + Sign base64 data value of attachment. Args: - verkeys: Verkey(s) of the signing party; specify: - - single verkey alone for single signature with no key identifier (kid) - - dict mapping single key identifier to verkey for single signature - - dict mapping key identifiers to verkeys for multi-signature + verkeys: verkey(s) of the signing party (in raw or DID key format) wallet: The wallet to use for the signature """ - def build_protected(verkey: str, kid: str, protect_kid: bool): + + def build_protected(verkey: str): """Build protected header.""" + return str_to_b64( - json.dumps({ - "alg": "EdDSA", - **{"kid": k for k in [kid] if kid and protect_kid}, - "jwk": { - "kty": "OKP", - "crv": "Ed25519", - "x": bytes_to_b64( - b58_to_bytes(verkey), - urlsafe=True, - pad=False - ), - **{"kid": k for k in [kid] if kid}, - }, - }), + json.dumps( + { + "alg": "EdDSA", + "kid": did_key(verkey), + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": bytes_to_b64( + b58_to_bytes(raw_key(verkey)), + urlsafe=True, + pad=False + ), + "kid": did_key(verkey), + }, + } + ), urlsafe=True, pad=False ) - assert self.base64_ + assert self.base64 - b64_payload = unpad(set_urlsafe_b64(self.base64_, True)) + b64_payload = unpad(set_urlsafe_b64(self.base64, True)) if ( isinstance(verkeys, str) or - (isinstance(verkeys, Mapping) and len(verkeys) == 1) + (isinstance(verkeys, Sequence) and len(verkeys) == 1) ): - kid = list(verkeys)[0] if isinstance(verkeys, Mapping) else None - verkey = verkeys[kid] if isinstance(verkeys, Mapping) else verkeys - b64_protected = build_protected(verkey, kid, protect_kid=True) + kid = did_key(verkeys if isinstance(verkeys, str) else verkeys[0]) + verkey = raw_key(verkeys if isinstance(verkeys, str) else verkeys[0]) + b64_protected = build_protected(verkey) b64_sig = bytes_to_b64( await wallet.sign_message( message=(b64_protected + "." + b64_payload).encode("ascii"), @@ -204,30 +389,33 @@ def build_protected(verkey: str, kid: str, protect_kid: bool): urlsafe=True, pad=False, ) - self.sig_ = ".".join([b64_protected, b64_payload, b64_sig]) + self.jws_ = AttachDecoratorDataJWS.deserialize( + { + "header": AttachDecoratorDataJWSHeader(kid).serialize(), + "protected": b64_protected, # always present by construction + "signature": b64_sig + } + ) else: - sig = {"payload": b64_payload, "signatures": []} - for (kid, verkey) in verkeys.items(): - assert kid is not None - b64_protected = build_protected(verkey, kid, protect_kid=False) + jws = {"signatures": []} + for verkey in verkeys: + b64_protected = build_protected(verkey) b64_sig = bytes_to_b64( await wallet.sign_message( message=(b64_protected + "." + b64_payload).encode("ascii"), - from_verkey=verkey + from_verkey=raw_key(verkey) ), urlsafe=True, pad=False, ) - sig["signatures"].append( + jws["signatures"].append( { - "protected": b64_protected, - "header": {"kid": kid}, + "protected": b64_protected, # always present by construction + "header": {"kid": did_key(verkey)}, "signature": b64_sig } ) - self.sig_ = sig - - self.base64_ = None + self.jws_ = AttachDecoratorDataJWS.deserialize(jws) async def verify(self, wallet: BaseWallet) -> bool: """ @@ -240,36 +428,27 @@ async def verify(self, wallet: BaseWallet) -> bool: True if verification succeeds else False """ - assert self.sig + assert self.jws + + b64_payload = unpad(set_urlsafe_b64(self.base64, True)) - if self.signatures == 1: - (b64_protected, b64_payload, b64_sig) = self.sig.split(".") + for sig in [self.jws] if self.signatures == 1 else self.jws.signatures: + b64_protected = sig.protected + b64_sig = sig.signature protected = json.loads(b64_to_str(b64_protected, urlsafe=True)) assert "jwk" in protected and protected["jwk"].get("kty") == "OKP" 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)) - - return await wallet.verify_message(sign_input, b_sig, verkey) - else: - b64_payload = self.sig["payload"] - for signature in self.sig["signatures"]: - b64_protected = signature["protected"] - b64_sig = signature["signature"] - protected = json.loads(b64_to_str(b64_protected, urlsafe=True)) - assert "jwk" in protected and protected["jwk"].get("kty") == "OKP" - - 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): - return False - return True + if not await wallet.verify_message(sign_input, b_sig, verkey): + return False + return True def __eq__(self, other): - """Equality comparator.""" - for attr in ["base64_", "sig_", "json_", "sha256_"]: + """Compare equality with another.""" + + for attr in ["jws_", "sha256_", "base64_"]: if getattr(self, attr, None) != getattr(other, attr, None): return False if set(getattr(self, "links_", [])) != set(getattr(other, "links_", [])): @@ -285,21 +464,28 @@ class Meta: model_class = AttachDecoratorData + @pre_load + def validate_data_spec(self, data: Mapping, **kwargs): + """Ensure model chooses exactly one of base64, json, or links.""" + + if len(set(data.keys()) & {"base64", "json", "links"}) != 1: + raise BaseModelError( + "AttachDecoratorSchema: choose exactly one of base64, json, or links" + ) + + return data + base64_ = fields.Str( description="Base64-encoded data", required=False, data_key="base64", **BASE64 ) - sig_ = fields.Str( - description="Signed content, replacing base64-encoded data", + jws_ = fields.Nested( + AttachDecoratorDataJWSSchema, + description="Detached Java Web Signature", required=False, - data_key="sig", - example=( - "eyJhbGciOiJFZERTQSJ9." - "eyJhIjogIjAifQ." - "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" - ), + data_key="jws" ) json_ = fields.Str( description="JSON-serialized data", @@ -314,7 +500,7 @@ class Meta: data_key="links" ) sha256_ = fields.Str( - description="SHA256 hash of linked data", + description="SHA256 hash (binhex encoded) of content", required=False, data_key="sha256", **SHA256 @@ -374,7 +560,7 @@ def indy_dict(self): """ assert hasattr(self.data, "base64_") - return json.loads(b64_to_bytes(self.data.base64_)) + return json.loads(b64_to_bytes(self.data.base64)) @classmethod def from_indy_dict( diff --git a/aries_cloudagent/messaging/decorators/tests/test_attach_decorator.py b/aries_cloudagent/messaging/decorators/tests/test_attach_decorator.py index dc2cec6341..d5632cf4bb 100644 --- a/aries_cloudagent/messaging/decorators/tests/test_attach_decorator.py +++ b/aries_cloudagent/messaging/decorators/tests/test_attach_decorator.py @@ -7,11 +7,27 @@ from time import time from unittest import TestCase +from ....messaging.models.base import BaseModelError from ....wallet.indy import IndyWallet from ....wallet.util import b64_to_bytes, bytes_to_b64 -from ..attach_decorator import AttachDecorator, AttachDecoratorData +from ..attach_decorator import ( + AttachDecorator, + AttachDecoratorSchema, + AttachDecoratorData, + AttachDecoratorDataSchema, + AttachDecoratorData1JWS, + AttachDecoratorData1JWSSchema, + AttachDecoratorDataJWS, + AttachDecoratorDataJWSSchema, + AttachDecoratorDataJWSHeader, + AttachDecoratorDataJWSHeaderSchema, + did_key, + raw_key +) + +KID = "did:sov:LjgpST2rjsoxYegQDRm7EL#keys-4" INDY_CRED = { "schema_id": "LjgpST2rjsoxYegQDRm7EL:2:icon:1.0", "cred_def_id": "LjgpST2rjsoxYegQDRm7EL:3:CL:19:tag", @@ -82,7 +98,144 @@ async def wallet(): class TestAttachDecorator(TestCase): - def test_init_embedded_b64(self): + def test_jws_header(self): + jws_header = AttachDecoratorDataJWSHeader(kid=KID) + assert jws_header.kid == KID + + dumped = jws_header.serialize() + loaded = AttachDecoratorDataJWSHeader.deserialize(dumped) + assert loaded.kid == jws_header.kid + + bad_kid = { + "kid": "a suffusion of yellow" + } + with pytest.raises(BaseModelError): + AttachDecoratorDataJWSHeader.deserialize(bad_kid) + + def test_1jws(self): + jws_header = AttachDecoratorDataJWSHeader(kid=KID) + PROTECTED = "YmFzZTY0dXJsOyBubyBwYWRkaW5n" + SIGNATURE = "YmFzZTY0dXJsOyBubyBwYWRkaW5n" + + one_jws = AttachDecoratorData1JWS( + header=jws_header, + protected=PROTECTED, + signature=SIGNATURE + ) + assert one_jws.header == jws_header + assert one_jws.protected == PROTECTED + assert one_jws.signature == SIGNATURE + + dumped = one_jws.serialize() + loaded = AttachDecoratorData1JWS.deserialize(dumped) + assert loaded.header == one_jws.header + assert loaded.protected == one_jws.protected + assert loaded.signature == one_jws.signature + + badness = [ + { + "header": jws_header.serialize(), + "protected": "a suffusion of yellow", + "signature": SIGNATURE + }, + { + "header": "incorrect", + "protected": PROTECTED, + "signature": SIGNATURE + } + ] + for bad in badness: + with pytest.raises(BaseModelError): + AttachDecoratorData1JWS.deserialize(bad) + + def test_jws1(self): + jws_header = AttachDecoratorDataJWSHeader(kid=KID) + PROTECTED = "YmFzZTY0dXJsOyBubyBwYWRkaW5n" + SIGNATURE = "YmFzZTY0dXJsOyBubyBwYWRkaW5n" + jws1 = AttachDecoratorDataJWS( + header=jws_header, + protected=PROTECTED, + signature=SIGNATURE + ) + assert jws1.header == jws_header + assert jws1.protected == PROTECTED + assert jws1.signature == SIGNATURE + + dumped = jws1.serialize() + loaded = AttachDecoratorDataJWS.deserialize(dumped) + assert loaded.header == jws1.header + assert loaded.protected == jws1.protected + assert loaded.signature == jws1.signature + + badness = [ + { + "header": jws_header.serialize() + }, + { + "header": "incorrect", + "protected": PROTECTED, + "signature": SIGNATURE + }, + { + "protected": PROTECTED, + "signature": SIGNATURE + } + ] + for bad in badness: + with pytest.raises(BaseModelError): + AttachDecoratorData1JWS.deserialize(bad) + + def test_jws2(self): + jws_header = AttachDecoratorDataJWSHeader(kid=KID) + PROTECTED = "YmFzZTY0dXJsOyBubyBwYWRkaW5n" + SIGNATURE = "YmFzZTY0dXJsOyBubyBwYWRkaW5n" + one_jws = AttachDecoratorData1JWS( + header=jws_header, + protected=PROTECTED, + signature=SIGNATURE + ) + jws2 = AttachDecoratorDataJWS( + signatures=[one_jws, one_jws] + ) + assert jws2.header is None + assert jws2.protected is None + assert jws2.signature is None + assert len(jws2.signatures) == 2 + + dumped = jws2.serialize() + loaded = AttachDecoratorDataJWS.deserialize(dumped) + assert loaded.header == jws2.header + assert loaded.protected == jws2.protected + assert loaded.signature == jws2.signature + assert loaded.signatures == jws2.signatures + + badness = [ + { + "header": jws_header.serialize(), + "protected": PROTECTED, + "signature": SIGNATURE, + "signatures": [ + one_jws.serialize(), + one_jws.serialize() + ] + }, + { + "signature": SIGNATURE, + "signatures": [ + one_jws.serialize(), + one_jws.serialize() + ] + }, + { + "protected": PROTECTED, + "signature": SIGNATURE, + } + ] + for bad in badness: + with pytest.raises(BaseModelError) as excinfo: + AttachDecoratorDataJWS.deserialize(bad) + + def test_embedded_b64(self): decorator = AttachDecorator( mime_type=MIME_TYPE, filename=FILENAME, @@ -90,29 +243,21 @@ def test_init_embedded_b64(self): description=DESCRIPTION, data=DATA_B64, ) + assert decorator.mime_type == MIME_TYPE assert decorator.filename == FILENAME assert decorator.lastmod_time == LASTMOD_TIME assert decorator.description == DESCRIPTION assert decorator.data == DATA_B64 - def test_serialize_load_embedded_b64(self): - decorator = AttachDecorator( - mime_type=MIME_TYPE, - filename=FILENAME, - lastmod_time=LASTMOD_TIME, - description=DESCRIPTION, - data=DATA_B64, - ) - dumped = decorator.serialize() loaded = AttachDecorator.deserialize(dumped) - assert decorator.mime_type == MIME_TYPE - assert decorator.filename == FILENAME - assert decorator.lastmod_time == LASTMOD_TIME - assert decorator.description == DESCRIPTION - assert decorator.data == DATA_B64 + assert loaded.mime_type == decorator.mime_type + assert loaded.filename == decorator.filename + assert loaded.lastmod_time == decorator.lastmod_time + assert loaded.description == decorator.description + assert loaded.data == decorator.data def test_init_appended_b64(self): decorator = AttachDecorator( @@ -201,6 +346,10 @@ def test_indy_dict(self): ) assert deco_indy.mime_type == 'application/json' assert hasattr(deco_indy.data, 'base64_') + assert deco_indy.data.base64 is not None + assert deco_indy.data.json is None + assert deco_indy.data.links is None + assert deco_indy.data.sha256 is None assert deco_indy.indy_dict == INDY_CRED assert deco_indy.ident == IDENT assert deco_indy.description == DESCRIPTION @@ -208,10 +357,29 @@ def test_indy_dict(self): deco_indy_auto_id = AttachDecorator.from_indy_dict(indy_dict=INDY_CRED) assert deco_indy_auto_id.ident + # cover AttachDecoratorData equality operator + plain_json = AttachDecoratorData(json_=json.dumps({"sample": "data"})) + assert deco_indy.data != plain_json + + lynx_str = AttachDecoratorData(links_="https://en.wikipedia.org/wiki/Lynx") + lynx_list = AttachDecoratorData(links_=["https://en.wikipedia.org/wiki/Lynx"]) + links = AttachDecoratorData(links_="https://en.wikipedia.org/wiki/Chain") + + assert lynx_str == lynx_list + assert lynx_str != links + @pytest.mark.indy class TestAttachDecoratorSignature: - + @pytest.mark.asyncio + async def test_did_raw_key(self, wallet, seed): + did_info = await wallet.create_local_did(seed[0]) + did_key0 = did_key(did_info.verkey) + raw_key0 = raw_key(did_key0) + assert raw_key0 != did_key0 + assert did_key0.startswith("did:key:z") + assert raw_key0 == did_info.verkey + @pytest.mark.asyncio async def test_indy_sign(self, wallet, seed): deco_indy = AttachDecorator.from_indy_dict( @@ -224,76 +392,61 @@ async def test_indy_sign(self, wallet, seed): ) deco_indy_master = deepcopy(deco_indy) did_info = [await wallet.create_local_did(seed[i]) for i in [0, 1]] - - assert deco_indy.data.signed is None assert deco_indy.data.signatures == 0 - assert deco_indy.data.header() is None + assert deco_indy.data.header_map() is None await deco_indy.data.sign(did_info[0].verkey, wallet) - assert deco_indy.data.sig is not None + assert deco_indy.data.jws is not None assert deco_indy.data.signatures == 1 - assert deco_indy.data.sig.count(".") == 2 - assert deco_indy.data.header() is not None - assert "kid" not in deco_indy.data.header() - assert ( - "jwk" in deco_indy.data.header() and - "kid" not in deco_indy.data.header()["jwk"] + assert deco_indy.data.jws.signature + assert not deco_indy.data.jws.signatures + assert deco_indy.data.header_map(0) is not None + assert deco_indy.data.header_map() is not None + assert "kid" in deco_indy.data.header_map() + assert "jwk" in deco_indy.data.header_map() + assert "kid" in deco_indy.data.header_map()["jwk"] + assert deco_indy.data.header_map()["kid"] == did_key( + did_info[0].verkey + ) + assert deco_indy.data.header_map()["jwk"]["kid"] == did_key( + did_info[0].verkey ) - assert deco_indy.data.signed is not None assert await deco_indy.data.verify(wallet) indy_cred = json.loads(deco_indy.data.signed.decode()) assert indy_cred == INDY_CRED # Test tamper evidence - jws_parts = deco_indy.data.sig_.split(".") + jws_parts = deco_indy.data.jws.signature.split(".") tampered = bytearray( - b64_to_bytes(jws_parts[2], urlsafe=True) + b64_to_bytes(deco_indy.data.jws.signature, urlsafe=True) ) tampered[0] = (tampered[0] + 1) % 256 - deco_indy.data.sig_ = ".".join( - jws_parts[0:2] + [bytes_to_b64(bytes(tampered), urlsafe=True, pad=False)] + deco_indy.data.jws.signature = bytes_to_b64( + bytes(tampered), urlsafe=True, pad=False ) assert not await deco_indy.data.verify(wallet) - # Specify "kid" + # Degenerate case: sign with list of 1 verkey (expect flattened JSON) deco_indy = deepcopy(deco_indy_master) assert deco_indy.data.signed is None assert deco_indy.data.signatures == 0 - assert deco_indy.data.header() is None - await deco_indy.data.sign({did_info[0].did: did_info[0].verkey}, wallet) - assert deco_indy.data.sig is not None + assert deco_indy.data.header_map() is None + await deco_indy.data.sign([did_info[0].verkey], wallet) + assert deco_indy.data.jws is not None assert deco_indy.data.signatures == 1 - assert deco_indy.data.sig.count(".") == 2 - assert deco_indy.data.header() is not None - assert "kid" in deco_indy.data.header() - assert ( - "jwk" in deco_indy.data.header() and - "kid" in deco_indy.data.header()["jwk"] and - deco_indy.data.header()["kid"] == did_info[0].did and - deco_indy.data.header()["jwk"]["kid"] == did_info[0].did + assert deco_indy.data.jws.signature + assert not deco_indy.data.jws.signatures + assert deco_indy.data.header_map(0) is not None + assert deco_indy.data.header_map() is not None + assert "kid" in deco_indy.data.header_map() + assert "jwk" in deco_indy.data.header_map() + assert "kid" in deco_indy.data.header_map()["jwk"] + assert deco_indy.data.header_map()["kid"] == did_key( + did_info[0].verkey ) - assert deco_indy.data.signed is not None - assert await deco_indy.data.verify(wallet) - - indy_cred = json.loads(deco_indy.data.signed.decode()) - assert indy_cred == INDY_CRED - - # Degenerate case: one key, kid=None explicitly - deco_indy = deepcopy(deco_indy_master) - assert deco_indy.data.signed is None - assert deco_indy.data.signatures == 0 - assert deco_indy.data.header() is None - await deco_indy.data.sign({None: did_info[0].verkey}, wallet) - assert deco_indy.data.sig is not None - assert deco_indy.data.signatures == 1 - assert deco_indy.data.sig.count(".") == 2 - assert deco_indy.data.header() is not None - assert "kid" not in deco_indy.data.header() - assert ( - "jwk" in deco_indy.data.header() and - "kid" not in deco_indy.data.header()["jwk"] + assert deco_indy.data.header_map()["jwk"]["kid"] == did_key( + did_info[0].verkey ) - assert deco_indy.data.signed is not None assert await deco_indy.data.verify(wallet) indy_cred = json.loads(deco_indy.data.signed.decode()) @@ -303,27 +456,36 @@ async def test_indy_sign(self, wallet, seed): deco_indy = deepcopy(deco_indy_master) assert deco_indy.data.signed is None assert deco_indy.data.signatures == 0 - assert deco_indy.data.header() is None + assert deco_indy.data.header_map() is None await deco_indy.data.sign( - {did_info[i].did: did_info[i].verkey for i in range(len(did_info))}, + [did_info[i].verkey for i in range(len(did_info))], wallet ) - assert deco_indy.data.sig is not None + assert deco_indy.data.jws is not None assert deco_indy.data.signatures == 2 - assert "payload" in deco_indy.data.sig - assert "signatures" in deco_indy.data.sig + assert not deco_indy.data.jws.signature + assert deco_indy.data.jws.signatures for i in range(len(did_info)): - assert deco_indy.data.header(i) is not None - assert "kid" not in deco_indy.data.header(i, jose=False) - assert "kid" in deco_indy.data.header(i) - assert ( - "jwk" in deco_indy.data.header(i) and - "kid" in deco_indy.data.header(i)["jwk"] and - deco_indy.data.header(i)["kid"] == did_info[i].did and - deco_indy.data.header(i)["jwk"]["kid"] == did_info[i].did + assert deco_indy.data.header_map(i) is not None + assert "kid" in deco_indy.data.header_map(i, jose=False) + assert "kid" in deco_indy.data.header_map(i, jose=True) + assert "jwk" in deco_indy.data.header_map(i) + assert "kid" in deco_indy.data.header_map(i)["jwk"] + assert deco_indy.data.header_map(i)["kid"] == did_key( + did_info[i].verkey + ) + assert deco_indy.data.header_map(i)["jwk"]["kid"] == did_key( + did_info[i].verkey ) assert deco_indy.data.signed is not None assert await deco_indy.data.verify(wallet) indy_cred = json.loads(deco_indy.data.signed.decode()) assert indy_cred == INDY_CRED + + # De/serialize to exercise initializer with JWS + deco_dict = deco_indy.serialize() + deco = AttachDecorator.deserialize(deco_dict) + deco_dict["data"]["links"] = "https://en.wikipedia.org/wiki/Potato" + with pytest.raises(BaseModelError): + AttachDecorator.deserialize(deco_dict) # now has base64 and links diff --git a/aries_cloudagent/messaging/tests/test_valid.py b/aries_cloudagent/messaging/tests/test_valid.py index 5a4d7e2295..70a823d24d 100644 --- a/aries_cloudagent/messaging/tests/test_valid.py +++ b/aries_cloudagent/messaging/tests/test_valid.py @@ -5,6 +5,8 @@ BASE58_SHA256_HASH, BASE64, BASE64URL, + BASE64URL_NO_PAD, + DID_KEY, INDY_CRED_DEF_ID, INDY_DID, INDY_ISO8601_DATETIME, @@ -14,6 +16,8 @@ INDY_SCHEMA_ID, INDY_VERSION, INT_EPOCH, + JWS_HEADER_KID, + JWT, SHA256, UUID4 ) @@ -62,6 +66,58 @@ def test_indy_raw_public_key(self): INDY_RAW_PUBLIC_KEY["validate"]("Q4zqM7aXqm7gDQkUVLng9hQ4zqM7aXqm7gDQkUVLng9h") + def test_jws_header_kid(self): + non_kids = [ + "http://not-this.one", + "did:sov:i", # too short + "did:key:Q4zqM7aXqm7gDQkUVLng9h" # missing leading z + "did:key:zI4zqM7aXqm7gDQkUVLng9h" # 'I' not a base58 char + ] + for non_kid in non_kids: + with self.assertRaises(ValidationError): + JWS_HEADER_KID["validate"](non_kid) + + JWS_HEADER_KID["validate"]("did:key:zQ4zqM7aXqm7gDQkUVLng9h") + JWS_HEADER_KID["validate"]("did:sov:Q4zqM7aXqm7gDQkUVLng9h#abc-123") + JWS_HEADER_KID["validate"]( + "did:sov:Q4zqM7aXqm7gDQkUVLng9h?version-time=1234567890#abc-123" + ) + JWS_HEADER_KID["validate"]( + "did:sov:Q4zqM7aXqm7gDQkUVLng9h?version-time=1234567890&a=b#abc-123" + ) + JWS_HEADER_KID["validate"]( + "did:sov:Q4zqM7aXqm7gDQkUVLng9h;foo:bar=low;a=b?version-id=1&a=b#abc-123" + ) + + def test_jwt(self): + non_jwts = [ + "abcde", + "abcde+.abcde/.abcdef", + "abcdef==.abcdef==.abcdef", + "abcdef==..", + ] + + for non_jwt in non_jwts: + with self.assertRaises(ValidationError): + JWT["validate"](non_jwt) + + JWT["validate"]("abcdef.abcdef.abcdef") + JWT["validate"]("abcde-.abcde_.abcdef") + JWT["validate"]("abcde-..abcdef") + + def test_did_key(self): + non_did_keys = [ + "http://not-this.one", + "did:sov:i", # wrong preamble + "did:key:Q4zqM7aXqm7gDQkUVLng9h" # missing leading z + "did:key:zI4zqM7aXqm7gDQkUVLng9h" # 'I' not a base58 char + ] + for non_did_key in non_did_keys: + with self.assertRaises(ValidationError): + DID_KEY["validate"](non_did_key) + + DID_KEY["validate"]("did:key:zQ4zqM7aXqm7gDQkUVLng9h") + def test_indy_base58_sha256_hash(self): non_base58_sha256_hashes = [ "Q4zqM7aXqm7gDQkUVLng9JQ4zqM7aXqm7gDQkUVLng9I", # 'I' not a base58 char @@ -226,7 +282,25 @@ def test_base64(self): BASE64URL["validate"]("UG90Y-_=") BASE64URL["validate"]("UG90YX==") with self.assertRaises(ValidationError): - BASE64URL["validate"]("UG90YX+v") + BASE64URL["validate"]("UG90YX+v") # '+' is not a base64url char + + non_base64_no_pads = [ + "####", + "abcde=", + "ab=cde" + ] + for non_base64_no_pad in non_base64_no_pads: + with self.assertRaises(ValidationError): + BASE64URL_NO_PAD["validate"](non_base64_no_pad) + + BASE64URL_NO_PAD["validate"]("") + BASE64URL_NO_PAD["validate"]("abcd123") + BASE64URL_NO_PAD["validate"]("abcde") + BASE64URL_NO_PAD["validate"]("UG90YX-v") + BASE64URL_NO_PAD["validate"]("UG90Y-_") + BASE64URL_NO_PAD["validate"]("UG90YX") + with self.assertRaises(ValidationError): + BASE64URL_NO_PAD["validate"]("UG90YX+v") # '+' is not a base64url char def test_sha256(self): non_sha256s = [ diff --git a/aries_cloudagent/messaging/valid.py b/aries_cloudagent/messaging/valid.py index a91220c609..afc6ec79e5 100644 --- a/aries_cloudagent/messaging/valid.py +++ b/aries_cloudagent/messaging/valid.py @@ -21,7 +21,53 @@ def __init__(self): super().__init__( # use 64-bit for Aries RFC compatibility min=-9223372036854775808, max=9223372036854775807, - error="Value {input} is not a valid integer epoch time." + error="Value {input} is not a valid integer epoch time" + ) + + +class JWSHeaderKid(Regexp): + """Validate value against JWS header kid.""" + + EXAMPLE = "did:sov:LjgpST2rjsoxYegQDRm7EL#keys-4" + + def __init__(self): + """Initializer.""" + + super().__init__( + rf"^did:(?:key:z[{B58}]+|sov:[{B58}]{{21,22}}(;.*)?(\?.*)?#.+)$", + error="Value {input} is neither in W3C did:key nor DID URL format" + ) + + +class JSONWebToken(Regexp): + """Validate JSON Web Token.""" + + EXAMPLE = ( + "eyJhbGciOiJFZERTQSJ9." + "eyJhIjogIjAifQ." + "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + ) + + def __init__(self): + """Initializer.""" + + super().__init__( + r"^[-_a-zA-Z0-9]*\.[-_a-zA-Z0-9]*\.[-_a-zA-Z0-9]*$", + error="Value {input} is not a valid JSON Web token" + ) + + +class DIDKey(Regexp): + """Validate value against DID key specification.""" + + EXAMPLE = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH" + + def __init__(self): + """Initializer.""" + + super().__init__( + rf"^did:key:z[{B58}]+$", + error="Value {input} is not in W3C did:key format" ) @@ -35,7 +81,7 @@ def __init__(self): super().__init__( rf"^(did:sov:)?[{B58}]{{21,22}}$", - error="Value {input} is not an indy decentralized identifier (DID)." + error="Value {input} is not an indy decentralized identifier (DID)" ) @@ -49,7 +95,7 @@ def __init__(self): super().__init__( rf"^[{B58}]{{43,44}}$", - error="Value {input} is not a raw Ed25519VerificationKey2018 key." + error="Value {input} is not a raw Ed25519VerificationKey2018 key" ) @@ -69,7 +115,7 @@ def __init__(self): rf":(([1-9][0-9]*)|([{B58}]{{21,22}}:2:.+:[0-9.]+))" # schema txn / id f":(.+)?$" # tag ), - error="Value {input} is not an indy credential definition identifier." + error="Value {input} is not an indy credential definition identifier" ) @@ -83,7 +129,7 @@ def __init__(self): super().__init__( rf"^[0-9.]+$", - error="Value {input} is not an indy version (use only digits and '.')." + error="Value {input} is not an indy version (use only digits and '.')" ) @@ -97,7 +143,7 @@ def __init__(self): super().__init__( rf"^[{B58}]{{21,22}}:2:.+:[0-9.]+$", - error="Value {input} is not an indy schema identifier." + error="Value {input} is not an indy schema identifier" ) @@ -116,7 +162,7 @@ def __init__(self): rf"CL:(([1-9][0-9]*)|([{B58}]{{21,22}}:2:.+:[0-9.]+))(:.+)?:" rf"CL_ACCUM:(.+$)" ), - error="Value {input} is not an indy revocation registry identifier." + error="Value {input} is not an indy revocation registry identifier" ) @@ -130,7 +176,7 @@ def __init__(self): super().__init__( choices=["<", "<=", ">=", ">"], - error="Value {input} must be one of {choices}." + error="Value {input} must be one of {choices}" ) @@ -145,7 +191,7 @@ def __init__(self): super().__init__( r"^\d{4}-\d\d-\d\d[T ]\d\d:\d\d" r"(?:\:(?:\d\d(?:\.\d{1,6})?))?(?:[+-]\d\d:?\d\d|Z|)$", - error="Value {input} is not a date in valid format." + error="Value {input} is not a date in valid format" ) @@ -177,6 +223,20 @@ def __init__(self): ) +class Base64URLNoPad(Regexp): + """Validate base64 value.""" + + EXAMPLE = "ey4uLn0" + + def __init__(self): + """Initializer.""" + + super().__init__( + r"^[-_a-zA-Z0-9]*$", + error="Value {input} is not a valid unpadded base64url encoding" + ) + + class SHA256Hash(Regexp): """Validate (binhex-encoded) SHA256 value.""" @@ -201,7 +261,7 @@ def __init__(self): super().__init__( rf"^[{B58}]{{43,44}}$", - error="Value {input} is not a base58 encoding of a SHA-256 hash." + error="Value {input} is not a base58 encoding of a SHA-256 hash" ) @@ -228,6 +288,18 @@ def __init__(self): "validate": IntEpoch(), "example": IntEpoch.EXAMPLE } +JWS_HEADER_KID = { + "validate": JWSHeaderKid(), + "example": JWSHeaderKid.EXAMPLE +} +JWT = { + "validate": JSONWebToken(), + "example": JSONWebToken.EXAMPLE +} +DID_KEY = { + "validate": DIDKey(), + "example": DIDKey.EXAMPLE +} INDY_DID = { "validate": IndyDID(), "example": IndyDID.EXAMPLE @@ -266,8 +338,13 @@ def __init__(self): } BASE64URL = { "validate": Base64URL(), - "example": Base64.EXAMPLE + "example": Base64URL.EXAMPLE } +BASE64URL_NO_PAD = { + "validate": Base64URLNoPad(), + "example": Base64URLNoPad.EXAMPLE +} + SHA256 = { "validate": SHA256Hash(), "example": SHA256Hash.EXAMPLE diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_issue.py b/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_issue.py index fd71e31f2f..6a496c45c9 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_issue.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_issue.py @@ -75,7 +75,7 @@ class Meta: model_class = CredentialIssue - comment = fields.Str(comment="Human-readable comment", required=False) + comment = fields.Str(description="Human-readable comment", required=False) credentials_attach = fields.Nested( AttachDecoratorSchema, required=True, many=True, data_key="credentials~attach" ) diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_offer.py b/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_offer.py index 9a375cbf8c..6d36513e7d 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_offer.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_offer.py @@ -79,7 +79,7 @@ class Meta: model_class = CredentialOffer - comment = fields.Str(required=False, allow_none=False) + comment = fields.Str(description="Human-readable comment", required=False) credential_preview = fields.Nested(CredentialPreviewSchema, required=False) offers_attach = fields.Nested( AttachDecoratorSchema, required=True, many=True, data_key="offers~attach" diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_proposal.py b/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_proposal.py index 23b2f35202..7decaeea10 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_proposal.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_proposal.py @@ -77,7 +77,7 @@ class Meta: model_class = CredentialProposal - comment = fields.Str(required=False, allow_none=False) + comment = fields.Str(description="Human-readable comment", required=False) credential_proposal = fields.Nested( CredentialPreviewSchema, required=False, allow_none=False ) diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_request.py b/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_request.py index 1ba98b572b..a59e4964ad 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_request.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_request.py @@ -76,7 +76,7 @@ class Meta: model_class = CredentialRequest - comment = fields.Str(required=False) + comment = fields.Str(description="Human-readable comment", required=False) requests_attach = fields.Nested( AttachDecoratorSchema, required=True, many=True, data_key="requests~attach" )