diff --git a/pyhanko/sign/ades/report.py b/pyhanko/sign/ades/report.py index 7907515a..69b9106c 100644 --- a/pyhanko/sign/ades/report.py +++ b/pyhanko/sign/ades/report.py @@ -15,6 +15,7 @@ 'AdESIndeterminate', ] + # TODO document these @@ -29,6 +30,10 @@ class AdESSubIndic: def status(self) -> AdESStatus: raise NotImplementedError + @property + def standard_name(self): + raise NotImplementedError + class AdESPassed(AdESSubIndic, enum.Enum): OK = enum.auto() @@ -37,6 +42,10 @@ class AdESPassed(AdESSubIndic, enum.Enum): def status(self) -> AdESStatus: return AdESStatus.PASSED + @property + def standard_name(self): + return self.name + class AdESFailure(AdESSubIndic, enum.Enum): FORMAT_FAILURE = enum.auto() @@ -49,6 +58,10 @@ class AdESFailure(AdESSubIndic, enum.Enum): def status(self): return AdESStatus.FAILED + @property + def standard_name(self): + return self.name + class AdESIndeterminate(AdESSubIndic, enum.Enum): SIG_CONSTRAINTS_FAILURE = enum.auto() @@ -76,3 +89,7 @@ class AdESIndeterminate(AdESSubIndic, enum.Enum): @property def status(self): return AdESStatus.INDETERMINATE + + @property + def standard_name(self): + return self.name diff --git a/pyhanko/sign/validation/ades.py b/pyhanko/sign/validation/ades.py index b073158c..9d14d292 100644 --- a/pyhanko/sign/validation/ades.py +++ b/pyhanko/sign/validation/ades.py @@ -18,6 +18,7 @@ Any, Dict, FrozenSet, + Generator, Generic, Iterable, Iterator, @@ -44,7 +45,14 @@ ValidationDataHandlers, ) from pyhanko_certvalidator.ltv.ades_past import past_validate -from pyhanko_certvalidator.ltv.poe import KnownPOE, POEManager, digest_for_poe +from pyhanko_certvalidator.ltv.poe import ( + KnownPOE, + POEManager, + POEType, + ValidationObject, + ValidationObjectType, + digest_for_poe, +) from pyhanko_certvalidator.ltv.time_slide import ades_gather_prima_facie_revinfo from pyhanko_certvalidator.ltv.types import ValidationTimingInfo from pyhanko_certvalidator.path import ValidationPath @@ -93,6 +101,7 @@ ) from ..diff_analysis import DiffPolicy +from .dss import enumerate_ocsp_certs from .errors import NoDSSFoundError from .policy_decl import ( LocalKnowledge, @@ -113,6 +122,7 @@ 'AdESBasicValidationResult', 'AdESWithTimeValidationResult', 'AdESLTAValidationResult', + 'derive_validation_object_identifier', ] logger = logging.getLogger(__name__) @@ -120,6 +130,65 @@ StatusType = TypeVar('StatusType', bound=SignatureStatus, covariant=True) +def derive_validation_object_binary_data( + vo: ValidationObject, +) -> Optional[bytes]: + if vo.object_type == ValidationObjectType.CERTIFICATE: + return vo.value.dump() + elif vo.object_type == ValidationObjectType.CRL: + return vo.value.crl_data.dump() + elif vo.object_type == ValidationObjectType.OCSP_RESPONSE: + return vo.value.ocsp_response_data.dump() + elif vo.object_type in ( + ValidationObjectType.SIGNED_DATA, + ValidationObjectType.TIMESTAMP, + ): + return vo.value['signer_infos'][0]['signature'].native + else: + return None + + +def derive_validation_object_identifier(vo: ValidationObject) -> Optional[str]: + # TODO for certs and signers, it could make sense to somehow encode + # a human-readable "slugified" representation of the common name + # to identify things at a glance. + if vo.object_type == ValidationObjectType.CERTIFICATE: + marker = digest_for_poe(vo.value.dump()).hex() + elif vo.object_type == ValidationObjectType.CRL: + marker = digest_for_poe(vo.value.crl_data.dump()).hex() + elif vo.object_type == ValidationObjectType.OCSP_RESPONSE: + marker = digest_for_poe(vo.value.ocsp_response_data.dump()).hex() + elif vo.object_type in ( + ValidationObjectType.SIGNED_DATA, + ValidationObjectType.TIMESTAMP, + ): + marker = digest_for_poe( + vo.value['signer_infos'][0]['signature'].native + ).hex() + else: + return None + + return f'vo-{vo.object_type.value}-{marker}' + + +class ValidationObjectSet: + def __init__(self, *object_collections: Iterable[ValidationObject]): + def _pairs(): + for obj in itertools.chain(*object_collections): + ident = derive_validation_object_identifier(obj) + if ident: + yield ident, obj + + self._things = {k: v for k, v in _pairs()} + + def __iter__(self) -> Iterator[ValidationObject]: + return iter(self._things.values()) + + @staticmethod + def empty(): + return ValidationObjectSet(()) + + @dataclass(frozen=True) class AdESBasicValidationResult(Generic[StatusType]): """ @@ -146,6 +215,11 @@ class AdESBasicValidationResult(Generic[StatusType]): if applicable. """ + validation_objects: ValidationObjectSet + """ + Validation objects that were potentially relevant for the validation process. + """ + @dataclass class _InternalBasicValidationResult: @@ -293,6 +367,37 @@ def _ades_signature_crypto_policy_check( ) +def _enumerate_validation_objects( + validation_context: Optional[ValidationContext], +) -> Generator[ValidationObject, None, None]: + if validation_context is None: + return + for ocsp in validation_context.ocsps: + for cont in OCSPContainer.load_multi(ocsp): + yield ValidationObject(ValidationObjectType.OCSP_RESPONSE, cont) + for cert in enumerate_ocsp_certs(ocsp): + yield ValidationObject(ValidationObjectType.CERTIFICATE, cert) + for crl in validation_context.crls: + yield ValidationObject(ValidationObjectType.CRL, CRLContainer(crl)) + + +def _enumerate_certs_in_paths( + status: Union[SignatureStatus, _InternalBasicValidationResult, None], +): + if status is None: + return + path = status.validation_path + if path: + for cert in path.iter_certs(include_root=True): + yield ValidationObject(ValidationObjectType.CERTIFICATE, cert) + if isinstance(status, StandardCMSSignatureStatus): + yield from _enumerate_certs_in_paths(status.timestamp_validity) + yield from _enumerate_certs_in_paths(status.content_timestamp_validity) + if isinstance(status, _InternalBasicValidationResult): + yield from _enumerate_certs_in_paths(status.signature_ts_validity) + yield from _enumerate_certs_in_paths(status.content_ts_validity) + + async def _ades_timestamp_validation_from_context( tst_signed_data: cms.SignedData, validation_context: ValidationContext, @@ -300,6 +405,7 @@ async def _ades_timestamp_validation_from_context( extra_status_kwargs: Optional[Dict[str, Any]] = None, status_cls=TimestampSignatureStatus, ) -> AdESBasicValidationResult: + vos = ValidationObjectSet(_enumerate_validation_objects(validation_context)) status_kwargs = dict(extra_status_kwargs or {}) status_kwargs_from_validation = await generic_cms.validate_tst_signed_data( tst_signed_data, @@ -307,18 +413,21 @@ async def _ades_timestamp_validation_from_context( expected_tst_imprint=expected_tst_imprint, ) status_kwargs.update(status_kwargs_from_validation) + # noinspection PyArgumentList status = status_cls(**status_kwargs) if not status.intact: return AdESBasicValidationResult( ades_subindic=AdESFailure.HASH_FAILURE, api_status=status, failure_msg=None, + validation_objects=vos, ) elif not status.valid: return AdESBasicValidationResult( ades_subindic=AdESFailure.SIG_CRYPTO_FAILURE, api_status=status, failure_msg=None, + validation_objects=vos, ) interm_result = await _process_basic_validation( @@ -329,12 +438,16 @@ async def _ades_timestamp_validation_from_context( signature_not_before_time=None, ) interm_result.status_kwargs = status_kwargs + vos = ValidationObjectSet( + iter(vos), _enumerate_certs_in_paths(interm_result) + ) return AdESBasicValidationResult( ades_subindic=interm_result.ades_subindic, api_status=interm_result.update( status_cls, with_ts=False, with_attrs=False ), failure_msg=None, + validation_objects=vos, ) @@ -352,6 +465,7 @@ async def _ades_process_attached_ts( ades_subindic=AdESIndeterminate.GENERIC, failure_msg=None, api_status=None, + validation_objects=ValidationObjectSet.empty(), ) @@ -362,6 +476,7 @@ async def _process_basic_validation( ac_validation_context: Optional[ValidationContext], signature_not_before_time: Optional[datetime], ): + validation_time = temp_status.validation_time ades_trust_status: Optional[AdESSubIndic] = temp_status.trust_problem_indic signer_info = generic_cms.extract_signer_info(signed_data) ts_status: Optional[TimestampSignatureStatus] = None @@ -434,6 +549,7 @@ async def _process_basic_validation( signer_attr_status=SignerAttributeStatus(**attr_status_kwargs), signature_poe_time=None, validation_path=temp_status.validation_path, + status_kwargs={'validation_time': validation_time}, ) @@ -562,12 +678,21 @@ async def ades_basic_validation( if isinstance(interm_result, AdESBasicValidationResult): return interm_result + status: StandardCMSSignatureStatus = interm_result.update( + StandardCMSSignatureStatus, with_ts=False, with_attrs=True + ) + vos = ValidationObjectSet( + _enumerate_validation_objects(validation_context), + _enumerate_validation_objects(ac_validation_context), + _enumerate_validation_objects(ts_validation_context), + _enumerate_certs_in_paths(status), + ) + return AdESBasicValidationResult( ades_subindic=interm_result.ades_subindic, - api_status=interm_result.update( - StandardCMSSignatureStatus, with_ts=False, with_attrs=True - ), + api_status=status, failure_msg=None, + validation_objects=vos, ) @@ -584,6 +709,11 @@ async def _ades_basic_validation( status_cls: Type[StatusType], ) -> Union[AdESBasicValidationResult, _InternalBasicValidationResult]: status_kwargs = dict(extra_status_kwargs or {}) + vos = ValidationObjectSet( + _enumerate_validation_objects(validation_context), + _enumerate_validation_objects(ts_validation_context), + _enumerate_validation_objects(ac_validation_context), + ) try: status_kwargs_from_validation = await generic_cms.cms_basic_validation( signed_data, @@ -598,6 +728,7 @@ async def _ades_basic_validation( ades_subindic=e.ades_subindication or AdESIndeterminate.GENERIC, failure_msg=e.failure_message, api_status=None, + validation_objects=vos, ) # put the temp status into a SignatureStatus object for convenience @@ -607,12 +738,14 @@ async def _ades_basic_validation( ades_subindic=AdESFailure.HASH_FAILURE, api_status=status, failure_msg=None, + validation_objects=vos, ) elif not status.valid: return AdESBasicValidationResult( ades_subindic=AdESFailure.SIG_CRYPTO_FAILURE, api_status=status, failure_msg=None, + validation_objects=vos, ) interm_result = await _process_basic_validation( @@ -736,13 +869,21 @@ async def ades_with_time_validation( status_cls=status_cls, algorithm_policy=validation_spec.signature_algorithm_policy, ) + if isinstance(interm_result, AdESBasicValidationResult): + vos = ValidationObjectSet( + _enumerate_validation_objects(validation_context), + _enumerate_validation_objects(ts_validation_context), + _enumerate_validation_objects(ac_validation_context), + _enumerate_certs_in_paths(interm_result.api_status), + ) return AdESWithTimeValidationResult( ades_subindic=interm_result.ades_subindic, api_status=interm_result.api_status, failure_msg=interm_result.failure_msg, best_signature_time=signature_poe_time, signature_not_before_time=signature_not_before_time, + validation_objects=vos, ) elif interm_result.ades_subindic not in _WITH_TIME_FURTHER_PROC: assert isinstance(interm_result, _InternalBasicValidationResult) @@ -750,12 +891,19 @@ async def ades_with_time_validation( api_status = interm_result.update( status_cls, with_ts=True, with_attrs=True ) + vos = ValidationObjectSet( + _enumerate_validation_objects(validation_context), + _enumerate_validation_objects(ts_validation_context), + _enumerate_validation_objects(ac_validation_context), + _enumerate_certs_in_paths(api_status), + ) return AdESWithTimeValidationResult( ades_subindic=interm_result.ades_subindic, api_status=api_status, failure_msg=None, best_signature_time=signature_poe_time, signature_not_before_time=signature_not_before_time, + validation_objects=vos, ) signer_info = generic_cms.extract_signer_info(signed_data) @@ -770,16 +918,30 @@ async def ades_with_time_validation( # TODO conditionally enforce this based on policy params--- # for now we assume that someone calling this method actually cares # about timestamps + vos = ValidationObjectSet( + _enumerate_validation_objects(validation_context), + _enumerate_validation_objects(ts_validation_context), + _enumerate_validation_objects(ac_validation_context), + _enumerate_certs_in_paths(interm_result), + ) return AdESWithTimeValidationResult( ades_subindic=AdESIndeterminate.SIG_CONSTRAINTS_FAILURE, api_status=temp_status, failure_msg="No signature timestamp present", best_signature_time=timing_info.best_signature_time, signature_not_before_time=signature_not_before_time, + validation_objects=vos, ) sig_ts_result = await _ades_process_attached_ts( signer_info, validation_context, signed=False, tst_digest=tst_digest ) + vos = ValidationObjectSet( + _enumerate_validation_objects(validation_context), + _enumerate_validation_objects(ts_validation_context), + _enumerate_validation_objects(ac_validation_context), + _enumerate_certs_in_paths(interm_result), + _enumerate_certs_in_paths(sig_ts_result.api_status), + ) if sig_ts_result.ades_subindic != AdESPassed.OK: return AdESWithTimeValidationResult( ades_subindic=sig_ts_result.ades_subindic, @@ -787,6 +949,7 @@ async def ades_with_time_validation( failure_msg=None, best_signature_time=signature_poe_time, signature_not_before_time=signature_not_before_time, + validation_objects=vos, ) ts_status = sig_ts_result.api_status @@ -811,6 +974,7 @@ async def ades_with_time_validation( failure_msg=None, best_signature_time=signature_poe_time, signature_not_before_time=signature_not_before_time, + validation_objects=vos, ) elif interm_result.ades_subindic == AdESIndeterminate.OUT_OF_BOUNDS_NO_POE: # NOTE: we can't process expiration here since we don't have access @@ -823,6 +987,7 @@ async def ades_with_time_validation( failure_msg=None, best_signature_time=signature_poe_time, signature_not_before_time=signature_not_before_time, + validation_objects=vos, ) elif ( interm_result.ades_subindic @@ -838,6 +1003,7 @@ async def ades_with_time_validation( failure_msg=None, best_signature_time=signature_poe_time, signature_not_before_time=signature_not_before_time, + validation_objects=vos, ) # TODO TSTInfo ordering/comparison check @@ -851,6 +1017,7 @@ async def ades_with_time_validation( failure_msg=None, best_signature_time=signature_poe_time, signature_not_before_time=signature_not_before_time, + validation_objects=vos, ) # TODO handle time-stamp delay interm_result.trust_subindic_update = None @@ -863,6 +1030,7 @@ async def ades_with_time_validation( failure_msg=None, best_signature_time=signature_poe_time, signature_not_before_time=signature_not_before_time, + validation_objects=vos, ) @@ -1162,25 +1330,57 @@ async def ades_past_signature_validation( @dataclass(frozen=True) -class PrimaFaciePOE: +class _PrimaFaciePOEItem: + digest: bytes + validation_object: ValidationObject + + +@dataclass(frozen=True) +class _PrimaFaciePOEFromTimeStamp: pdf_revision: int timestamp_dt: datetime - digests_covered: FrozenSet[bytes] + poes_implied: FrozenSet[_PrimaFaciePOEItem] timestamp_token_signed_data: cms.SignedData doc_digest: bytes # include info from difference analysis as part of the status kwargs forensic_info: dict def add_to_poe_manager(self, manager: POEManager): - for thing in self.digests_covered: - manager.register_by_digest(digest=thing, dt=self.timestamp_dt) + for thing in self.poes_implied: + manager.register_known_poe( + KnownPOE( + poe_type=POEType.VALIDATION, + digest=thing.digest, + poe_time=self.timestamp_dt, + validation_object=thing.validation_object, + ) + ) -def _extract_cert_digests_from_signed_data(sd: cms.SignedData): +def _extract_cert_digests_from_signed_data( + sd: cms.SignedData, +) -> Generator[_PrimaFaciePOEItem, None, None]: cert_choice: cms.CertificateChoices for cert_choice in sd['certificates']: - if cert_choice.name in ('certificate', 'v2_attr_set'): - yield digest_for_poe(cert_choice.chosen.dump()) + obj = cert_choice.chosen + data = obj.dump() + if cert_choice.name == 'certificate': + vo_type = ValidationObjectType.CERTIFICATE + elif cert_choice.name == 'v2_attr_cert': + # There is no separate type for an attribute cert in + # ETSI TS 119 102-2, so we mark it as OTHER. + vo_type = ValidationObjectType.OTHER + else: + # skip over unsupported certificate types + # since we don't want to give the impression + # in the validation report that we processed them. + # TODO write test to verify that these don't end up in the report + continue + digest = digest_for_poe(data) + yield _PrimaFaciePOEItem( + digest=digest, + validation_object=ValidationObject(object_type=vo_type, value=obj), + ) def _get_tst_timestamp(sd: cms.SignedData) -> datetime: @@ -1188,11 +1388,64 @@ def _get_tst_timestamp(sd: cms.SignedData) -> datetime: return tst_info['gen_time'].native +def _read_validation_objects_from_revinfo_archival( + revinfo_archival: asn1_pdf.RevocationInfoArchival, +) -> Generator[_PrimaFaciePOEItem, None, None]: + for crl in revinfo_archival['crl']: + yield _PrimaFaciePOEItem( + digest=digest_for_poe(crl.dump()), + validation_object=ValidationObject( + object_type=ValidationObjectType.CRL, + value=CRLContainer(crl), + ), + ) + for ocsp in revinfo_archival['ocsp']: + yield _PrimaFaciePOEItem( + digest=digest_for_poe(ocsp.dump()), + validation_object=ValidationObject( + object_type=ValidationObjectType.OCSP_RESPONSE, + value=OCSPContainer(ocsp), + ), + ) + + +def _read_validation_objects_from_dss( + dss: DocumentSecurityStore, +) -> Generator[_PrimaFaciePOEItem, None, None]: + for crl_obj in dss.crls: + data = crl_obj.get_object().data + yield _PrimaFaciePOEItem( + digest=digest_for_poe(data), + validation_object=ValidationObject( + object_type=ValidationObjectType.CRL, + value=CRLContainer(CertificateList.load(data)), + ), + ) + for ocsp_obj in dss.ocsps: + data = ocsp_obj.get_object().data + yield _PrimaFaciePOEItem( + digest=digest_for_poe(data), + validation_object=ValidationObject( + object_type=ValidationObjectType.OCSP_RESPONSE, + value=OCSPContainer(OCSPResponse.load(data)), + ), + ) + for cert_obj in dss.certs.values(): + data = cert_obj.get_object().data + yield _PrimaFaciePOEItem( + digest=digest_for_poe(data), + validation_object=ValidationObject( + object_type=ValidationObjectType.CERTIFICATE, + value=x509.Certificate.load(data), + ), + ) + + def _build_prima_facie_poe_index_from_pdf_timestamps( r: PdfFileReader, include_content_ts: bool, diff_policy: Optional[DiffPolicy], -) -> List[PrimaFaciePOE]: +) -> List[_PrimaFaciePOEFromTimeStamp]: # This subroutine implements the POE gathering part of the evidence record # processing algorithm in AdES as applied to PDF. For the purposes of this # function, the chain of document timestamps is treated as a single evidence @@ -1218,15 +1471,15 @@ def _build_prima_facie_poe_index_from_pdf_timestamps( # integrity, we do run the integrity checker for the TST data at this stage. # The actual trust validation is delegated - collected_so_far: Set[bytes] = set() + collected_so_far: Set[_PrimaFaciePOEItem] = set() # Holds all digests of objects contained in _document_ content so far # (note: this is why it's important to traverse the revisions in order) - for_next_ts: Set[bytes] = set() + for_next_ts: Set[_PrimaFaciePOEItem] = set() # Holds digests of objects that will be registered with POE on the next # document TS or content TS encountered. - prima_facie_poe_sets: List[PrimaFaciePOE] = [] + prima_facie_poe_sets: List[_PrimaFaciePOEFromTimeStamp] = [] # output array (to avoid having to work with async generators) embedded_sig: EmbeddedPdfSignature @@ -1267,12 +1520,7 @@ def _build_prima_facie_poe_index_from_pdf_timestamps( if ts_signed_data is not None: # add DSS content dss = DocumentSecurityStore.read_dss(hist_handler) - collected_so_far.update( - digest_for_poe(item.get_object().data) - for item in itertools.chain( - dss.crls, dss.ocsps, dss.certs.values() - ) - ) + collected_so_far.update(_read_validation_objects_from_dss(dss)) collected_so_far.update(for_next_ts) doc_digest = embedded_sig.compute_digest() coverage_normal = ( @@ -1281,10 +1529,10 @@ def _build_prima_facie_poe_index_from_pdf_timestamps( ) if coverage_normal: prima_facie_poe_sets.append( - PrimaFaciePOE( + _PrimaFaciePOEFromTimeStamp( pdf_revision=embedded_sig.signed_revision, timestamp_dt=_get_tst_timestamp(ts_signed_data), - digests_covered=frozenset(collected_so_far), + poes_implied=frozenset(collected_so_far), timestamp_token_signed_data=ts_signed_data, doc_digest=doc_digest, forensic_info=embedded_sig.summarise_integrity_info(), @@ -1314,10 +1562,7 @@ def _build_prima_facie_poe_index_from_pdf_timestamps( ) for_next_ts.update( - digest_for_poe(item.dump()) - for item in itertools.chain( - revinfo_attr['crl'], revinfo_attr['ocsp'] - ) + _read_validation_objects_from_revinfo_archival(revinfo_attr) ) except (MultivaluedAttributeError, NonexistentAttributeError): pass @@ -1325,7 +1570,19 @@ def _build_prima_facie_poe_index_from_pdf_timestamps( # Prepare a POE entry for the signature itself (to be processed # with the next timestamp) sig_bytes = embedded_sig.signer_info['signature'].native - for_next_ts.add(digest_for_poe(sig_bytes)) + for_next_ts.add( + _PrimaFaciePOEItem( + digest=digest_for_poe(sig_bytes), + validation_object=ValidationObject( + object_type=ValidationObjectType.SIGNED_DATA, + # For now, we put the entire signed data object here + # while we take the digest only over the signature. + # This was done for expediency & ease of reasoning + # given existing code, but may change in the future. + value=embedded_sig.signed_data, + ), + ) + ) # add POE entries for the timestamp(s) attached to this signature try: @@ -1344,15 +1601,28 @@ def _build_prima_facie_poe_index_from_pdf_timestamps( signature_tses = () for ts_data in itertools.chain(signature_tses, content_tses): - for ts_signer_info in ts_data['content']['signer_infos']: + ts_data_content = ts_data['content'] + for ts_signer_info in ts_data_content['signer_infos']: ts_sig_bytes = ts_signer_info['signature'].native - for_next_ts.add(digest_for_poe(ts_sig_bytes)) + for_next_ts.add( + _PrimaFaciePOEItem( + digest=digest_for_poe(ts_sig_bytes), + # Same as for signedData: we take the digest over + # the signature part only. + # This was done for expediency & ease of reasoning + # given existing code, but may change in the future. + validation_object=ValidationObject( + object_type=ValidationObjectType.TIMESTAMP, + value=ts_data_content, + ), + ) + ) return prima_facie_poe_sets async def _validate_prima_facie_poe( - prima_facie_poe_sets: List[PrimaFaciePOE], + prima_facie_poe_sets: List[_PrimaFaciePOEFromTimeStamp], # we assume that the validation info extracted from the DSS # has been registered in the revinfo gathering policy object # and/or the known cert list, respectively @@ -1526,12 +1796,14 @@ async def _process_signature_ts( validation_path=validation_path, ), failure_msg=None, + validation_objects=signature_ts_prelim_result.validation_objects, ) except errors.SignatureValidationError as e: signature_ts_result = AdESBasicValidationResult( ades_subindic=e.ades_subindication or ts_current_time_sub_indic, failure_msg=e.failure_message, api_status=signature_ts_prelim_result.api_status, + validation_objects=signature_ts_prelim_result.validation_objects, ) else: signature_ts_result = signature_ts_prelim_result @@ -1706,12 +1978,15 @@ async def ades_lta_validation( ), oldest_evidence_record_timestamp=oldest_evidence_record_timestamp, signature_timestamp_status=None, + validation_objects=signature_prelim_result.validation_objects, ) # (4) Register PoE for the signature based on best_signature_time signature_bytes = embedded_sig.signer_info['signature'].native updated_poe_manager.register( - signature_bytes, signature_prelim_result.best_signature_time + signature_bytes, + poe_type=POEType.VALIDATION, + dt=signature_prelim_result.best_signature_time, ) # (5) process signature TS if present @@ -1752,6 +2027,7 @@ async def ades_lta_validation( oldest_evidence_record_timestamp=( oldest_evidence_record_timestamp ), + validation_objects=signature_prelim_result.validation_objects, ) # (7) get the oldest PoE for the signature @@ -1787,6 +2063,7 @@ async def ades_lta_validation( ), signature_timestamp_status=signature_ts_result, oldest_evidence_record_timestamp=oldest_evidence_record_timestamp, + validation_objects=signature_prelim_result.validation_objects, ) @@ -1861,11 +2138,16 @@ def _poes(): yield from orig_local_knowledge.assert_existence_known_at(now) yield from dss_knowledge.assert_existence_known_at(now) for prima_facie_poe in prima_facie_poes: - for digest in prima_facie_poe.digests_covered: + for item in prima_facie_poe.poes_implied: # for the prima facie ones, we only emit POEs for , since # we don't validate the would-be POEs that are embedded in the # document at this point - yield KnownPOE(digest=digest, poe_time=now) + yield KnownPOE( + poe_type=POEType.VALIDATION, + digest=item.digest, + poe_time=now, + validation_object=item.validation_object, + ) updated_local_knowledge = dataclasses.replace( orig_local_knowledge, diff --git a/pyhanko/sign/validation/dss.py b/pyhanko/sign/validation/dss.py index 6a77bff7..d33eae1a 100644 --- a/pyhanko/sign/validation/dss.py +++ b/pyhanko/sign/validation/dss.py @@ -27,6 +27,7 @@ 'DocumentSecurityStore', 'async_add_validation_info', 'collect_validation_info', + 'enumerate_ocsp_certs', ] from ...pdf_utils.crypt import SerialisedCredential diff --git a/pyhanko/sign/validation/generic_cms.py b/pyhanko/sign/validation/generic_cms.py index 6460a6c1..971cd541 100644 --- a/pyhanko/sign/validation/generic_cms.py +++ b/pyhanko/sign/validation/generic_cms.py @@ -88,6 +88,7 @@ 'extract_certs_for_validation', 'collect_signer_attr_status', 'validate_algorithm_protection', + 'get_signing_cert_attr', ] logger = logging.getLogger(__name__) @@ -95,6 +96,24 @@ StatusType = TypeVar('StatusType', bound=SignatureStatus) +def get_signing_cert_attr( + signed_attrs: cms.CMSAttributes, +) -> Union[tsp.SigningCertificate, tsp.SigningCertificateV2, None]: + """ + Retrieve the ``signingCertificate`` or ``signingCertificateV2`` attribute + (giving preference to the latter) from a signature's signed attributes. + + :param signed_attrs: + Signed attributes. + :return: + The value of the attribute, if present, else ``None``. + """ + attr = _grab_signing_cert_attr(signed_attrs, v2=True) + if attr is None: + attr = _grab_signing_cert_attr(signed_attrs, v2=False) + return attr + + def _grab_signing_cert_attr(signed_attrs, v2: bool): # TODO check certificate policies, enforce restrictions on chain of trust # TODO document and/or mark as internal API explicitly @@ -123,12 +142,9 @@ def _check_signing_certificate( # TODO check certificate policies, enforce restrictions on chain of trust # TODO document and/or mark as internal API explicitly - attr = _grab_signing_cert_attr(signed_attrs, v2=True) + attr = get_signing_cert_attr(signed_attrs) if attr is None: - attr = _grab_signing_cert_attr(signed_attrs, v2=False) - - if attr is None: - # if neither attr is present -> no constraints + # if not present -> no constraints return # For the main signer cert, we only care about the first value, the others @@ -498,6 +514,9 @@ async def cms_basic_validation( ades_status = AdESIndeterminate.CERTIFICATE_CHAIN_GENERAL_FAILURE status_kwargs = status_kwargs or {} + status_kwargs['validation_time'] = ( + None if validation_context is None else validation_context.moment + ) status_kwargs.update( intact=intact, valid=valid, diff --git a/pyhanko/sign/validation/policy_decl.py b/pyhanko/sign/validation/policy_decl.py index 99076c78..ab00834a 100644 --- a/pyhanko/sign/validation/policy_decl.py +++ b/pyhanko/sign/validation/policy_decl.py @@ -16,7 +16,14 @@ from pyhanko_certvalidator.fetchers.requests_fetchers import ( RequestsFetcherBackend, ) -from pyhanko_certvalidator.ltv.poe import KnownPOE, POEManager, digest_for_poe +from pyhanko_certvalidator.ltv.poe import ( + KnownPOE, + POEManager, + POEType, + ValidationObject, + ValidationObjectType, + digest_for_poe, +) from pyhanko_certvalidator.ltv.types import ValidationTimingInfo from pyhanko_certvalidator.revinfo.archival import CRLContainer, OCSPContainer @@ -74,24 +81,44 @@ class LocalKnowledge: def add_to_poe_manager(self, poe_manager: POEManager): for poe in self.known_poes: - poe_manager.register_by_digest(poe.digest, dt=poe.poe_time) + poe_manager.register_known_poe(poe) def assert_existence_known_at( self, dt: datetime ) -> Generator[KnownPOE, None, None]: for poe in self.known_poes: - yield dataclasses.replace(poe, poe_time=min(poe.poe_time, dt)) + yield dataclasses.replace( + poe, + poe_time=min(poe.poe_time, dt), + poe_type=POEType.PROVIDED, + ) for crl in self.known_crls: yield KnownPOE( - digest=digest_for_poe(crl.crl_data.dump()), poe_time=dt + digest=digest_for_poe(crl.crl_data.dump()), + poe_time=dt, + poe_type=POEType.PROVIDED, + validation_object=ValidationObject( + ValidationObjectType.CRL, crl + ), ) for ocsp in self.known_ocsps: yield KnownPOE( digest=digest_for_poe(ocsp.ocsp_response_data.dump()), poe_time=dt, + poe_type=POEType.PROVIDED, + validation_object=ValidationObject( + ValidationObjectType.OCSP_RESPONSE, ocsp + ), ) for cert in self.known_certs: - yield KnownPOE(digest=digest_for_poe(cert.dump()), poe_time=dt) + yield KnownPOE( + digest=digest_for_poe(cert.dump()), + poe_time=dt, + poe_type=POEType.PROVIDED, + validation_object=ValidationObject( + ValidationObjectType.CERTIFICATE, cert + ), + ) @dataclass(frozen=True) diff --git a/pyhanko/sign/validation/report/__init__.py b/pyhanko/sign/validation/report/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyhanko/sign/validation/report/model.py b/pyhanko/sign/validation/report/model.py new file mode 100644 index 00000000..e69de29b diff --git a/pyhanko/sign/validation/report/tools.py b/pyhanko/sign/validation/report/tools.py new file mode 100644 index 00000000..1ca426cb --- /dev/null +++ b/pyhanko/sign/validation/report/tools.py @@ -0,0 +1,374 @@ +from typing import Any, Dict, Optional, cast + +from asn1crypto import tsp +from cryptography.hazmat.primitives import hashes +from pyhanko_certvalidator.ltv.poe import ValidationObject, ValidationObjectType +from xsdata.models.datatype import XmlDateTime + +from pyhanko.generated.etsi import ts_11910202, xades +from pyhanko.generated.w3c import xmldsig_core +from pyhanko.sign.ades import cades_asn1 +from pyhanko.sign.ades.report import AdESStatus +from pyhanko.sign.general import ( + NonexistentAttributeError, + find_cms_attribute, + find_unique_cms_attribute, + get_pyca_cryptography_hash, +) +from pyhanko.sign.validation.ades import ( + AdESBasicValidationResult, + AdESLTAValidationResult, + AdESWithTimeValidationResult, + derive_validation_object_binary_data, + derive_validation_object_identifier, +) +from pyhanko.sign.validation.generic_cms import get_signing_cert_attr +from pyhanko.sign.validation.pdf_embedded import EmbeddedPdfSignature +from pyhanko.sign.validation.status import PdfSignatureStatus + +DIGEST_ALGO_URIS = { + 'sha1': 'http://www.w3.org/2000/09/xmldsig#sha1', + 'sha256': 'http://www.w3.org/2001/04/xmlenc#sha256', + 'sha224': 'http://www.w3.org/2001/04/xmldsig-more#sha224', + 'sha384': 'http://www.w3.org/2001/04/xmldsig-more#sha384', + 'sha512': 'http://www.w3.org/2001/04/xmlenc#sha512', +} + +NAMESPACES = { + 'vr': 'http://uri.etsi.org/19102/v1.2.1#', + 'XAdES': 'http://uri.etsi.org/01903/v1.3.2#', + 'ds': 'http://www.w3.org/2000/09/xmldsig#', + 'xs': 'http://www.w3.org/2001/XMLSchema', +} + + +def _digest_algo_uri(algo: str): + try: + return DIGEST_ALGO_URIS[algo] + except KeyError: + raise NotImplementedError( + f"No XML signature syntax available for digest algo '{algo}'" + ) + + +def _summarise_attrs( + embedded_sig: EmbeddedPdfSignature, api_status: PdfSignatureStatus +): + # TODO refactor this to use a provider pattern so it can be + # more easily generalised to CAdES or even XAdES + + signed_attrs = embedded_sig.signer_info['signed_attrs'] + + # signing_time (SASigningTimeType) + kwargs: Dict[str, Any] = {} + claimed_time = embedded_sig.self_reported_timestamp or ( + api_status.timestamp_validity.timestamp + if api_status.timestamp_validity + else None + ) + if claimed_time: + kwargs['signing_time'] = ts_11910202.SASigningTimeType( + signed=True, + time=XmlDateTime.from_datetime(claimed_time), + ) + # signing_certificate (SACertIDListType) + signing_cert_attr = get_signing_cert_attr(signed_attrs) + if signing_cert_attr is not None: + cert_ids_xml = [] + for cert_id in signing_cert_attr['certs']: + if isinstance(cert_id, tsp.ESSCertID): + hash_algo = 'sha1' + else: + hash_algo = cert_id['hash_algorithm']['algorithm'].native + cert_ids_xml.append( + ts_11910202.SACertIDType( + digest_method=xmldsig_core.DigestMethod( + _digest_algo_uri(hash_algo), + ), + digest_value=cert_id['cert_hash'].native, + x509_issuer_serial=( + cert_id['issuer_serial'].dump() + if cert_id['issuer_serial'] + else None + ), + ) + ) + kwargs['signing_certificate'] = ts_11910202.SACertIDListType( + signed=True, cert_id=tuple(cert_ids_xml) + ) + # data_object_format (SADataObjectFormatType) -> not applicable + # commitment_type_indication (SACommitmentTypeIndicationType) + try: + commitment_type: cades_asn1.CommitmentTypeIndication = ( + find_unique_cms_attribute( + signed_attrs, 'commitment_type_indication' + ) + ) + oid = commitment_type['commitment_type_id'] + kwargs[ + 'commitment_type_indication' + ] = ts_11910202.SACommitmentTypeIndicationType( + signed=True, commitment_type_identifier=f'urn:oid:{oid.dotted}' + ) + except NonexistentAttributeError: + pass + # all_data_objects_time_stamp (SATimestampType) + if api_status.content_timestamp_validity: + kwargs['all_data_objects_time_stamp'] = ts_11910202.SATimestampType( + signed=True, + time_stamp_value=XmlDateTime.from_datetime( + api_status.content_timestamp_validity.timestamp + ), + ) + # individual_data_objects_time_stamp (SATimestampType) -> not applicable + # sig_policy_identifier (SASigPolicyIdentifierType) + try: + sig_policy_ident: cades_asn1.SignaturePolicyIdentifier = ( + find_cms_attribute(signed_attrs, 'signature_policy_identifier') + ) + actual_policy_ident = sig_policy_ident.chosen + # we don't support implicit policies (or at least not now) + if isinstance(actual_policy_ident, cades_asn1.SignaturePolicyId): + oid = actual_policy_ident['sig_policy_id'] + ident_xml = ts_11910202.SASigPolicyIdentifierType( + signed=True, sig_policy_id=f'urn:oid:{oid.dotted}' + ) + kwargs['sig_policy_identifier'] = ident_xml + except NonexistentAttributeError: + pass + + # signature_production_place (SASignatureProductionPlaceType) + if '/Location' in embedded_sig.sig_object: + kwargs[ + 'signature_production_place' + ] = ts_11910202.SASignatureProductionPlaceType( + signed=True, + address_string=(str(embedded_sig.sig_object['/Location']),), + ) + # signer_role (SASignerRoleType) + if api_status.cades_signer_attrs: + cades_signer_attrs = api_status.cades_signer_attrs + roles = [] + for claimed_attr in cades_signer_attrs.claimed_attrs: + role_type = ts_11910202.SAOneSignerRoleTypeEndorsementType.CLAIMED + stringified = ( + f"{claimed_attr.attr_type.native}: " + f"{'; '.join(str(v.native) for v in claimed_attr.attr_values)}" + ) + roles.append( + ts_11910202.SAOneSignerRoleType( + endorsement_type=role_type, role=stringified + ) + ) + for cert_attr in cades_signer_attrs.certified_attrs or (): + role_type = ts_11910202.SAOneSignerRoleTypeEndorsementType.CERTIFIED + # TODO include provenance info in the string representation? + stringified = ( + f"{cert_attr.attr_type.native} " + f"{'; '.join(str(v.native) for v in cert_attr.attr_values)}" + ) + roles.append( + ts_11910202.SAOneSignerRoleType( + endorsement_type=role_type, role=stringified + ) + ) + kwargs['signer_role'] = ts_11910202.SASignerRoleType( + signed=True, role_details=tuple(roles) + ) + + # counter_signature (SACounterSignatureType) + # -> not reasonably implementable in PDF signatures + # and banned in PAdES, skip + # signature_time_stamp (SATimestampType) + if api_status.timestamp_validity: + kwargs['signature_time_stamp'] = ts_11910202.SATimestampType( + signed=False, + time_stamp_value=XmlDateTime.from_datetime( + api_status.timestamp_validity.timestamp + ), + ) + # complete_certificate_refs (SACertIDListType) -> not in PAdES + # complete_revocation_refs (SARevIDListType) -> not in PAdES + # attribute_certificate_refs (SACertIDListType) -> not in PAdES + # attribute_revocation_refs (SARevIDListType) -> not in PAdES + # sig_and_refs_time_stamp (SATimestampType) -> not in PAdES + # refs_only_time_stamp (SATimestampType) -> not in PAdES + # certificate_values (AttributeBaseType) -> not in PAdES + # revocation_values (AttributeBaseType) -> not in PAdES + # attr_authorities_cert_values (AttributeBaseType) -> not in PAdES + # attribute_revocation_values (AttributeBaseType) -> not in PAdES + # time_stamp_validation_data (AttributeBaseType) -> XAdES-exclusive + # archive_time_stamp (SATimestampType) -> not in PAdES + # renewed_digests: tuple(int, ...) -> XAdES-exclusive + + # message_digest (SAMessageDigestType) + # for invalid sigs, this is worth reporting as specified + try: + message_digest = find_unique_cms_attribute( + signed_attrs, 'message_digest' + ) + kwargs['message_digest'] = ts_11910202.SAMessageDigestType( + signed=True, digest=message_digest.native + ) + except NonexistentAttributeError: + pass + # dss (SADSSType) + # TODO (emitting validation objects) + # vri (SAVRIType) + # TODO (emitting validation objects) + # doc_time_stamp (SATimestampType) + # TODO (emitting validation objects) + # reason (SAReasonType) + if '/Reason' in embedded_sig.sig_object: + kwargs['reason'] = ts_11910202.SAReasonType( + signed=True, reason_element=str(embedded_sig.sig_object['/Reason']) + ) + # name (SANameType) + if '/Name' in embedded_sig.sig_object: + kwargs['name'] = ts_11910202.SANameType( + signed=True, + name_element=str(embedded_sig.sig_object['/Name']), + ) + # contact_info (SAContactInfoType) + if '/ContactInfo' in embedded_sig.sig_object: + kwargs['contact_info'] = ts_11910202.SAContactInfoType( + signed=True, + contact_info_element=str(embedded_sig.sig_object['/ContactInfo']), + ) + # sub_filter (SASubFilterType) + if '/SubFilter' in embedded_sig.sig_object: + kwargs['sub_filter'] = ts_11910202.SASubFilterType( + signed=True, + sub_filter_element=str(embedded_sig.sig_object['/SubFilter'])[1:], + ) + # byte_range: (int, int, int, int) + kwargs['byte_range'] = tuple( + int(x) for x in embedded_sig.sig_object['/ByteRange'] + ) + # filter (SAFilterType) + if '/Filter' in embedded_sig.sig_object: + kwargs['filter'] = ts_11910202.SAFilterType( + filter=str(embedded_sig.sig_object['/Filter'])[1:], + ) + return ts_11910202.SignatureAttributesType(**kwargs) + + +def _generate_report( + embedded_sig: EmbeddedPdfSignature, status: AdESBasicValidationResult +): + api_status: PdfSignatureStatus = cast(PdfSignatureStatus, status.api_status) + # this is meaningless for EdDSA signatures, but the entry is mandatory, so... + md_spec = get_pyca_cryptography_hash(api_status.md_algorithm) + md = hashes.Hash(md_spec) + md.update(embedded_sig.signer_info['signed_attrs'].dump()) + dtbsr_digest = md.finalize() + dtbsr_digest_info = xades.DigestAlgAndValueType( + digest_method=xmldsig_core.DigestMethod( + _digest_algo_uri(api_status.md_algorithm) + ), + digest_value=dtbsr_digest, + ) + sig_id = ts_11910202.SignatureIdentifierType( + signature_value=xmldsig_core.SignatureValue( + embedded_sig.signer_info['signature'].native + ), + digest_alg_and_value=dtbsr_digest_info, + hash_only=False, + doc_hash_only=False, + ) + if isinstance(status, AdESLTAValidationResult): + process = 'LTA' + elif isinstance(status, AdESWithTimeValidationResult): + process = 'LTVM' + else: + process = 'Basic' + ades_main_indic = { + AdESStatus.PASSED: 'urn:etsi:019102:mainindication:total-passed', + AdESStatus.FAILED: 'urn:etsi:019102:mainindication:total-failed', + AdESStatus.INDETERMINATE: 'urn:etsi:019102:mainindication:indeterminate', + }[status.ades_subindic.status] + validation_time = api_status.validation_time + assert validation_time is not None + best_sig_time: Optional[ts_11910202.POEType] = None + if isinstance(status, AdESWithTimeValidationResult): + best_sig_time = ts_11910202.POEType( + poetime=XmlDateTime.from_datetime(status.best_signature_time), + # TODO extend POE semantics to preserve this info, + # for now we simply claim it was derived during validation + type_of_proof='urn:etsi:019102:poetype:validation', + ) + signer_cert_vo = ValidationObject( + object_type=ValidationObjectType.CERTIFICATE, + value=api_status.signing_cert, + ) + single_report = ts_11910202.SignatureValidationReportType( + signature_identifier=sig_id, + # TODO validation constraints eval report + validation_time_info=ts_11910202.ValidationTimeInfoType( + validation_time=XmlDateTime.from_datetime(validation_time), + best_signature_time=best_sig_time, + ), + signers_document=ts_11910202.SignersDocumentType( + digest_alg_and_value=xades.DigestAlgAndValueType( + digest_method=xmldsig_core.DigestMethod( + _digest_algo_uri(api_status.md_algorithm) + ), + digest_value=embedded_sig.compute_digest(), + ), + ), + signature_attributes=_summarise_attrs(embedded_sig, api_status), + signer_information=ts_11910202.SignerInformationType( + signer_certificate=ts_11910202.VOReferenceType( + voreference=( + f"#{derive_validation_object_identifier(signer_cert_vo)}", + ), + ), + ), + # TODO quality -> needs Qualification Algorithm support + signature_validation_process=ts_11910202.SignatureValidationProcessType( + signature_validation_process_id=( + f'urn:etsi:019102:validationprocess:{process}' + ) + ), + signature_validation_status=ts_11910202.ValidationStatusType( + main_indication=ades_main_indic, + sub_indication=( + f'urn:etsi:019102:subindication:{str(status.ades_subindic)}', + ), + ), + ) + return single_report + + +def _package_validation_object(vo: ValidationObject): + bin_data = derive_validation_object_binary_data(vo) + # TODO preserve POE info, sub-validation reports (mainly for timestamps) + return ts_11910202.ValidationObjectType( + id=derive_validation_object_identifier(vo), + object_type=vo.object_type.urn(), + validation_object_representation=ts_11910202.ValidationObjectRepresentationType( + base64=bin_data + ) + if bin_data + else None, + ) + + +def generate_report( + embedded_sig: EmbeddedPdfSignature, status: AdESBasicValidationResult +) -> str: + report = ts_11910202.ValidationReport( + signature_validation_report=(_generate_report(embedded_sig, status),), + signature_validation_objects=ts_11910202.ValidationObjectListType( + tuple( + _package_validation_object(vo) + for vo in status.validation_objects + ) + ), + ) + from xsdata.formats.dataclass.serializers import XmlSerializer + from xsdata.formats.dataclass.serializers.config import SerializerConfig + + config = SerializerConfig(pretty_print=True) + ser = XmlSerializer(config).render(report, ns_map=NAMESPACES) + return ser diff --git a/pyhanko/sign/validation/status.py b/pyhanko/sign/validation/status.py index ef4a24f1..1bd42879 100644 --- a/pyhanko/sign/validation/status.py +++ b/pyhanko/sign/validation/status.py @@ -159,6 +159,11 @@ class SignatureStatus: See :attr:`.KeyUsageConstraints.extd_key_usage`. """ + validation_time: Optional[datetime] + """ + Reference time for validation purposes. + """ + def summary_fields(self): if self.trusted: cert_status = 'TRUSTED' diff --git a/pyhanko_tests/test_ades_validation.py b/pyhanko_tests/test_ades_validation.py index e3df05f3..a30983cf 100644 --- a/pyhanko_tests/test_ades_validation.py +++ b/pyhanko_tests/test_ades_validation.py @@ -24,7 +24,13 @@ from pyhanko_certvalidator.fetchers.requests_fetchers import ( RequestsFetcherBackend, ) -from pyhanko_certvalidator.ltv.poe import KnownPOE, digest_for_poe +from pyhanko_certvalidator.ltv.poe import ( + KnownPOE, + POEType, + ValidationObject, + ValidationObjectType, + digest_for_poe, +) from pyhanko_certvalidator.path import ValidationPath from pyhanko_certvalidator.policy_decl import ( AlgorithmUsageConstraint, @@ -479,7 +485,13 @@ def digest_algorithm_allowed( def _assert_certs_known(certs): return [ KnownPOE( - digest=digest_for_poe(cert.dump()), poe_time=cert.not_valid_before + digest=digest_for_poe(cert.dump()), + poe_time=cert.not_valid_before, + poe_type=POEType.PROVIDED, + validation_object=ValidationObject( + object_type=ValidationObjectType.CERTIFICATE, + value=cert, + ), ) for cert in certs ] diff --git a/pyhanko_tests/test_ades_validation_report.py b/pyhanko_tests/test_ades_validation_report.py new file mode 100644 index 00000000..d6651a68 --- /dev/null +++ b/pyhanko_tests/test_ades_validation_report.py @@ -0,0 +1,39 @@ +from io import BytesIO + +import pytest +from freezegun import freeze_time + +from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter +from pyhanko.pdf_utils.reader import PdfFileReader +from pyhanko.sign import signers +from pyhanko.sign.validation import ades + +from .samples import MINIMAL_ONE_FIELD +from .signing_commons import DUMMY_TS, FROM_CA, live_testing_vc +from .test_ades_validation import DEFAULT_SIG_VALIDATION_SPEC +from .test_pades import PADES + + +@pytest.mark.asyncio +async def test_pades_basic_report_smoke_test(requests_mock): + with freeze_time('2020-11-20'): + w = IncrementalPdfFileWriter(BytesIO(MINIMAL_ONE_FIELD)) + out = await signers.async_sign_pdf( + w, + signers.PdfSignatureMetadata(field_name='Sig1', subfilter=PADES), + signer=FROM_CA, + timestamper=DUMMY_TS, + ) + + with freeze_time('2020-11-25'): + r = PdfFileReader(out) + live_testing_vc(requests_mock) + result = await ades.ades_basic_validation( + r.embedded_signatures[0].signed_data, + validation_spec=DEFAULT_SIG_VALIDATION_SPEC, + raw_digest=r.embedded_signatures[0].compute_digest(), + ) + from pyhanko.sign.validation.report.tools import generate_report + + report = generate_report(r.embedded_signatures[0], result) + assert 'urn:etsi:019102:mainindication:total-passed' in report