356 lines
11 KiB
Python

import asyncio
import getpass
from datetime import datetime
import click
from asn1crypto import cms, pem
from pyhanko_certvalidator import ValidationContext
import pyhanko.sign
from pyhanko.cli._trust import (
_get_key_usage_settings,
_prepare_vc,
build_vc_kwargs,
trust_options,
)
from pyhanko.cli.commands.signing import signing
from pyhanko.cli.runtime import pyhanko_exception_manager
from pyhanko.cli.utils import logger
from pyhanko.pdf_utils import crypt
from pyhanko.pdf_utils.misc import isoparse
from pyhanko.pdf_utils.reader import PdfFileReader
from pyhanko.sign import validation
from pyhanko.sign.validation import RevocationInfoValidationType
from pyhanko.sign.validation.errors import SignatureValidationError
__all__ = ['validate_signatures']
def _signature_status(
ltv_profile,
vc_kwargs,
force_revinfo,
key_usage_settings,
embedded_sig,
skip_diff=False,
):
if ltv_profile is None:
vc = ValidationContext(**vc_kwargs)
status = pyhanko.sign.validation.validate_pdf_signature(
embedded_sig,
key_usage_settings=key_usage_settings,
signer_validation_context=vc,
skip_diff=skip_diff,
)
else:
status = validation.validate_pdf_ltv_signature(
embedded_sig,
ltv_profile,
key_usage_settings=key_usage_settings,
force_revinfo=force_revinfo,
validation_context_kwargs=vc_kwargs,
skip_diff=skip_diff,
)
return status
def _validate_detached(
infile, sig_infile, validation_context, key_usage_settings
):
sig_bytes = sig_infile.read()
try:
if pem.detect(sig_bytes):
_, _, sig_bytes = pem.unarmor(sig_bytes)
content_info = cms.ContentInfo.load(sig_bytes)
if content_info['content_type'].native != 'signed_data':
raise click.ClickException("CMS content type is not signedData")
except ValueError as e:
raise click.ClickException("Could not parse CMS object") from e
validation_coro = validation.async_validate_detached_cms(
infile,
signed_data=content_info['content'],
signer_validation_context=validation_context,
key_usage_settings=key_usage_settings,
)
return asyncio.run(validation_coro)
def _signature_status_str(status_callback, pretty_print, executive_summary):
try:
status = status_callback()
if executive_summary and not pretty_print:
return (
'VALID' if status.bottom_line else 'INVALID',
status.bottom_line,
)
elif pretty_print:
return status.pretty_print_details(), status.bottom_line
else:
return status.summary(), status.bottom_line
except validation.ValidationInfoReadingError as e:
msg = (
'An error occurred while parsing the revocation information '
'for this signature: ' + str(e)
)
logger.error(msg)
if pretty_print:
return msg, False
else:
return 'REVINFO_FAILURE', False
except SignatureValidationError as e:
msg = 'An error occurred while validating this signature: ' + str(e)
logger.error(msg, exc_info=e)
if pretty_print:
return msg, False
else:
return 'INVALID', False
def _attempt_iso_dt_parse(dt_str) -> datetime:
try:
dt = isoparse(dt_str)
except ValueError:
raise click.ClickException(f"datetime {dt_str!r} could not be parsed")
return dt
# TODO add an option to do LTV, but guess the profile
@trust_options
@signing.command(name='validate', help='validate signatures')
@click.argument('infile', type=click.File('rb'))
@click.option(
'--executive-summary',
help='only print final judgment on signature validity',
type=bool,
is_flag=True,
default=False,
show_default=True,
)
@click.option(
'--pretty-print',
help='render a prettier summary for the signatures in the file',
type=bool,
is_flag=True,
default=False,
show_default=True,
)
@click.option(
'--ltv-profile',
help='LTV signature validation profile',
type=click.Choice(RevocationInfoValidationType.as_tuple()),
required=False,
)
@click.option(
'--force-revinfo',
help='Fail trust validation if a certificate has no known CRL '
'or OCSP endpoints.',
type=bool,
is_flag=True,
default=False,
show_default=True,
)
@click.option(
'--soft-revocation-check',
help='Do not fail validation on revocation checking failures '
'(only applied to on-line revocation checks)',
type=bool,
is_flag=True,
default=False,
show_default=True,
)
@click.option(
'--no-revocation-check',
help='Do not attempt to check revocation status '
'(meaningless for LTV validation)',
type=bool,
is_flag=True,
default=False,
show_default=True,
)
@click.option(
'--retroactive-revinfo',
help='Treat revocation info as retroactively valid '
'(i.e. ignore thisUpdate timestamp)',
type=bool,
is_flag=True,
default=False,
show_default=True,
)
@click.option(
'--validation-time',
help=(
'Override the validation time (ISO 8601 date). '
'The special value \'claimed\' causes the validation time '
'claimed by the signer to be used. Revocation checking '
'will be disabled. Option ignored in LTV mode.'
),
type=str,
required=False,
)
@click.option(
'--password',
required=False,
type=str,
help='password to access the file (can also be read from stdin)',
)
@click.option(
'--no-diff-analysis',
default=False,
type=bool,
is_flag=True,
help='disable incremental update analysis',
)
@click.option(
'--detached',
type=click.File('rb'),
help=(
'Read signature CMS object from the indicated file; '
'this can be used to verify signatures on non-PDF files'
),
)
@click.option(
'--no-strict-syntax',
help='Attempt to ignore syntactical problems in the input file '
'and enable signature validation in hybrid-reference files.'
'(warning: this may affect validation results in unexpected '
'ways.)',
type=bool,
is_flag=True,
default=False,
show_default=True,
)
@click.pass_context
def validate_signatures(
ctx: click.Context,
infile,
executive_summary,
pretty_print,
validation_context,
trust,
trust_replace,
other_certs,
ltv_profile,
force_revinfo,
soft_revocation_check,
no_revocation_check,
password,
retroactive_revinfo,
detached,
no_diff_analysis,
validation_time,
no_strict_syntax,
):
no_revocation_check |= validation_time is not None
if no_revocation_check:
soft_revocation_check = True
if pretty_print and executive_summary:
raise click.ClickException(
"--pretty-print is incompatible with --executive-summary."
)
if ltv_profile is not None:
if validation_time is not None:
raise click.ClickException(
"--validation-time is not compatible with --ltv-profile"
)
ltv_profile = RevocationInfoValidationType(ltv_profile)
vc_kwargs = build_vc_kwargs(
ctx.obj.config,
validation_context,
trust,
trust_replace,
other_certs,
retroactive_revinfo,
allow_fetching=False if no_revocation_check else None,
)
use_claimed_validation_time = False
if validation_time == 'claimed':
use_claimed_validation_time = True
elif validation_time is not None:
vc_kwargs['moment'] = _attempt_iso_dt_parse(validation_time)
key_usage_settings = _get_key_usage_settings(ctx, validation_context)
vc_kwargs = _prepare_vc(
vc_kwargs,
soft_revocation_check=soft_revocation_check,
force_revinfo=force_revinfo,
)
with pyhanko_exception_manager():
if detached is not None:
(status_str, signature_ok) = _signature_status_str(
status_callback=lambda: _validate_detached(
infile,
detached,
ValidationContext(**vc_kwargs),
key_usage_settings,
),
pretty_print=pretty_print,
executive_summary=executive_summary,
)
if signature_ok:
click.echo(status_str)
else:
raise click.ClickException(status_str)
return
if no_strict_syntax:
logger.info(
"Strict PDF syntax is disabled; this could impact validation "
"results. Use caution."
)
r = PdfFileReader(infile, strict=False)
else:
r = PdfFileReader(infile)
sh = r.security_handler
if isinstance(sh, crypt.StandardSecurityHandler):
if password is None:
password = getpass.getpass(prompt='File password: ')
auth_result = r.decrypt(password)
if auth_result.status == crypt.AuthStatus.FAILED:
raise click.ClickException("Password didn't match.")
elif sh is not None:
raise click.ClickException(
"The CLI supports only password-based encryption when "
"validating (for now)"
)
all_signatures_ok = True
for ix, embedded_sig in enumerate(r.embedded_regular_signatures):
fingerprint: str = embedded_sig.signer_cert.sha256.hex()
if use_claimed_validation_time:
vc_kwargs['moment'] = embedded_sig.self_reported_timestamp
(status_str, signature_ok) = _signature_status_str(
status_callback=lambda: _signature_status(
ltv_profile=ltv_profile,
force_revinfo=force_revinfo,
vc_kwargs=vc_kwargs,
key_usage_settings=key_usage_settings,
embedded_sig=embedded_sig,
skip_diff=no_diff_analysis,
),
pretty_print=pretty_print,
executive_summary=executive_summary,
)
name = embedded_sig.field_name
if pretty_print:
header = f'Field {ix + 1}: {name}'
line = '=' * len(header)
click.echo(line)
click.echo(header)
click.echo(line)
click.echo('\n\n' + status_str)
else:
click.echo('%s:%s:%s' % (name, fingerprint, status_str))
all_signatures_ok &= signature_ok
if not all_signatures_ok:
raise click.ClickException("Validation failed")