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.
- pyhanko/__main__.py +9 -0
- pyhanko/cli/__init__.py +12 -0
- pyhanko/cli/_ctx.py +92 -0
- pyhanko/cli/_root.py +171 -0
- pyhanko/cli/_trust.py +159 -0
- pyhanko/cli/commands/__init__.py +0 -0
- pyhanko/cli/commands/crypt.py +219 -0
- pyhanko/cli/commands/fields.py +66 -0
- pyhanko/cli/commands/signing/__init__.py +328 -0
- pyhanko/cli/commands/signing/pkcs11_cli.py +185 -0
- pyhanko/cli/commands/signing/plugin.py +144 -0
- pyhanko/cli/commands/signing/simple.py +260 -0
- pyhanko/cli/commands/signing/utils.py +58 -0
- pyhanko/cli/commands/stamp.py +100 -0
- pyhanko/cli/commands/validation/__init__.py +2 -0
- pyhanko/cli/commands/validation/ltv.py +136 -0
- pyhanko/cli/commands/validation/validate.py +355 -0
- pyhanko/cli/config.py +306 -0
- pyhanko/cli/plugin_api.py +118 -0
- pyhanko/cli/py.typed +0 -0
- pyhanko/cli/runtime.py +80 -0
- pyhanko/cli/utils.py +68 -0
- pyhanko/cli/version.py +2 -0
- pyhanko_cli-0.1.0.dist-info/METADATA +68 -0
- pyhanko_cli-0.1.0.dist-info/RECORD +29 -0
- pyhanko_cli-0.1.0.dist-info/WHEEL +5 -0
- pyhanko_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pyhanko_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyhanko_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import tzlocal
|
|
7
|
+
from asn1crypto import pem
|
|
8
|
+
from pyhanko.cli._ctx import CLIContext
|
|
9
|
+
from pyhanko.cli.commands.signing.utils import get_text_params, open_for_signing
|
|
10
|
+
from pyhanko.cli.plugin_api import SigningCommandPlugin
|
|
11
|
+
from pyhanko.cli.runtime import pyhanko_exception_manager
|
|
12
|
+
from pyhanko.cli.utils import readable_file, writable_file
|
|
13
|
+
from pyhanko.pdf_utils.rw_common import PdfHandler
|
|
14
|
+
from pyhanko.sign import PdfSigner, fields
|
|
15
|
+
from pyhanko.sign.signers.pdf_cms import (
|
|
16
|
+
PdfCMSSignedAttributes,
|
|
17
|
+
select_suitable_signing_md,
|
|
18
|
+
)
|
|
19
|
+
from pyhanko.sign.timestamps import HTTPTimeStamper
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ensure_field_visible(
|
|
23
|
+
handler: PdfHandler, name: Optional[str], will_create: bool
|
|
24
|
+
):
|
|
25
|
+
# verify if the resulting signature will be a visible one
|
|
26
|
+
prefix = "You seem to be trying to create a visible signature"
|
|
27
|
+
try:
|
|
28
|
+
fq_name, _, field_ref = next(
|
|
29
|
+
fields.enumerate_sig_fields(handler, with_name=name)
|
|
30
|
+
)
|
|
31
|
+
sig_annot = fields.get_sig_field_annot(sig_field=field_ref.get_object())
|
|
32
|
+
w, h = fields.annot_width_height(sig_annot)
|
|
33
|
+
if not w or not h:
|
|
34
|
+
raise click.ClickException(
|
|
35
|
+
f"{prefix}, but the field '{fq_name}' in the PDF is not a "
|
|
36
|
+
f"visible one. Please specify another field name if you "
|
|
37
|
+
f"need a visible signature."
|
|
38
|
+
)
|
|
39
|
+
except StopIteration:
|
|
40
|
+
if not name:
|
|
41
|
+
raise click.ClickException(
|
|
42
|
+
f"{prefix}, but the PDF did not contain any signature fields, "
|
|
43
|
+
f"and you did not specify a bounding box. "
|
|
44
|
+
f"Please specify the field as "
|
|
45
|
+
f"--field \"PAGE/X1,Y1,X2,Y2/NAME\" to "
|
|
46
|
+
f"create a visible signature field at the coordinates provided."
|
|
47
|
+
)
|
|
48
|
+
elif not will_create:
|
|
49
|
+
raise click.ClickException(
|
|
50
|
+
f"{prefix}, but the field '{name}' does not exist in the PDF "
|
|
51
|
+
f"file, and you did not specify a bounding box. "
|
|
52
|
+
f"Please specify the field as "
|
|
53
|
+
f"--field \"PAGE/X1,Y1,X2,Y2/{name}\" to "
|
|
54
|
+
f"create a visible signature field at the coordinates provided."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _callback_logic(
|
|
59
|
+
plugin: SigningCommandPlugin, infile: str, outfile: str, **kwargs
|
|
60
|
+
):
|
|
61
|
+
ctx = click.get_current_context()
|
|
62
|
+
cli_ctx: CLIContext = ctx.obj
|
|
63
|
+
|
|
64
|
+
timestamp_url: Optional[str] = cli_ctx.timestamp_url
|
|
65
|
+
if timestamp_url is not None:
|
|
66
|
+
timestamper = HTTPTimeStamper(timestamp_url)
|
|
67
|
+
else:
|
|
68
|
+
timestamper = None
|
|
69
|
+
with plugin.create_signer(cli_ctx, **kwargs) as signer:
|
|
70
|
+
pdf_sig_settings = cli_ctx.sig_settings
|
|
71
|
+
|
|
72
|
+
if pdf_sig_settings is None:
|
|
73
|
+
# detached sig
|
|
74
|
+
if timestamper is not None:
|
|
75
|
+
# signed TS
|
|
76
|
+
timestamp_attr = None
|
|
77
|
+
else:
|
|
78
|
+
# in this case, embed the signing time as a signed attr
|
|
79
|
+
timestamp_attr = datetime.now(tz=tzlocal.get_localzone())
|
|
80
|
+
|
|
81
|
+
with open(infile, 'rb') as inf:
|
|
82
|
+
cert = signer.signing_cert
|
|
83
|
+
assert cert is not None
|
|
84
|
+
signature_job = signer.async_sign_general_data(
|
|
85
|
+
inf,
|
|
86
|
+
select_suitable_signing_md(cert.public_key),
|
|
87
|
+
timestamper=timestamper,
|
|
88
|
+
signed_attr_settings=PdfCMSSignedAttributes(
|
|
89
|
+
signing_time=timestamp_attr
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
signature = asyncio.run(signature_job)
|
|
93
|
+
|
|
94
|
+
output_bytes = signature.dump()
|
|
95
|
+
if cli_ctx.detach_pem:
|
|
96
|
+
output_bytes = pem.armor('PKCS7', output_bytes)
|
|
97
|
+
|
|
98
|
+
with open(outfile, 'wb') as out:
|
|
99
|
+
out.write(output_bytes)
|
|
100
|
+
else:
|
|
101
|
+
with open_for_signing(
|
|
102
|
+
infile_path=infile, lenient=cli_ctx.lenient
|
|
103
|
+
) as w:
|
|
104
|
+
if cli_ctx.ux.visible_signature_desired:
|
|
105
|
+
_ensure_field_visible(
|
|
106
|
+
w,
|
|
107
|
+
pdf_sig_settings.field_name,
|
|
108
|
+
cli_ctx.new_field_spec is not None,
|
|
109
|
+
)
|
|
110
|
+
result = PdfSigner(
|
|
111
|
+
pdf_sig_settings,
|
|
112
|
+
signer=signer,
|
|
113
|
+
timestamper=timestamper,
|
|
114
|
+
stamp_style=cli_ctx.stamp_style,
|
|
115
|
+
new_field_spec=cli_ctx.new_field_spec,
|
|
116
|
+
).sign_pdf(
|
|
117
|
+
w,
|
|
118
|
+
existing_fields_only=cli_ctx.existing_fields_only,
|
|
119
|
+
appearance_text_params=get_text_params(ctx),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
with open(outfile, 'wb') as outf:
|
|
123
|
+
buf = result.getbuffer()
|
|
124
|
+
outf.write(buf)
|
|
125
|
+
buf.release()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def command_from_plugin(plugin: SigningCommandPlugin) -> click.Command:
|
|
129
|
+
def _callback(*, infile: str, outfile: str, **kwargs):
|
|
130
|
+
with pyhanko_exception_manager():
|
|
131
|
+
_callback_logic(plugin, infile, outfile, **kwargs)
|
|
132
|
+
|
|
133
|
+
params: List[click.Parameter] = [
|
|
134
|
+
click.Argument(('infile',), type=readable_file),
|
|
135
|
+
click.Argument(('outfile',), type=writable_file),
|
|
136
|
+
]
|
|
137
|
+
params.extend(plugin.click_extra_arguments())
|
|
138
|
+
params.extend(plugin.click_options())
|
|
139
|
+
return click.Command(
|
|
140
|
+
name=plugin.subcommand_name,
|
|
141
|
+
callback=_callback,
|
|
142
|
+
help=plugin.help_summary,
|
|
143
|
+
params=params,
|
|
144
|
+
)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import getpass
|
|
3
|
+
from typing import ContextManager, List, Optional
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from pyhanko.cli._ctx import CLIContext
|
|
7
|
+
from pyhanko.cli._trust import grab_certs
|
|
8
|
+
from pyhanko.cli.config import CLIConfig
|
|
9
|
+
from pyhanko.cli.plugin_api import SigningCommandPlugin, register_signing_plugin
|
|
10
|
+
from pyhanko.cli.utils import _warn_empty_passphrase, logger, readable_file
|
|
11
|
+
from pyhanko.config.errors import ConfigurationError
|
|
12
|
+
from pyhanko.config.local_keys import (
|
|
13
|
+
PemDerSignatureConfig,
|
|
14
|
+
PKCS12SignatureConfig,
|
|
15
|
+
)
|
|
16
|
+
from pyhanko.sign.signers.pdf_cms import (
|
|
17
|
+
Signer,
|
|
18
|
+
signer_from_p12_config,
|
|
19
|
+
signer_from_pemder_config,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = ['PemderPlugin', 'PKCS12Plugin']
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class KeyFileConfigWrapper:
|
|
26
|
+
def __init__(self, config: CLIConfig):
|
|
27
|
+
config_dict = config.raw_config
|
|
28
|
+
self.pemder_setups = config_dict.get('pemder-setups', {})
|
|
29
|
+
self.pkcs12_setups = config_dict.get('pkcs12-setups', {})
|
|
30
|
+
|
|
31
|
+
def get_pkcs12_config(self, name):
|
|
32
|
+
try:
|
|
33
|
+
setup = self.pkcs12_setups[name]
|
|
34
|
+
except KeyError:
|
|
35
|
+
raise ConfigurationError(f"There's no PKCS#12 setup named '{name}'")
|
|
36
|
+
return PKCS12SignatureConfig.from_config(setup)
|
|
37
|
+
|
|
38
|
+
def get_pemder_config(self, name):
|
|
39
|
+
try:
|
|
40
|
+
setup = self.pemder_setups[name]
|
|
41
|
+
except KeyError:
|
|
42
|
+
raise ConfigurationError(f"There's no PEM/DER setup named '{name}'")
|
|
43
|
+
return PemDerSignatureConfig.from_config(setup)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PemderPlugin(SigningCommandPlugin):
|
|
47
|
+
subcommand_name = 'pemder'
|
|
48
|
+
help_summary = 'read key material from PEM/DER files'
|
|
49
|
+
|
|
50
|
+
def click_options(self) -> List[click.Option]:
|
|
51
|
+
return [
|
|
52
|
+
click.Option(
|
|
53
|
+
('--key',),
|
|
54
|
+
help='file containing the private key (PEM/DER)',
|
|
55
|
+
type=readable_file,
|
|
56
|
+
required=False,
|
|
57
|
+
),
|
|
58
|
+
click.Option(
|
|
59
|
+
('--cert',),
|
|
60
|
+
help='file containing the signer\'s certificate ' '(PEM/DER)',
|
|
61
|
+
type=readable_file,
|
|
62
|
+
required=False,
|
|
63
|
+
),
|
|
64
|
+
click.Option(
|
|
65
|
+
('--chain',),
|
|
66
|
+
type=readable_file,
|
|
67
|
+
multiple=True,
|
|
68
|
+
help='file(s) containing the chain of trust for the '
|
|
69
|
+
'signer\'s certificate (PEM/DER). May be '
|
|
70
|
+
'passed multiple times.',
|
|
71
|
+
),
|
|
72
|
+
click.Option(
|
|
73
|
+
('--pemder-setup',),
|
|
74
|
+
type=str,
|
|
75
|
+
required=False,
|
|
76
|
+
help='name of preconfigured PEM/DER profile (overrides all '
|
|
77
|
+
'other options)',
|
|
78
|
+
),
|
|
79
|
+
# TODO allow reading the passphrase from a specific file descriptor
|
|
80
|
+
# (for advanced scripting setups)
|
|
81
|
+
click.Option(
|
|
82
|
+
('--passfile',),
|
|
83
|
+
help='file containing the passphrase ' 'for the private key',
|
|
84
|
+
required=False,
|
|
85
|
+
type=click.File('r'),
|
|
86
|
+
show_default='stdin',
|
|
87
|
+
),
|
|
88
|
+
click.Option(
|
|
89
|
+
('--no-pass',),
|
|
90
|
+
help='assume the private key file is unencrypted',
|
|
91
|
+
type=bool,
|
|
92
|
+
is_flag=True,
|
|
93
|
+
default=False,
|
|
94
|
+
show_default=True,
|
|
95
|
+
),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
def create_signer(
|
|
99
|
+
self, context: CLIContext, **kwargs
|
|
100
|
+
) -> ContextManager[Signer]:
|
|
101
|
+
@contextlib.contextmanager
|
|
102
|
+
def _m():
|
|
103
|
+
yield _pemder_signer(context, **kwargs)
|
|
104
|
+
|
|
105
|
+
return _m()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _pemder_signer(
|
|
109
|
+
ctx: CLIContext,
|
|
110
|
+
key,
|
|
111
|
+
cert,
|
|
112
|
+
chain,
|
|
113
|
+
pemder_setup,
|
|
114
|
+
passfile,
|
|
115
|
+
no_pass,
|
|
116
|
+
):
|
|
117
|
+
if pemder_setup:
|
|
118
|
+
cli_config = ctx.config
|
|
119
|
+
if cli_config is None:
|
|
120
|
+
raise click.ClickException(
|
|
121
|
+
"The --pemder-setup option requires a configuration file"
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
pemder_config = KeyFileConfigWrapper(cli_config).get_pemder_config(
|
|
125
|
+
pemder_setup
|
|
126
|
+
)
|
|
127
|
+
except ConfigurationError as e:
|
|
128
|
+
msg = f"Error while reading PEM/DER setup {pemder_setup}"
|
|
129
|
+
logger.error(msg, exc_info=e)
|
|
130
|
+
raise click.ClickException(msg)
|
|
131
|
+
elif not (key and cert):
|
|
132
|
+
raise click.ClickException(
|
|
133
|
+
"Either both the --key and --cert options, or the --pemder-setup "
|
|
134
|
+
"option must be provided."
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
pemder_config = PemDerSignatureConfig(
|
|
138
|
+
key_file=key,
|
|
139
|
+
cert_file=cert,
|
|
140
|
+
other_certs=grab_certs(chain),
|
|
141
|
+
prefer_pss=ctx.prefer_pss,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if pemder_config.key_passphrase is not None:
|
|
145
|
+
passphrase = pemder_config.key_passphrase
|
|
146
|
+
elif passfile is not None:
|
|
147
|
+
passphrase = passfile.readline().strip().encode('utf-8')
|
|
148
|
+
passfile.close()
|
|
149
|
+
elif pemder_config.prompt_passphrase and not no_pass:
|
|
150
|
+
passphrase = getpass.getpass(prompt='Key passphrase: ').encode('utf-8')
|
|
151
|
+
if not passphrase:
|
|
152
|
+
_warn_empty_passphrase()
|
|
153
|
+
passphrase = None
|
|
154
|
+
else:
|
|
155
|
+
passphrase = None
|
|
156
|
+
|
|
157
|
+
return signer_from_pemder_config(
|
|
158
|
+
pemder_config, provided_key_passphrase=passphrase
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class PKCS12Plugin(SigningCommandPlugin):
|
|
163
|
+
subcommand_name = 'pkcs12'
|
|
164
|
+
help_summary = 'read key material from PKCS#12 files'
|
|
165
|
+
|
|
166
|
+
def click_extra_arguments(self) -> List[click.Argument]:
|
|
167
|
+
return [click.Argument(('pfx',), type=readable_file, required=False)]
|
|
168
|
+
|
|
169
|
+
def click_options(self) -> List[click.Option]:
|
|
170
|
+
return [
|
|
171
|
+
click.Option(
|
|
172
|
+
('--p12-setup',),
|
|
173
|
+
type=str,
|
|
174
|
+
required=False,
|
|
175
|
+
help='name of preconfigured PKCS#12 profile (overrides all '
|
|
176
|
+
'other options)',
|
|
177
|
+
),
|
|
178
|
+
click.Option(
|
|
179
|
+
('--chain',),
|
|
180
|
+
type=readable_file,
|
|
181
|
+
multiple=True,
|
|
182
|
+
help='PEM/DER file(s) containing extra certificates to embed '
|
|
183
|
+
'(e.g. chain of trust not embedded in the PKCS#12 file)'
|
|
184
|
+
'May be passed multiple times.',
|
|
185
|
+
),
|
|
186
|
+
click.Option(
|
|
187
|
+
('--passfile',),
|
|
188
|
+
help='file containing the passphrase for the PKCS#12 file.',
|
|
189
|
+
required=False,
|
|
190
|
+
type=click.File('r'),
|
|
191
|
+
show_default='stdin',
|
|
192
|
+
),
|
|
193
|
+
click.Option(
|
|
194
|
+
('--no-pass',),
|
|
195
|
+
help='assume the PKCS#12 file is unencrypted',
|
|
196
|
+
type=bool,
|
|
197
|
+
is_flag=True,
|
|
198
|
+
default=False,
|
|
199
|
+
show_default=True,
|
|
200
|
+
),
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
def create_signer(
|
|
204
|
+
self, context: CLIContext, **kwargs
|
|
205
|
+
) -> ContextManager[Signer]:
|
|
206
|
+
@contextlib.contextmanager
|
|
207
|
+
def _m():
|
|
208
|
+
yield _pkcs12_signer(context, **kwargs)
|
|
209
|
+
|
|
210
|
+
return _m()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _pkcs12_signer(ctx: CLIContext, pfx, chain, passfile, p12_setup, no_pass):
|
|
214
|
+
# TODO add sanity check in case the user gets the arg order wrong
|
|
215
|
+
# (now it fails with a gnarly DER decoding error, which is not very
|
|
216
|
+
# user-friendly)
|
|
217
|
+
if p12_setup:
|
|
218
|
+
cli_config: Optional[CLIConfig] = ctx.config
|
|
219
|
+
if cli_config is None:
|
|
220
|
+
raise click.ClickException(
|
|
221
|
+
"The --p12-setup option requires a configuration file"
|
|
222
|
+
)
|
|
223
|
+
try:
|
|
224
|
+
pkcs12_config = KeyFileConfigWrapper(cli_config).get_pkcs12_config(
|
|
225
|
+
p12_setup
|
|
226
|
+
)
|
|
227
|
+
except ConfigurationError as e:
|
|
228
|
+
msg = f"Error while reading PKCS#12 config {p12_setup}"
|
|
229
|
+
logger.error(msg, exc_info=e)
|
|
230
|
+
raise click.ClickException(msg)
|
|
231
|
+
elif not pfx:
|
|
232
|
+
raise click.ClickException(
|
|
233
|
+
"Either the PFX argument or the --p12-setup option "
|
|
234
|
+
"must be provided."
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
pkcs12_config = PKCS12SignatureConfig(
|
|
238
|
+
pfx_file=pfx,
|
|
239
|
+
other_certs=grab_certs(chain),
|
|
240
|
+
prefer_pss=ctx.prefer_pss,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if pkcs12_config.pfx_passphrase is not None:
|
|
244
|
+
passphrase = pkcs12_config.pfx_passphrase
|
|
245
|
+
elif passfile is not None:
|
|
246
|
+
passphrase = passfile.readline().strip().encode('utf-8')
|
|
247
|
+
passfile.close()
|
|
248
|
+
elif pkcs12_config.prompt_passphrase and not no_pass:
|
|
249
|
+
passphrase = getpass.getpass(prompt='PKCS#12 passphrase: ').encode(
|
|
250
|
+
'utf-8'
|
|
251
|
+
)
|
|
252
|
+
if not passphrase:
|
|
253
|
+
_warn_empty_passphrase()
|
|
254
|
+
passphrase = None
|
|
255
|
+
else:
|
|
256
|
+
passphrase = None
|
|
257
|
+
|
|
258
|
+
return signer_from_p12_config(
|
|
259
|
+
pkcs12_config, provided_pfx_passphrase=passphrase
|
|
260
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
from typing import BinaryIO, Optional
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from pyhanko.pdf_utils import crypt
|
|
6
|
+
from pyhanko.pdf_utils.crypt import AuthStatus
|
|
7
|
+
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OpenForSigning:
|
|
11
|
+
def __init__(self, infile_path: str, lenient: bool):
|
|
12
|
+
self.infile_path = infile_path
|
|
13
|
+
self.lenient = lenient
|
|
14
|
+
self.handle: Optional[BinaryIO] = None
|
|
15
|
+
|
|
16
|
+
def __enter__(self) -> IncrementalPdfFileWriter:
|
|
17
|
+
self.handle = infile = open(self.infile_path, 'rb')
|
|
18
|
+
writer = IncrementalPdfFileWriter(infile, strict=not self.lenient)
|
|
19
|
+
|
|
20
|
+
# TODO make this an option higher up the tree
|
|
21
|
+
if writer.prev.encrypted:
|
|
22
|
+
sh = writer.prev.security_handler
|
|
23
|
+
if isinstance(sh, crypt.StandardSecurityHandler):
|
|
24
|
+
pdf_pass = getpass.getpass(
|
|
25
|
+
prompt='Password for encrypted file \'%s\': '
|
|
26
|
+
% self.infile_path
|
|
27
|
+
)
|
|
28
|
+
auth = writer.encrypt(pdf_pass)
|
|
29
|
+
if auth.status == AuthStatus.FAILED:
|
|
30
|
+
raise click.ClickException(
|
|
31
|
+
"Invalid password for encrypted file"
|
|
32
|
+
)
|
|
33
|
+
elif isinstance(sh, crypt.PubKeySecurityHandler):
|
|
34
|
+
raise click.ClickException(
|
|
35
|
+
"Public-key document encryption is not supported in the CLI"
|
|
36
|
+
)
|
|
37
|
+
else: # pragma: nocover
|
|
38
|
+
raise click.ClickException(
|
|
39
|
+
"Input file appears to be encrypted, but appropriate "
|
|
40
|
+
"credentials are not available."
|
|
41
|
+
)
|
|
42
|
+
return writer
|
|
43
|
+
|
|
44
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
45
|
+
if self.handle:
|
|
46
|
+
self.handle.close()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def open_for_signing(infile_path: str, lenient: bool):
|
|
50
|
+
return OpenForSigning(infile_path, lenient)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_text_params(ctx):
|
|
54
|
+
text_params = None
|
|
55
|
+
stamp_url = ctx.obj.stamp_url
|
|
56
|
+
if stamp_url is not None:
|
|
57
|
+
text_params = {'url': stamp_url}
|
|
58
|
+
return text_params
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from pyhanko.cli._root import cli_root
|
|
5
|
+
from pyhanko.cli.config import CLIConfig
|
|
6
|
+
from pyhanko.cli.runtime import DEFAULT_CONFIG_FILE, pyhanko_exception_manager
|
|
7
|
+
from pyhanko.cli.utils import _index_page, logger, readable_file
|
|
8
|
+
from pyhanko.config.errors import ConfigurationError
|
|
9
|
+
from pyhanko.stamp import QRStampStyle, qr_stamp_file, text_stamp_file
|
|
10
|
+
|
|
11
|
+
__all__ = ['stamp', 'select_style']
|
|
12
|
+
|
|
13
|
+
_CONFIG_REQUIRED_MSG = (
|
|
14
|
+
"Using stamp styles requires a configuration file "
|
|
15
|
+
f"({DEFAULT_CONFIG_FILE} by default)."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def select_style(ctx: click.Context, style_name: str, url: str):
|
|
20
|
+
cli_config: Optional[CLIConfig] = ctx.obj.config
|
|
21
|
+
if not cli_config:
|
|
22
|
+
if not style_name:
|
|
23
|
+
return None
|
|
24
|
+
raise click.ClickException(_CONFIG_REQUIRED_MSG)
|
|
25
|
+
try:
|
|
26
|
+
style = cli_config.get_stamp_style(style_name)
|
|
27
|
+
except ConfigurationError as e:
|
|
28
|
+
logger.error(e.msg, exc_info=e)
|
|
29
|
+
raise click.ClickException(e.msg)
|
|
30
|
+
if url and not isinstance(style, QRStampStyle):
|
|
31
|
+
raise click.ClickException(
|
|
32
|
+
"The --stamp-url parameter is only meaningful for QR stamp styles."
|
|
33
|
+
)
|
|
34
|
+
elif not url and isinstance(style, QRStampStyle):
|
|
35
|
+
raise click.ClickException(
|
|
36
|
+
"QR stamp styles require the --stamp-url option."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return style
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# TODO: text_params support
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@cli_root.command(help='stamp PDF files', name='stamp')
|
|
46
|
+
@click.argument('infile', type=readable_file)
|
|
47
|
+
@click.argument('outfile', type=click.Path(writable=True, dir_okay=False))
|
|
48
|
+
@click.argument('x', type=int)
|
|
49
|
+
@click.argument('y', type=int)
|
|
50
|
+
@click.option(
|
|
51
|
+
'--style-name',
|
|
52
|
+
help='stamp style name for stamp appearance',
|
|
53
|
+
required=False,
|
|
54
|
+
type=str,
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
'--page',
|
|
58
|
+
help='page on which the stamp should be applied',
|
|
59
|
+
required=False,
|
|
60
|
+
type=int,
|
|
61
|
+
default=1,
|
|
62
|
+
show_default=True,
|
|
63
|
+
)
|
|
64
|
+
@click.option(
|
|
65
|
+
'--stamp-url',
|
|
66
|
+
help='QR code URL to use in QR stamp style',
|
|
67
|
+
required=False,
|
|
68
|
+
type=str,
|
|
69
|
+
)
|
|
70
|
+
@click.pass_context
|
|
71
|
+
def stamp(
|
|
72
|
+
ctx,
|
|
73
|
+
infile: str,
|
|
74
|
+
outfile: str,
|
|
75
|
+
x: int,
|
|
76
|
+
y: int,
|
|
77
|
+
style_name: str,
|
|
78
|
+
page: int,
|
|
79
|
+
stamp_url: str,
|
|
80
|
+
):
|
|
81
|
+
cli_config: Optional[CLIConfig] = ctx.obj.config
|
|
82
|
+
if cli_config is None:
|
|
83
|
+
raise click.ClickException(_CONFIG_REQUIRED_MSG)
|
|
84
|
+
with pyhanko_exception_manager():
|
|
85
|
+
stamp_style = select_style(ctx, style_name, stamp_url)
|
|
86
|
+
page_ix = _index_page(page)
|
|
87
|
+
if stamp_url:
|
|
88
|
+
qr_stamp_file(
|
|
89
|
+
infile,
|
|
90
|
+
outfile,
|
|
91
|
+
stamp_style,
|
|
92
|
+
dest_page=page_ix,
|
|
93
|
+
x=x,
|
|
94
|
+
y=y,
|
|
95
|
+
url=stamp_url,
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
text_stamp_file(
|
|
99
|
+
infile, outfile, stamp_style, dest_page=page_ix, x=x, y=y
|
|
100
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from pyhanko.cli._trust import build_vc_kwargs, trust_options
|
|
3
|
+
from pyhanko.cli.commands.signing import signing
|
|
4
|
+
from pyhanko.cli.runtime import pyhanko_exception_manager
|
|
5
|
+
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
|
|
6
|
+
from pyhanko.pdf_utils.reader import PdfFileReader
|
|
7
|
+
from pyhanko.sign import signers, validation
|
|
8
|
+
from pyhanko.sign.timestamps import HTTPTimeStamper
|
|
9
|
+
|
|
10
|
+
from pyhanko_certvalidator import ValidationContext
|
|
11
|
+
|
|
12
|
+
__all__ = ['ltv_fix', 'lta_update']
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@trust_options
|
|
16
|
+
@signing.command(name='ltaupdate', help='update LTA timestamp')
|
|
17
|
+
@click.argument('infile', type=click.File('r+b'))
|
|
18
|
+
@click.option(
|
|
19
|
+
'--timestamp-url',
|
|
20
|
+
help='URL for timestamp server',
|
|
21
|
+
required=True,
|
|
22
|
+
type=str,
|
|
23
|
+
default=None,
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
'--retroactive-revinfo',
|
|
27
|
+
help='Treat revocation info as retroactively valid '
|
|
28
|
+
'(i.e. ignore thisUpdate timestamp)',
|
|
29
|
+
type=bool,
|
|
30
|
+
is_flag=True,
|
|
31
|
+
default=False,
|
|
32
|
+
show_default=True,
|
|
33
|
+
)
|
|
34
|
+
@click.pass_context
|
|
35
|
+
def lta_update(
|
|
36
|
+
ctx,
|
|
37
|
+
infile,
|
|
38
|
+
validation_context,
|
|
39
|
+
trust,
|
|
40
|
+
trust_replace,
|
|
41
|
+
other_certs,
|
|
42
|
+
timestamp_url,
|
|
43
|
+
retroactive_revinfo,
|
|
44
|
+
):
|
|
45
|
+
with pyhanko_exception_manager():
|
|
46
|
+
vc_kwargs = build_vc_kwargs(
|
|
47
|
+
ctx.obj.config,
|
|
48
|
+
validation_context,
|
|
49
|
+
trust,
|
|
50
|
+
trust_replace,
|
|
51
|
+
other_certs,
|
|
52
|
+
retroactive_revinfo,
|
|
53
|
+
)
|
|
54
|
+
timestamper = HTTPTimeStamper(timestamp_url)
|
|
55
|
+
r = PdfFileReader(infile)
|
|
56
|
+
signers.PdfTimeStamper(timestamper).update_archival_timestamp_chain(
|
|
57
|
+
r, ValidationContext(**vc_kwargs)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# TODO perhaps add an option here to fix the lack of a timestamp and/or
|
|
62
|
+
# warn if none is present
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@trust_options
|
|
66
|
+
@signing.command(
|
|
67
|
+
name='ltvfix', help='add revocation information for a signature to the DSS'
|
|
68
|
+
)
|
|
69
|
+
@click.argument('infile', type=click.File('r+b'))
|
|
70
|
+
@click.option('--field', help='name of the signature field', required=True)
|
|
71
|
+
@click.option(
|
|
72
|
+
'--timestamp-url',
|
|
73
|
+
help='URL for timestamp server',
|
|
74
|
+
required=False,
|
|
75
|
+
type=str,
|
|
76
|
+
default=None,
|
|
77
|
+
)
|
|
78
|
+
@click.option(
|
|
79
|
+
'--apply-lta-timestamp',
|
|
80
|
+
help='Apply a document timestamp after adding revocation info.',
|
|
81
|
+
required=False,
|
|
82
|
+
type=bool,
|
|
83
|
+
default=False,
|
|
84
|
+
is_flag=True,
|
|
85
|
+
show_default=True,
|
|
86
|
+
)
|
|
87
|
+
@click.pass_context
|
|
88
|
+
def ltv_fix(
|
|
89
|
+
ctx,
|
|
90
|
+
infile,
|
|
91
|
+
field,
|
|
92
|
+
timestamp_url,
|
|
93
|
+
apply_lta_timestamp,
|
|
94
|
+
validation_context,
|
|
95
|
+
trust_replace,
|
|
96
|
+
trust,
|
|
97
|
+
other_certs,
|
|
98
|
+
):
|
|
99
|
+
if apply_lta_timestamp and not timestamp_url:
|
|
100
|
+
raise click.ClickException(
|
|
101
|
+
"Please specify a timestamp server using --timestamp-url."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
vc_kwargs = build_vc_kwargs(
|
|
105
|
+
ctx.obj.config,
|
|
106
|
+
validation_context,
|
|
107
|
+
trust,
|
|
108
|
+
trust_replace,
|
|
109
|
+
other_certs,
|
|
110
|
+
retroactive_revinfo=False,
|
|
111
|
+
allow_fetching=True,
|
|
112
|
+
)
|
|
113
|
+
vc_kwargs['revocation_mode'] = 'hard-fail'
|
|
114
|
+
r = PdfFileReader(infile)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
emb_sig = next(
|
|
118
|
+
s for s in r.embedded_regular_signatures if s.field_name == field
|
|
119
|
+
)
|
|
120
|
+
except StopIteration:
|
|
121
|
+
raise click.ClickException(
|
|
122
|
+
f"Could not find a PDF signature labelled {field}."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
output = validation.add_validation_info(
|
|
126
|
+
emb_sig, ValidationContext(**vc_kwargs), in_place=True
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if apply_lta_timestamp:
|
|
130
|
+
timestamper = HTTPTimeStamper(timestamp_url)
|
|
131
|
+
signers.PdfTimeStamper(timestamper).timestamp_pdf(
|
|
132
|
+
IncrementalPdfFileWriter(output),
|
|
133
|
+
signers.DEFAULT_MD,
|
|
134
|
+
ValidationContext(**vc_kwargs),
|
|
135
|
+
in_place=True,
|
|
136
|
+
)
|