356 lines
11 KiB
Python
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")
|