pyhanko-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,355 @@
1
+ import asyncio
2
+ import getpass
3
+ from datetime import datetime
4
+
5
+ import click
6
+ import pyhanko.sign
7
+ from asn1crypto import cms, pem
8
+ from pyhanko.cli._trust import (
9
+ _get_key_usage_settings,
10
+ _prepare_vc,
11
+ build_vc_kwargs,
12
+ trust_options,
13
+ )
14
+ from pyhanko.cli.commands.signing import signing
15
+ from pyhanko.cli.runtime import pyhanko_exception_manager
16
+ from pyhanko.cli.utils import logger
17
+ from pyhanko.pdf_utils import crypt
18
+ from pyhanko.pdf_utils.misc import isoparse
19
+ from pyhanko.pdf_utils.reader import PdfFileReader
20
+ from pyhanko.sign import validation
21
+ from pyhanko.sign.validation import RevocationInfoValidationType
22
+ from pyhanko.sign.validation.errors import SignatureValidationError
23
+
24
+ from pyhanko_certvalidator import ValidationContext
25
+
26
+ __all__ = ['validate_signatures']
27
+
28
+
29
+ def _signature_status(
30
+ ltv_profile,
31
+ vc_kwargs,
32
+ force_revinfo,
33
+ key_usage_settings,
34
+ embedded_sig,
35
+ skip_diff=False,
36
+ ):
37
+ if ltv_profile is None:
38
+ vc = ValidationContext(**vc_kwargs)
39
+ status = pyhanko.sign.validation.validate_pdf_signature(
40
+ embedded_sig,
41
+ key_usage_settings=key_usage_settings,
42
+ signer_validation_context=vc,
43
+ skip_diff=skip_diff,
44
+ )
45
+ else:
46
+ status = validation.validate_pdf_ltv_signature(
47
+ embedded_sig,
48
+ ltv_profile,
49
+ key_usage_settings=key_usage_settings,
50
+ force_revinfo=force_revinfo,
51
+ validation_context_kwargs=vc_kwargs,
52
+ skip_diff=skip_diff,
53
+ )
54
+ return status
55
+
56
+
57
+ def _validate_detached(
58
+ infile, sig_infile, validation_context, key_usage_settings
59
+ ):
60
+ sig_bytes = sig_infile.read()
61
+ try:
62
+ if pem.detect(sig_bytes):
63
+ _, _, sig_bytes = pem.unarmor(sig_bytes)
64
+ content_info = cms.ContentInfo.load(sig_bytes)
65
+ if content_info['content_type'].native != 'signed_data':
66
+ raise click.ClickException("CMS content type is not signedData")
67
+ except ValueError as e:
68
+ raise click.ClickException("Could not parse CMS object") from e
69
+
70
+ validation_coro = validation.async_validate_detached_cms(
71
+ infile,
72
+ signed_data=content_info['content'],
73
+ signer_validation_context=validation_context,
74
+ key_usage_settings=key_usage_settings,
75
+ )
76
+ return asyncio.run(validation_coro)
77
+
78
+
79
+ def _signature_status_str(status_callback, pretty_print, executive_summary):
80
+ try:
81
+ status = status_callback()
82
+ if executive_summary and not pretty_print:
83
+ return (
84
+ 'VALID' if status.bottom_line else 'INVALID',
85
+ status.bottom_line,
86
+ )
87
+ elif pretty_print:
88
+ return status.pretty_print_details(), status.bottom_line
89
+ else:
90
+ return status.summary(), status.bottom_line
91
+ except validation.ValidationInfoReadingError as e:
92
+ msg = (
93
+ 'An error occurred while parsing the revocation information '
94
+ 'for this signature: ' + str(e)
95
+ )
96
+ logger.error(msg)
97
+ if pretty_print:
98
+ return msg, False
99
+ else:
100
+ return 'REVINFO_FAILURE', False
101
+ except SignatureValidationError as e:
102
+ msg = 'An error occurred while validating this signature: ' + str(e)
103
+ logger.error(msg, exc_info=e)
104
+ if pretty_print:
105
+ return msg, False
106
+ else:
107
+ return 'INVALID', False
108
+
109
+
110
+ def _attempt_iso_dt_parse(dt_str) -> datetime:
111
+ try:
112
+ dt = isoparse(dt_str)
113
+ except ValueError:
114
+ raise click.ClickException(f"datetime {dt_str!r} could not be parsed")
115
+ return dt
116
+
117
+
118
+ # TODO add an option to do LTV, but guess the profile
119
+
120
+
121
+ @trust_options
122
+ @signing.command(name='validate', help='validate signatures')
123
+ @click.argument('infile', type=click.File('rb'))
124
+ @click.option(
125
+ '--executive-summary',
126
+ help='only print final judgment on signature validity',
127
+ type=bool,
128
+ is_flag=True,
129
+ default=False,
130
+ show_default=True,
131
+ )
132
+ @click.option(
133
+ '--pretty-print',
134
+ help='render a prettier summary for the signatures in the file',
135
+ type=bool,
136
+ is_flag=True,
137
+ default=False,
138
+ show_default=True,
139
+ )
140
+ @click.option(
141
+ '--ltv-profile',
142
+ help='LTV signature validation profile',
143
+ type=click.Choice(RevocationInfoValidationType.as_tuple()),
144
+ required=False,
145
+ )
146
+ @click.option(
147
+ '--force-revinfo',
148
+ help='Fail trust validation if a certificate has no known CRL '
149
+ 'or OCSP endpoints.',
150
+ type=bool,
151
+ is_flag=True,
152
+ default=False,
153
+ show_default=True,
154
+ )
155
+ @click.option(
156
+ '--soft-revocation-check',
157
+ help='Do not fail validation on revocation checking failures '
158
+ '(only applied to on-line revocation checks)',
159
+ type=bool,
160
+ is_flag=True,
161
+ default=False,
162
+ show_default=True,
163
+ )
164
+ @click.option(
165
+ '--no-revocation-check',
166
+ help='Do not attempt to check revocation status '
167
+ '(meaningless for LTV validation)',
168
+ type=bool,
169
+ is_flag=True,
170
+ default=False,
171
+ show_default=True,
172
+ )
173
+ @click.option(
174
+ '--retroactive-revinfo',
175
+ help='Treat revocation info as retroactively valid '
176
+ '(i.e. ignore thisUpdate timestamp)',
177
+ type=bool,
178
+ is_flag=True,
179
+ default=False,
180
+ show_default=True,
181
+ )
182
+ @click.option(
183
+ '--validation-time',
184
+ help=(
185
+ 'Override the validation time (ISO 8601 date). '
186
+ 'The special value \'claimed\' causes the validation time '
187
+ 'claimed by the signer to be used. Revocation checking '
188
+ 'will be disabled. Option ignored in LTV mode.'
189
+ ),
190
+ type=str,
191
+ required=False,
192
+ )
193
+ @click.option(
194
+ '--password',
195
+ required=False,
196
+ type=str,
197
+ help='password to access the file (can also be read from stdin)',
198
+ )
199
+ @click.option(
200
+ '--no-diff-analysis',
201
+ default=False,
202
+ type=bool,
203
+ is_flag=True,
204
+ help='disable incremental update analysis',
205
+ )
206
+ @click.option(
207
+ '--detached',
208
+ type=click.File('rb'),
209
+ help=(
210
+ 'Read signature CMS object from the indicated file; '
211
+ 'this can be used to verify signatures on non-PDF files'
212
+ ),
213
+ )
214
+ @click.option(
215
+ '--no-strict-syntax',
216
+ help='Attempt to ignore syntactical problems in the input file '
217
+ 'and enable signature validation in hybrid-reference files.'
218
+ '(warning: this may affect validation results in unexpected '
219
+ 'ways.)',
220
+ type=bool,
221
+ is_flag=True,
222
+ default=False,
223
+ show_default=True,
224
+ )
225
+ @click.pass_context
226
+ def validate_signatures(
227
+ ctx: click.Context,
228
+ infile,
229
+ executive_summary,
230
+ pretty_print,
231
+ validation_context,
232
+ trust,
233
+ trust_replace,
234
+ other_certs,
235
+ ltv_profile,
236
+ force_revinfo,
237
+ soft_revocation_check,
238
+ no_revocation_check,
239
+ password,
240
+ retroactive_revinfo,
241
+ detached,
242
+ no_diff_analysis,
243
+ validation_time,
244
+ no_strict_syntax,
245
+ ):
246
+ no_revocation_check |= validation_time is not None
247
+
248
+ if no_revocation_check:
249
+ soft_revocation_check = True
250
+
251
+ if pretty_print and executive_summary:
252
+ raise click.ClickException(
253
+ "--pretty-print is incompatible with --executive-summary."
254
+ )
255
+
256
+ if ltv_profile is not None:
257
+ if validation_time is not None:
258
+ raise click.ClickException(
259
+ "--validation-time is not compatible with --ltv-profile"
260
+ )
261
+ ltv_profile = RevocationInfoValidationType(ltv_profile)
262
+
263
+ vc_kwargs = build_vc_kwargs(
264
+ ctx.obj.config,
265
+ validation_context,
266
+ trust,
267
+ trust_replace,
268
+ other_certs,
269
+ retroactive_revinfo,
270
+ allow_fetching=False if no_revocation_check else None,
271
+ )
272
+
273
+ use_claimed_validation_time = False
274
+ if validation_time == 'claimed':
275
+ use_claimed_validation_time = True
276
+ elif validation_time is not None:
277
+ vc_kwargs['moment'] = _attempt_iso_dt_parse(validation_time)
278
+
279
+ key_usage_settings = _get_key_usage_settings(ctx, validation_context)
280
+ vc_kwargs = _prepare_vc(
281
+ vc_kwargs,
282
+ soft_revocation_check=soft_revocation_check,
283
+ force_revinfo=force_revinfo,
284
+ )
285
+ with pyhanko_exception_manager():
286
+ if detached is not None:
287
+ (status_str, signature_ok) = _signature_status_str(
288
+ status_callback=lambda: _validate_detached(
289
+ infile,
290
+ detached,
291
+ ValidationContext(**vc_kwargs),
292
+ key_usage_settings,
293
+ ),
294
+ pretty_print=pretty_print,
295
+ executive_summary=executive_summary,
296
+ )
297
+ if signature_ok:
298
+ click.echo(status_str)
299
+ else:
300
+ raise click.ClickException(status_str)
301
+ return
302
+
303
+ if no_strict_syntax:
304
+ logger.info(
305
+ "Strict PDF syntax is disabled; this could impact validation "
306
+ "results. Use caution."
307
+ )
308
+ r = PdfFileReader(infile, strict=False)
309
+ else:
310
+ r = PdfFileReader(infile)
311
+ sh = r.security_handler
312
+ if isinstance(sh, crypt.StandardSecurityHandler):
313
+ if password is None:
314
+ password = getpass.getpass(prompt='File password: ')
315
+ auth_result = r.decrypt(password)
316
+ if auth_result.status == crypt.AuthStatus.FAILED:
317
+ raise click.ClickException("Password didn't match.")
318
+ elif sh is not None:
319
+ raise click.ClickException(
320
+ "The CLI supports only password-based encryption when "
321
+ "validating (for now)"
322
+ )
323
+
324
+ all_signatures_ok = True
325
+ for ix, embedded_sig in enumerate(r.embedded_regular_signatures):
326
+ fingerprint: str = embedded_sig.signer_cert.sha256.hex()
327
+ if use_claimed_validation_time:
328
+ vc_kwargs['moment'] = embedded_sig.self_reported_timestamp
329
+ (status_str, signature_ok) = _signature_status_str(
330
+ status_callback=lambda: _signature_status(
331
+ ltv_profile=ltv_profile,
332
+ force_revinfo=force_revinfo,
333
+ vc_kwargs=vc_kwargs,
334
+ key_usage_settings=key_usage_settings,
335
+ embedded_sig=embedded_sig,
336
+ skip_diff=no_diff_analysis,
337
+ ),
338
+ pretty_print=pretty_print,
339
+ executive_summary=executive_summary,
340
+ )
341
+ name = embedded_sig.field_name
342
+
343
+ if pretty_print:
344
+ header = f'Field {ix + 1}: {name}'
345
+ line = '=' * len(header)
346
+ click.echo(line)
347
+ click.echo(header)
348
+ click.echo(line)
349
+ click.echo('\n\n' + status_str)
350
+ else:
351
+ click.echo('%s:%s:%s' % (name, fingerprint, status_str))
352
+ all_signatures_ok &= signature_ok
353
+
354
+ if not all_signatures_ok:
355
+ raise click.ClickException("Validation failed")
pyhanko/cli/config.py ADDED
@@ -0,0 +1,306 @@
1
+ from dataclasses import dataclass
2
+ from datetime import timedelta
3
+ from typing import Dict, List, Optional, Type, Union
4
+
5
+ import yaml
6
+ from pyhanko.config.errors import ConfigurationError
7
+ from pyhanko.config.logging import LogConfig, parse_logging_config
8
+ from pyhanko.config.trust import DEFAULT_TIME_TOLERANCE, parse_trust_config
9
+ from pyhanko.sign.signers import DEFAULT_SIGNING_STAMP_STYLE
10
+ from pyhanko.sign.validation.settings import KeyUsageConstraints
11
+ from pyhanko.stamp import BaseStampStyle, QRStampStyle, TextStampStyle
12
+
13
+ from pyhanko_certvalidator import ValidationContext
14
+
15
+
16
+ @dataclass
17
+ class CLIConfig:
18
+ """
19
+ CLI configuration settings.
20
+ """
21
+
22
+ validation_contexts: Dict[str, dict]
23
+ """
24
+ Named validation contexts. The values in this dictionary
25
+ are themselves dictionaries that support the following keys:
26
+
27
+ * ``trust``: path to a root certificate or list of such paths
28
+ * ``trust-replace``: whether the value of the ``trust`` setting should
29
+ replace the system trust, or add to it
30
+ * ``other-certs``: paths to other relevant certificates that are not
31
+ trusted by fiat.
32
+ * ``time-tolerance``: a time drift tolerance setting in seconds
33
+ * ``retroactive-revinfo``: whether to consider revocation information
34
+ retroactively valid
35
+ * ``signer-key-usage-policy``: Signer key usage requirements. See
36
+ :class:`.KeyUsageConstraints`.
37
+
38
+ There are two settings that are deprecated but still supported for backwards
39
+ compatibility:
40
+
41
+ * ``signer-key-usage``: Supplanted by ``signer-key-usage-policy``
42
+ * ``signer-extd-key-usage``: Supplanted by ``signer-key-usage-policy``
43
+
44
+ These may eventually be removed.
45
+
46
+ Callers should not process this information directly, but rely on
47
+ :meth:`get_validation_context` instead.
48
+ """
49
+
50
+ stamp_styles: Dict[str, dict]
51
+ """
52
+ Named stamp styles. The type of style is selected by the ``type`` key, which
53
+ can be either ``qr`` or ``text`` (the default is ``text``).
54
+ For other settings values, see :class:.`QRStampStyle` and
55
+ :class:`.TextStampStyle`.
56
+
57
+
58
+ Callers should not process this information directly, but rely on
59
+ :meth:`get_stamp_style` instead.
60
+ """
61
+
62
+ default_validation_context: str
63
+ """
64
+ The name of the default validation context.
65
+ The default value for this setting is ``default``.
66
+ """
67
+
68
+ default_stamp_style: str
69
+ """
70
+ The name of the default stamp style.
71
+ The default value for this setting is ``default``.
72
+ """
73
+
74
+ time_tolerance: timedelta
75
+ """
76
+ Time drift tolerance (global default).
77
+ """
78
+
79
+ retroactive_revinfo: bool
80
+ """
81
+ Whether to consider revocation information retroactively valid
82
+ (global default).
83
+ """
84
+
85
+ raw_config: dict
86
+ """
87
+ The raw config data parsed into a Python dictionary.
88
+ """
89
+
90
+ # TODO graceful error handling for syntax & type issues?
91
+
92
+ def _get_validation_settings_raw(self, name=None):
93
+ name = name or self.default_validation_context
94
+ try:
95
+ return self.validation_contexts[name]
96
+ except KeyError:
97
+ raise ConfigurationError(
98
+ f"There is no validation context named '{name}'."
99
+ )
100
+
101
+ def get_validation_context(
102
+ self, name: Optional[str] = None, as_dict: bool = False
103
+ ):
104
+ """
105
+ Retrieve a validation context by name.
106
+
107
+ :param name:
108
+ The name of the validation context. If not supplied, the value
109
+ of :attr:`default_validation_context` will be used.
110
+ :param as_dict:
111
+ If ``True`` return the settings as a keyword argument dictionary.
112
+ If ``False`` (the default), return a
113
+ :class:`~pyhanko_certvalidator.context.ValidationContext`
114
+ object.
115
+ """
116
+ vc_config = self._get_validation_settings_raw(name)
117
+ vc_kwargs = parse_trust_config(
118
+ vc_config, self.time_tolerance, self.retroactive_revinfo
119
+ )
120
+ return vc_kwargs if as_dict else ValidationContext(**vc_kwargs)
121
+
122
+ def get_signer_key_usages(
123
+ self, name: Optional[str] = None
124
+ ) -> KeyUsageConstraints:
125
+ """
126
+ Get a set of key usage constraints for a given validation context.
127
+
128
+ :param name:
129
+ The name of the validation context. If not supplied, the value
130
+ of :attr:`default_validation_context` will be used.
131
+ :return:
132
+ A :class:`.KeyUsageConstraints` object.
133
+ """
134
+ vc_config = self._get_validation_settings_raw(name)
135
+
136
+ try:
137
+ policy_settings = dict(vc_config['signer-key-usage-policy'])
138
+ except KeyError:
139
+ policy_settings = {}
140
+
141
+ # fallbacks to stay compatible with the simpler 0.5.0 signer-key-usage
142
+ # and signer-extd-key-usage settings: copy old settings keys to
143
+ # their corresponding values in the new one
144
+
145
+ try:
146
+ key_usage_strings = vc_config['signer-key-usage']
147
+ policy_settings.setdefault('key-usage', key_usage_strings)
148
+ except KeyError:
149
+ pass
150
+
151
+ try:
152
+ key_usage_strings = vc_config['signer-extd-key-usage']
153
+ policy_settings.setdefault('extd-key-usage', key_usage_strings)
154
+ except KeyError:
155
+ pass
156
+
157
+ return KeyUsageConstraints.from_config(policy_settings)
158
+
159
+ def get_stamp_style(
160
+ self, name: Optional[str] = None
161
+ ) -> Union[TextStampStyle, QRStampStyle]:
162
+ """
163
+ Retrieve a stamp style by name.
164
+
165
+ :param name:
166
+ The name of the style. If not supplied, the value
167
+ of :attr:`default_stamp_style` will be used.
168
+ :return:
169
+ A :class:`TextStampStyle` or `QRStampStyle` object.
170
+ """
171
+ name = name or self.default_stamp_style
172
+ config = self.stamp_styles.get(name, None)
173
+ if config is None:
174
+ raise ConfigurationError(f"There is no stamp style named '{name}'.")
175
+ try:
176
+ style_config = dict(config)
177
+ except (TypeError, ValueError) as e:
178
+ raise ConfigurationError(
179
+ f"Could not process stamp style named '{name}'"
180
+ ) from e
181
+ cls = STAMP_STYLE_TYPES[style_config.pop('type', 'text')]
182
+ return cls.from_config(style_config)
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class CLIRootConfig:
187
+ """
188
+ Config settings that are only relevant tothe CLI root and are not exposed
189
+ to subcommands and plugins.
190
+ """
191
+
192
+ config: CLIConfig
193
+ """
194
+ General CLI config.
195
+ """
196
+
197
+ log_config: Dict[Optional[str], LogConfig]
198
+ """
199
+ Per-module logging configuration. The keys in this dictionary are
200
+ module names, the :class:`.LogConfig` values define the logging settings.
201
+
202
+ The ``None`` key houses the configuration for the root logger, if any.
203
+ """
204
+
205
+ plugin_endpoints: List[str]
206
+ """
207
+ List of plugin endpoints to load, of the form
208
+ ``package.module:PluginClass``.
209
+ See :class:`~pyhanko.cli.plugin_api.SigningCommandPlugin`.
210
+
211
+ The value of this setting is ignored if ``--no-plugins`` is passed.
212
+
213
+ .. note::
214
+ This is convenient for importing plugin classes that don't live in
215
+ installed packages for some reason or another.
216
+
217
+ Plugins that are part of packages should define their endpoints
218
+ in the package metadata, which will allow them to be discovered
219
+ automatically. See the docs for
220
+ :class:`~pyhanko.cli.plugin_api.SigningCommandPlugin` for more
221
+ information.
222
+ """
223
+
224
+
225
+ # TODO allow CRL/OCSP loading here as well (esp. CRL loading might be useful
226
+ # in some cases)
227
+ # Time-related settings are probably better off in the CLI.
228
+
229
+
230
+ DEFAULT_VALIDATION_CONTEXT = DEFAULT_STAMP_STYLE = 'default'
231
+ STAMP_STYLE_TYPES: Dict[str, Type[BaseStampStyle]] = {
232
+ 'qr': QRStampStyle,
233
+ 'text': TextStampStyle,
234
+ }
235
+
236
+
237
+ def parse_cli_config(yaml_str) -> CLIRootConfig:
238
+ config_dict = yaml.safe_load(yaml_str) or {}
239
+ return CLIRootConfig(
240
+ **process_root_config_settings(config_dict),
241
+ config=CLIConfig(
242
+ **process_config_dict(config_dict), raw_config=config_dict
243
+ ),
244
+ )
245
+
246
+
247
+ def process_root_config_settings(config_dict: dict) -> dict:
248
+ plugins = config_dict.get('plugins', [])
249
+ # logging config
250
+ log_config_spec = config_dict.get('logging', {})
251
+ log_config = parse_logging_config(log_config_spec)
252
+ return dict(
253
+ log_config=log_config,
254
+ plugin_endpoints=plugins,
255
+ )
256
+
257
+
258
+ def process_config_dict(config_dict: dict) -> dict:
259
+ # validation context config
260
+ vcs: Dict[str, dict] = {DEFAULT_VALIDATION_CONTEXT: {}}
261
+ try:
262
+ vc_specs = config_dict['validation-contexts']
263
+ vcs.update(vc_specs)
264
+ except KeyError:
265
+ pass
266
+
267
+ # stamp style config
268
+ # TODO this style is obviously not suited for non-signing scenarios
269
+ # (but it'll do for now)
270
+ stamp_configs = {
271
+ DEFAULT_STAMP_STYLE: {
272
+ 'stamp-text': DEFAULT_SIGNING_STAMP_STYLE.stamp_text,
273
+ 'background': '__stamp__',
274
+ }
275
+ }
276
+ try:
277
+ stamp_specs = config_dict['stamp-styles']
278
+ stamp_configs.update(stamp_specs)
279
+ except KeyError:
280
+ pass
281
+
282
+ # some misc settings
283
+ default_vc = config_dict.get(
284
+ 'default-validation-context', DEFAULT_VALIDATION_CONTEXT
285
+ )
286
+ default_stamp_style = config_dict.get(
287
+ 'default-stamp-style', DEFAULT_STAMP_STYLE
288
+ )
289
+ time_tolerance_seconds = config_dict.get(
290
+ 'time-tolerance', DEFAULT_TIME_TOLERANCE.seconds
291
+ )
292
+ if not isinstance(time_tolerance_seconds, int):
293
+ raise ConfigurationError(
294
+ "time-tolerance parameter must be specified in seconds"
295
+ )
296
+
297
+ time_tolerance = timedelta(seconds=time_tolerance_seconds)
298
+ retroactive_revinfo = bool(config_dict.get('retroactive-revinfo', False))
299
+ return dict(
300
+ validation_contexts=vcs,
301
+ default_validation_context=default_vc,
302
+ time_tolerance=time_tolerance,
303
+ retroactive_revinfo=retroactive_revinfo,
304
+ stamp_styles=stamp_configs,
305
+ default_stamp_style=default_stamp_style,
306
+ )