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,66 @@
1
+ import click
2
+ from pyhanko.cli.commands.signing import signing
3
+ from pyhanko.cli.runtime import pyhanko_exception_manager
4
+ from pyhanko.cli.utils import parse_field_location_spec
5
+ from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
6
+ from pyhanko.pdf_utils.reader import PdfFileReader
7
+ from pyhanko.pdf_utils.writer import copy_into_new_writer
8
+ from pyhanko.sign import fields
9
+
10
+ __all__ = ['list_sigfields', 'add_sig_field']
11
+
12
+
13
+ @signing.command(name='list', help='list signature fields')
14
+ @click.argument('infile', type=click.File('rb'))
15
+ @click.option(
16
+ '--skip-status',
17
+ help='do not print status',
18
+ required=False,
19
+ type=bool,
20
+ is_flag=True,
21
+ default=False,
22
+ show_default=True,
23
+ )
24
+ def list_sigfields(infile, skip_status):
25
+ with pyhanko_exception_manager():
26
+ r = PdfFileReader(infile, strict=False)
27
+ field_info = fields.enumerate_sig_fields(r)
28
+ for ix, (name, value, field_ref) in enumerate(field_info):
29
+ if skip_status:
30
+ click.echo(name)
31
+ continue
32
+ click.echo(f"{name}:{'EMPTY' if value is None else 'FILLED'}")
33
+
34
+
35
+ @signing.command(
36
+ name='addfields', help='add empty signature fields to a PDF field'
37
+ )
38
+ @click.argument('infile', type=click.File('rb'))
39
+ @click.argument('outfile', type=click.File('wb'))
40
+ @click.option(
41
+ '--field',
42
+ metavar='PAGE/X1,Y1,X2,Y2/NAME',
43
+ multiple=True,
44
+ required=True,
45
+ help="Field specification (multiple allowed)",
46
+ )
47
+ @click.option(
48
+ '--resave',
49
+ is_flag=True,
50
+ help="Resave the PDF document instead of creating an incremental update",
51
+ )
52
+ def add_sig_field(infile, outfile, field, resave):
53
+ with pyhanko_exception_manager():
54
+ if resave:
55
+ writer = copy_into_new_writer(PdfFileReader(infile, strict=False))
56
+ else:
57
+ writer = IncrementalPdfFileWriter(infile, strict=False)
58
+
59
+ for s in field:
60
+ name, spec = parse_field_location_spec(s)
61
+ assert spec is not None
62
+ fields.append_signature_field(writer, spec)
63
+
64
+ writer.write(outfile)
65
+ infile.close()
66
+ outfile.close()
@@ -0,0 +1,328 @@
1
+ from typing import List, Optional
2
+
3
+ import click
4
+ from pyhanko.cli._root import cli_root
5
+ from pyhanko.cli._trust import (
6
+ _get_key_usage_settings,
7
+ build_vc_kwargs,
8
+ trust_options,
9
+ )
10
+ from pyhanko.cli.commands.signing.plugin import command_from_plugin
11
+ from pyhanko.cli.commands.stamp import select_style
12
+ from pyhanko.cli.utils import parse_field_location_spec
13
+ from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
14
+ from pyhanko.sign import DEFAULT_SIGNER_KEY_USAGE, fields, signers
15
+ from pyhanko.sign.signers.pdf_byterange import BuildProps
16
+ from pyhanko.sign.timestamps import HTTPTimeStamper
17
+ from pyhanko.version import __version__
18
+
19
+ from pyhanko_certvalidator import ValidationContext
20
+
21
+ from ..._ctx import CLIContext
22
+ from ...plugin_api import SigningCommandPlugin
23
+
24
+ __all__ = ['signing', 'addsig', 'register']
25
+
26
+ from ...runtime import pyhanko_exception_manager
27
+
28
+
29
+ @cli_root.group(help='sign PDFs and other files', name='sign')
30
+ def signing():
31
+ pass
32
+
33
+
34
+ @trust_options
35
+ @signing.group(name='addsig', help='add a signature')
36
+ @click.option(
37
+ '--field',
38
+ help=(
39
+ 'signature field name, or field specification PAGE/X1,Y1,X2,Y2/NAME '
40
+ '(required unless the field contains exactly one signature field)'
41
+ ),
42
+ required=False,
43
+ )
44
+ @click.option('--name', help='explicitly specify signer name', required=False)
45
+ @click.option('--reason', help='reason for signing', required=False)
46
+ @click.option('--location', help='location of signing', required=False)
47
+ @click.option('--contact-info', help='contact of the signer', required=False)
48
+ @click.option(
49
+ '--certify',
50
+ help='add certification signature',
51
+ required=False,
52
+ default=False,
53
+ is_flag=True,
54
+ type=bool,
55
+ show_default=True,
56
+ )
57
+ @click.option(
58
+ '--existing-only',
59
+ help='never create signature fields',
60
+ required=False,
61
+ default=False,
62
+ is_flag=True,
63
+ type=bool,
64
+ show_default=True,
65
+ )
66
+ @click.option(
67
+ '--timestamp-url',
68
+ help='URL for timestamp server',
69
+ required=False,
70
+ type=str,
71
+ default=None,
72
+ )
73
+ @click.option(
74
+ '--use-pades',
75
+ help='sign PAdES-style [level B/B-T/B-LT/B-LTA]',
76
+ required=False,
77
+ default=False,
78
+ is_flag=True,
79
+ type=bool,
80
+ show_default=True,
81
+ )
82
+ @click.option(
83
+ '--use-pades-lta',
84
+ help='produce PAdES-B-LTA signature',
85
+ required=False,
86
+ default=False,
87
+ is_flag=True,
88
+ type=bool,
89
+ show_default=True,
90
+ )
91
+ @click.option(
92
+ '--prefer-pss',
93
+ is_flag=True,
94
+ default=False,
95
+ type=bool,
96
+ help='prefer RSASSA-PSS to PKCS#1 v1.5 padding, if available',
97
+ )
98
+ @click.option(
99
+ '--with-validation-info',
100
+ help='embed revocation info',
101
+ required=False,
102
+ default=False,
103
+ is_flag=True,
104
+ type=bool,
105
+ show_default=True,
106
+ )
107
+ @click.option(
108
+ '--style-name',
109
+ help='stamp style name for signature appearance',
110
+ required=False,
111
+ type=str,
112
+ )
113
+ @click.option(
114
+ '--stamp-url',
115
+ help='QR code URL to use in QR stamp style',
116
+ required=False,
117
+ type=str,
118
+ )
119
+ @click.option(
120
+ '--detach',
121
+ type=bool,
122
+ is_flag=True,
123
+ default=False,
124
+ help=(
125
+ 'write only the signature CMS object to the output file; '
126
+ 'this can be used to sign non-PDF files'
127
+ ),
128
+ )
129
+ @click.option(
130
+ '--detach-pem',
131
+ help='output PEM data instead of DER when using --detach',
132
+ type=bool,
133
+ is_flag=True,
134
+ default=False,
135
+ )
136
+ @click.option(
137
+ '--retroactive-revinfo',
138
+ help='Treat revocation info as retroactively valid '
139
+ '(i.e. ignore thisUpdate timestamp)',
140
+ type=bool,
141
+ is_flag=True,
142
+ default=False,
143
+ show_default=True,
144
+ )
145
+ @click.option(
146
+ '--no-strict-syntax',
147
+ help='Attempt to ignore syntactical problems in the input file '
148
+ 'and enable signature creation in hybrid-reference files.'
149
+ '(warning: such documents may behave in unexpected ways)',
150
+ type=bool,
151
+ is_flag=True,
152
+ default=False,
153
+ show_default=True,
154
+ )
155
+ @click.pass_context
156
+ def addsig(
157
+ ctx: click.Context,
158
+ field,
159
+ name,
160
+ reason,
161
+ contact_info,
162
+ location,
163
+ certify,
164
+ existing_only,
165
+ timestamp_url,
166
+ use_pades,
167
+ use_pades_lta,
168
+ with_validation_info,
169
+ validation_context,
170
+ trust_replace,
171
+ trust,
172
+ other_certs,
173
+ style_name,
174
+ stamp_url,
175
+ prefer_pss,
176
+ retroactive_revinfo,
177
+ detach,
178
+ detach_pem,
179
+ no_strict_syntax,
180
+ ):
181
+ ctx_obj: CLIContext = ctx.obj
182
+ ctx_obj.existing_fields_only = existing_only or field is None
183
+ ctx_obj.timestamp_url = timestamp_url
184
+ ctx_obj.prefer_pss = prefer_pss
185
+
186
+ if detach or detach_pem:
187
+ ctx_obj.detach_pem = detach_pem
188
+ ctx_obj.sig_settings = None
189
+ if field:
190
+ raise click.ClickException(
191
+ "--field is not compatible with --detach or --detach-pem"
192
+ )
193
+ return # everything else doesn't apply
194
+
195
+ if use_pades_lta:
196
+ use_pades = with_validation_info = True
197
+ if not timestamp_url:
198
+ raise click.ClickException(
199
+ "--timestamp-url is required for --use-pades-lta"
200
+ )
201
+ if use_pades:
202
+ subfilter = fields.SigSeedSubFilter.PADES
203
+ else:
204
+ subfilter = fields.SigSeedSubFilter.ADOBE_PKCS7_DETACHED
205
+
206
+ key_usage = DEFAULT_SIGNER_KEY_USAGE
207
+ if with_validation_info:
208
+ vc_kwargs = build_vc_kwargs(
209
+ ctx.obj.config,
210
+ validation_context,
211
+ trust,
212
+ trust_replace,
213
+ other_certs,
214
+ retroactive_revinfo,
215
+ allow_fetching=True,
216
+ )
217
+ vc = ValidationContext(**vc_kwargs)
218
+ key_usage_sett = _get_key_usage_settings(ctx, validation_context)
219
+ if key_usage_sett is not None and key_usage_sett.key_usage is not None:
220
+ key_usage = key_usage_sett.key_usage
221
+ else:
222
+ vc = None
223
+ field_name: Optional[str]
224
+ if field:
225
+ field_name, new_field_spec = parse_field_location_spec(
226
+ field, require_full_spec=False
227
+ )
228
+ else:
229
+ field_name = new_field_spec = None
230
+ if new_field_spec and existing_only:
231
+ raise click.ClickException(
232
+ "Specifying field coordinates is incompatible with --existing-only"
233
+ )
234
+ ctx_obj.sig_settings = signers.PdfSignatureMetadata(
235
+ field_name=field_name,
236
+ location=location,
237
+ reason=reason,
238
+ contact_info=contact_info,
239
+ name=name,
240
+ certify=certify,
241
+ subfilter=subfilter,
242
+ embed_validation_info=with_validation_info,
243
+ validation_context=vc,
244
+ signer_key_usage=key_usage,
245
+ use_pades_lta=use_pades_lta,
246
+ app_build_props=BuildProps(name='pyHanko CLI', revision=__version__),
247
+ )
248
+ ctx_obj.new_field_spec = new_field_spec
249
+ ctx_obj.stamp_style = select_style(ctx, style_name, stamp_url)
250
+ ctx_obj.stamp_url = stamp_url
251
+ ctx_obj.lenient = no_strict_syntax
252
+ ctx_obj.ux.visible_signature_desired = bool(style_name or new_field_spec)
253
+
254
+
255
+ def _unavailable(signer_plugin: SigningCommandPlugin):
256
+ def _unavailable_callback():
257
+ raise click.ClickException(
258
+ signer_plugin.unavailable_message
259
+ or "This subcommand is not available"
260
+ )
261
+
262
+ return _unavailable_callback
263
+
264
+
265
+ def register(plugins: List[SigningCommandPlugin]):
266
+ # we reset the command list before (re)populating it, in order to
267
+ # make the tests more consistent
268
+ addsig.commands = {}
269
+ for signer_plugin in plugins:
270
+ if signer_plugin.is_available():
271
+ addsig.add_command(command_from_plugin(signer_plugin))
272
+ else:
273
+
274
+ addsig.add_command(
275
+ click.Command(
276
+ name=signer_plugin.subcommand_name,
277
+ help=signer_plugin.help_summary + " [unavailable]",
278
+ callback=_unavailable(signer_plugin),
279
+ )
280
+ )
281
+
282
+
283
+ readable_file = click.Path(exists=True, readable=True, dir_okay=False)
284
+ writable_file = click.Path(writable=True, dir_okay=False)
285
+
286
+
287
+ @trust_options
288
+ @signing.command(name='timestamp', help='add timestamp to PDF')
289
+ @click.argument('infile', type=readable_file)
290
+ @click.argument('outfile', type=writable_file)
291
+ @click.option(
292
+ '--timestamp-url',
293
+ help='URL for timestamp server',
294
+ required=True,
295
+ type=str,
296
+ default=None,
297
+ )
298
+ @click.pass_context
299
+ def timestamp(
300
+ ctx,
301
+ infile,
302
+ outfile,
303
+ validation_context,
304
+ trust,
305
+ trust_replace,
306
+ other_certs,
307
+ timestamp_url,
308
+ ):
309
+ with pyhanko_exception_manager():
310
+ vc_kwargs = build_vc_kwargs(
311
+ ctx.obj.config,
312
+ validation_context,
313
+ trust,
314
+ trust_replace,
315
+ other_certs,
316
+ retroactive_revinfo=True,
317
+ )
318
+ timestamper = HTTPTimeStamper(timestamp_url)
319
+ with open(infile, 'rb') as inf:
320
+ w = IncrementalPdfFileWriter(inf)
321
+ pdf_timestamper = signers.PdfTimeStamper(timestamper)
322
+ with open(outfile, 'wb') as outf:
323
+ pdf_timestamper.timestamp_pdf(
324
+ w,
325
+ 'sha256',
326
+ validation_context=ValidationContext(**vc_kwargs),
327
+ output=outf,
328
+ )
@@ -0,0 +1,185 @@
1
+ import getpass
2
+ import os
3
+ from typing import ContextManager, List, Optional
4
+
5
+ import click
6
+ from pyhanko.cli._ctx import CLIContext
7
+ from pyhanko.cli.config import CLIConfig
8
+ from pyhanko.cli.plugin_api import SigningCommandPlugin
9
+ from pyhanko.cli.utils import logger, readable_file
10
+ from pyhanko.config.errors import ConfigurationError
11
+ from pyhanko.config.pkcs11 import (
12
+ PKCS11PinEntryMode,
13
+ PKCS11SignatureConfig,
14
+ TokenCriteria,
15
+ )
16
+ from pyhanko.sign import Signer
17
+
18
+ __all__ = ['PKCS11Plugin']
19
+
20
+
21
+ try:
22
+ import pkcs11 # lgtm [py/unused-import]
23
+
24
+ pkcs11_available = True
25
+ except ImportError: # pragma: nocover
26
+ pkcs11 = None
27
+ pkcs11_available = False
28
+
29
+
30
+ P11_PIN_ENV_VAR = "PYHANKO_PKCS11_PIN"
31
+ UNAVAIL_MSG = "This subcommand requires python-pkcs11 to be installed."
32
+
33
+
34
+ class PKCS11Plugin(SigningCommandPlugin):
35
+ subcommand_name = 'pkcs11'
36
+ help_summary = 'use generic PKCS#11 device to sign'
37
+ unavailable_message = UNAVAIL_MSG
38
+
39
+ def is_available(self) -> bool:
40
+ return pkcs11_available
41
+
42
+ def click_options(self) -> List[click.Option]:
43
+ return [
44
+ click.Option(
45
+ ('--lib',),
46
+ help='path to PKCS#11 module',
47
+ type=readable_file,
48
+ required=False,
49
+ ),
50
+ click.Option(
51
+ ('--token-label',),
52
+ help='PKCS#11 token label',
53
+ type=str,
54
+ required=False,
55
+ ),
56
+ click.Option(
57
+ ('--cert-label',),
58
+ help='certificate label',
59
+ type=str,
60
+ required=False,
61
+ ),
62
+ click.Option(
63
+ ('--raw-mechanism',),
64
+ help='invoke raw PKCS#11 mechanism',
65
+ type=bool,
66
+ is_flag=True,
67
+ required=False,
68
+ ),
69
+ click.Option(
70
+ ('--key-label',), help='key label', type=str, required=False
71
+ ),
72
+ click.Option(
73
+ ('--slot-no',),
74
+ help='specify PKCS#11 slot to use',
75
+ required=False,
76
+ type=int,
77
+ default=None,
78
+ ),
79
+ click.Option(
80
+ ('--skip-user-pin',),
81
+ type=bool,
82
+ show_default=True,
83
+ default=False,
84
+ required=False,
85
+ is_flag=True,
86
+ help='do not prompt for PIN (e.g. if the token has a PIN pad)',
87
+ ),
88
+ click.Option(
89
+ ('--p11-setup',),
90
+ type=str,
91
+ required=False,
92
+ help='name of preconfigured PKCS#11 profile (overrides all '
93
+ 'other options)',
94
+ ),
95
+ click.Option(
96
+ ('--other-cert',),
97
+ type=str,
98
+ required=False,
99
+ help='label of other cert to pull (multiple uses allowed)',
100
+ multiple=True,
101
+ ),
102
+ ]
103
+
104
+ def create_signer(
105
+ self, context: CLIContext, **kwargs
106
+ ) -> ContextManager[Signer]:
107
+ return _pkcs11_signer_context(context, **kwargs)
108
+
109
+
110
+ def _pkcs11_signer_context(
111
+ ctx: CLIContext,
112
+ lib,
113
+ token_label,
114
+ cert_label,
115
+ key_label,
116
+ slot_no,
117
+ skip_user_pin,
118
+ p11_setup,
119
+ raw_mechanism,
120
+ other_cert,
121
+ ):
122
+ from pyhanko.sign import pkcs11
123
+
124
+ if p11_setup:
125
+ cli_config: Optional[CLIConfig] = ctx.config
126
+ if cli_config is None:
127
+ raise click.ClickException(
128
+ "The --p11-setup option requires a configuration file"
129
+ )
130
+ try:
131
+ pkcs11_config = ModuleConfigWrapper(cli_config).get_pkcs11_config(
132
+ p11_setup
133
+ )
134
+ except ConfigurationError as e:
135
+ msg = f"Error while reading PKCS#11 config {p11_setup}"
136
+ logger.error(msg, exc_info=e)
137
+ raise click.ClickException(msg)
138
+ else:
139
+ if not (lib and cert_label):
140
+ raise click.ClickException(
141
+ "The parameters --lib and --cert-label are required."
142
+ )
143
+
144
+ pinentry_mode = (
145
+ PKCS11PinEntryMode.SKIP
146
+ if skip_user_pin
147
+ else PKCS11PinEntryMode.PROMPT
148
+ )
149
+
150
+ pkcs11_config = PKCS11SignatureConfig(
151
+ module_path=lib,
152
+ cert_label=cert_label,
153
+ key_label=key_label,
154
+ slot_no=slot_no,
155
+ token_criteria=TokenCriteria(token_label),
156
+ # for now, DEFER requires a config file
157
+ prompt_pin=pinentry_mode,
158
+ raw_mechanism=raw_mechanism,
159
+ other_certs_to_pull=other_cert,
160
+ )
161
+
162
+ pin = pkcs11_config.user_pin
163
+
164
+ # try to fetch the PIN from an env var
165
+ if pin is None:
166
+ pin_env = os.environ.get(P11_PIN_ENV_VAR, None)
167
+ if pin_env:
168
+ pin = pin_env.strip()
169
+
170
+ if pkcs11_config.prompt_pin == PKCS11PinEntryMode.PROMPT and pin is None:
171
+ pin = getpass.getpass(prompt='PKCS#11 user PIN: ')
172
+ return pkcs11.PKCS11SigningContext(pkcs11_config, user_pin=pin)
173
+
174
+
175
+ class ModuleConfigWrapper:
176
+ def __init__(self, config: CLIConfig):
177
+ config_dict = config.raw_config
178
+ self.pkcs11_setups = config_dict.get('pkcs11-setups', {})
179
+
180
+ def get_pkcs11_config(self, name):
181
+ try:
182
+ setup = self.pkcs11_setups[name]
183
+ except KeyError:
184
+ raise ConfigurationError(f"There's no PKCS#11 setup named '{name}'")
185
+ return PKCS11SignatureConfig.from_config(setup)