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
pyhanko/__main__.py
ADDED
pyhanko/cli/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pyhanko.cli._root import cli_root
|
|
2
|
+
from pyhanko.cli.commands.crypt import *
|
|
3
|
+
from pyhanko.cli.commands.fields import *
|
|
4
|
+
from pyhanko.cli.commands.signing import *
|
|
5
|
+
from pyhanko.cli.commands.stamp import *
|
|
6
|
+
from pyhanko.cli.commands.validation import *
|
|
7
|
+
|
|
8
|
+
__all__ = ['launch', 'cli_root']
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def launch():
|
|
12
|
+
cli_root(prog_name='pyhanko')
|
pyhanko/cli/_ctx.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from pyhanko.cli.config import CLIConfig
|
|
5
|
+
from pyhanko.sign import PdfSignatureMetadata
|
|
6
|
+
from pyhanko.sign.fields import SigFieldSpec
|
|
7
|
+
from pyhanko.stamp import BaseStampStyle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class UXContext:
|
|
12
|
+
"""
|
|
13
|
+
Context object to track information that affects the UX, e.g. user intent
|
|
14
|
+
as inferred from certain argument combinations that are otherwise difficult
|
|
15
|
+
to wire throughout the UI code.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
visible_signature_desired: bool = False
|
|
19
|
+
"""
|
|
20
|
+
Set to `True` if the user explicitly specifies `--field` with a bounding box
|
|
21
|
+
or passes `--style-name` explicitly.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CLIContext:
|
|
27
|
+
"""
|
|
28
|
+
Context object that cobbles together various CLI settings values that were
|
|
29
|
+
gathered by various subcommands during the lifetime of a CLI invocation,
|
|
30
|
+
either from configuration or from command line arguments.
|
|
31
|
+
This object is passed around as a ``click`` context object.
|
|
32
|
+
|
|
33
|
+
Not all settings are applicable to all subcommands, so all values are
|
|
34
|
+
optional.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
sig_settings: Optional[PdfSignatureMetadata] = None
|
|
38
|
+
"""
|
|
39
|
+
The settings that will be used to produce a new signature.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
config: Optional[CLIConfig] = None
|
|
43
|
+
"""
|
|
44
|
+
Values for CLI configuration settings.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
existing_fields_only: bool = False
|
|
48
|
+
"""
|
|
49
|
+
Whether signing operations should use existing fields only.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
timestamp_url: Optional[str] = None
|
|
53
|
+
"""
|
|
54
|
+
Endpoint URL for the timestamping service to use.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
stamp_style: Optional[BaseStampStyle] = None
|
|
58
|
+
"""
|
|
59
|
+
Stamp style to use for generating visual signature appearances, if
|
|
60
|
+
applicable.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
stamp_url: Optional[str] = None
|
|
64
|
+
"""
|
|
65
|
+
For QR stamp styles, defines the URL used to generate the QR code.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
new_field_spec: Optional[SigFieldSpec] = None
|
|
69
|
+
"""
|
|
70
|
+
Field spec used to generate new signature fields, if applicable.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
prefer_pss: bool = False
|
|
74
|
+
"""
|
|
75
|
+
When working with RSA keys, prefer RSASSA-PSS signing if available.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
detach_pem: bool = False
|
|
79
|
+
"""
|
|
80
|
+
When producing detached signature payloads (i.e. non-PDF CMS), save the
|
|
81
|
+
result in a PEM file instead of in a DER file.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
lenient: bool = False
|
|
85
|
+
"""
|
|
86
|
+
Process PDF files in nonstrict mode.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
ux: UXContext = field(default_factory=UXContext)
|
|
90
|
+
"""
|
|
91
|
+
UX information.
|
|
92
|
+
"""
|
pyhanko/cli/_root.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Iterable, Optional
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from pyhanko.cli._ctx import CLIContext
|
|
7
|
+
from pyhanko.cli.config import CLIRootConfig, parse_cli_config
|
|
8
|
+
from pyhanko.cli.plugin_api import (
|
|
9
|
+
SIGNING_PLUGIN_ENTRY_POINT_GROUP,
|
|
10
|
+
SIGNING_PLUGIN_REGISTRY,
|
|
11
|
+
)
|
|
12
|
+
from pyhanko.cli.runtime import DEFAULT_CONFIG_FILE, logging_setup
|
|
13
|
+
from pyhanko.cli.version import __version__ as cli_version
|
|
14
|
+
from pyhanko.config.logging import LogConfig, parse_logging_config
|
|
15
|
+
from pyhanko.version import __version__ as lib_version
|
|
16
|
+
|
|
17
|
+
__all__ = ['cli_root']
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
full_version = f"{lib_version} (CLI {cli_version})"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group()
|
|
24
|
+
@click.version_option(prog_name='pyHanko', version=full_version)
|
|
25
|
+
@click.option(
|
|
26
|
+
'--config',
|
|
27
|
+
help=(
|
|
28
|
+
'YAML file to load configuration from'
|
|
29
|
+
f'[default: {DEFAULT_CONFIG_FILE}]'
|
|
30
|
+
),
|
|
31
|
+
required=False,
|
|
32
|
+
type=click.File('r'),
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
'--verbose',
|
|
36
|
+
help='Run in verbose mode',
|
|
37
|
+
required=False,
|
|
38
|
+
default=False,
|
|
39
|
+
type=bool,
|
|
40
|
+
is_flag=True,
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
'--no-plugins',
|
|
44
|
+
help='Disable non-builtin plugin loading',
|
|
45
|
+
type=bool,
|
|
46
|
+
is_flag=True,
|
|
47
|
+
)
|
|
48
|
+
@click.pass_context
|
|
49
|
+
def _root(ctx: click.Context, config, verbose, no_plugins):
|
|
50
|
+
config_text = None
|
|
51
|
+
if config is None:
|
|
52
|
+
try:
|
|
53
|
+
with open(DEFAULT_CONFIG_FILE, 'r') as f:
|
|
54
|
+
config_text = f.read()
|
|
55
|
+
config = DEFAULT_CONFIG_FILE
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
pass
|
|
58
|
+
except IOError as e:
|
|
59
|
+
raise click.ClickException(
|
|
60
|
+
f"Failed to read {DEFAULT_CONFIG_FILE}: {str(e)}"
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
try:
|
|
64
|
+
config_text = config.read()
|
|
65
|
+
except IOError as e:
|
|
66
|
+
raise click.ClickException(
|
|
67
|
+
f"Failed to read configuration: {str(e)}",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
ctx.ensure_object(CLIContext)
|
|
71
|
+
ctx_obj: CLIContext = ctx.obj
|
|
72
|
+
cfg: Optional[CLIRootConfig] = None
|
|
73
|
+
if config_text is not None:
|
|
74
|
+
cfg = parse_cli_config(config_text)
|
|
75
|
+
ctx_obj.config = cfg.config
|
|
76
|
+
log_config = cfg.log_config
|
|
77
|
+
else:
|
|
78
|
+
# grab the default
|
|
79
|
+
log_config = parse_logging_config({})
|
|
80
|
+
|
|
81
|
+
from .commands.signing import register
|
|
82
|
+
|
|
83
|
+
plugins_to_register = _load_plugins(cfg, plugins_enabled=not no_plugins)
|
|
84
|
+
register(plugins_to_register)
|
|
85
|
+
|
|
86
|
+
if verbose:
|
|
87
|
+
# override the root logger's logging level, but preserve the output
|
|
88
|
+
root_logger_config = log_config[None]
|
|
89
|
+
log_config[None] = LogConfig(
|
|
90
|
+
level=logging.DEBUG, output=root_logger_config.output
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
# use the root logger's output settings to populate the default
|
|
94
|
+
log_output = log_config[None].output
|
|
95
|
+
# Revinfo fetch logs -> filter by default
|
|
96
|
+
log_config['pyhanko_certvalidator.fetchers'] = LogConfig(
|
|
97
|
+
level=logging.WARNING, output=log_output
|
|
98
|
+
)
|
|
99
|
+
if 'fontTools.subset' not in log_config:
|
|
100
|
+
# the fontTools subsetter has a very noisy INFO log, so
|
|
101
|
+
# set that one to WARNING by default
|
|
102
|
+
log_config['fontTools.subset'] = LogConfig(
|
|
103
|
+
level=logging.WARNING, output=log_output
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
logging_setup(log_config, verbose)
|
|
107
|
+
|
|
108
|
+
if verbose:
|
|
109
|
+
logging.debug("Running with --verbose")
|
|
110
|
+
if config_text is not None:
|
|
111
|
+
logging.debug(f'Finished reading configuration from {config}.')
|
|
112
|
+
else:
|
|
113
|
+
logging.debug('There was no configuration to parse.')
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load_plugins(root_config: Optional[CLIRootConfig], plugins_enabled: bool):
|
|
117
|
+
import sys
|
|
118
|
+
from importlib import metadata
|
|
119
|
+
|
|
120
|
+
# we always load the default ones
|
|
121
|
+
to_load = [
|
|
122
|
+
'pyhanko.cli.commands.signing.pkcs11_cli:PKCS11Plugin',
|
|
123
|
+
'pyhanko.cli.commands.signing.simple:PKCS12Plugin',
|
|
124
|
+
'pyhanko.cli.commands.signing.simple:PemderPlugin',
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
eps_from_metadata: Iterable[metadata.EntryPoint] = []
|
|
128
|
+
if plugins_enabled:
|
|
129
|
+
if root_config is not None:
|
|
130
|
+
to_load += [str(mod) for mod in root_config.plugin_endpoints]
|
|
131
|
+
|
|
132
|
+
# need to use dict interface for 3.8 interop
|
|
133
|
+
if sys.version_info < (3, 10):
|
|
134
|
+
eps_from_metadata = metadata.entry_points().get(
|
|
135
|
+
SIGNING_PLUGIN_ENTRY_POINT_GROUP, []
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
eps_from_metadata = metadata.entry_points(
|
|
139
|
+
group=SIGNING_PLUGIN_ENTRY_POINT_GROUP
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# noinspection PyArgumentList
|
|
143
|
+
to_load_as_endpoints: Iterable[metadata.EntryPoint] = [
|
|
144
|
+
metadata.EntryPoint(
|
|
145
|
+
name='',
|
|
146
|
+
value=v,
|
|
147
|
+
group=SIGNING_PLUGIN_ENTRY_POINT_GROUP,
|
|
148
|
+
)
|
|
149
|
+
for v in to_load
|
|
150
|
+
]
|
|
151
|
+
resulting_plugins = list(SIGNING_PLUGIN_REGISTRY)
|
|
152
|
+
seen = set(type(x) for x in SIGNING_PLUGIN_REGISTRY)
|
|
153
|
+
for ep in itertools.chain(to_load_as_endpoints, eps_from_metadata):
|
|
154
|
+
plugin_cls = ep.load()
|
|
155
|
+
if not isinstance(plugin_cls, type):
|
|
156
|
+
click.echo(
|
|
157
|
+
click.style(
|
|
158
|
+
f"Plugins must be defined as references to classes with a "
|
|
159
|
+
f"nullary init function, but '{ep.value}' is "
|
|
160
|
+
f"a {type(plugin_cls)}. Disregarding...",
|
|
161
|
+
bold=True,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
continue
|
|
165
|
+
if plugin_cls not in seen:
|
|
166
|
+
seen.add(plugin_cls)
|
|
167
|
+
resulting_plugins.append(plugin_cls())
|
|
168
|
+
return resulting_plugins
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
cli_root: click.Group = _root
|
pyhanko/cli/_trust.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from typing import Iterable, Optional, TypeVar, Union
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from pyhanko.cli.config import CLIConfig
|
|
5
|
+
from pyhanko.cli.utils import logger, readable_file
|
|
6
|
+
from pyhanko.config.errors import ConfigurationError
|
|
7
|
+
from pyhanko.config.trust import init_validation_context_kwargs
|
|
8
|
+
from pyhanko.keys import load_certs_from_pemder
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_vc_kwargs(
|
|
12
|
+
cli_config: Optional[CLIConfig],
|
|
13
|
+
validation_context: Optional[str],
|
|
14
|
+
trust: Union[Iterable[str], str],
|
|
15
|
+
trust_replace: bool,
|
|
16
|
+
other_certs: Union[Iterable[str], str],
|
|
17
|
+
retroactive_revinfo: bool,
|
|
18
|
+
allow_fetching: Optional[bool] = None,
|
|
19
|
+
):
|
|
20
|
+
try:
|
|
21
|
+
if validation_context is not None:
|
|
22
|
+
if any((trust, other_certs)):
|
|
23
|
+
raise click.ClickException(
|
|
24
|
+
"--validation-context is incompatible with --trust "
|
|
25
|
+
"and --other-certs"
|
|
26
|
+
)
|
|
27
|
+
# load the desired context from config
|
|
28
|
+
if cli_config is None:
|
|
29
|
+
raise click.ClickException("No config file specified.")
|
|
30
|
+
try:
|
|
31
|
+
result = cli_config.get_validation_context(
|
|
32
|
+
validation_context, as_dict=True
|
|
33
|
+
)
|
|
34
|
+
except ConfigurationError as e:
|
|
35
|
+
msg = (
|
|
36
|
+
"Configuration problem. Are you sure that the validation "
|
|
37
|
+
f"context '{validation_context}' is properly defined in the"
|
|
38
|
+
" configuration file?"
|
|
39
|
+
)
|
|
40
|
+
logger.error(msg, exc_info=e)
|
|
41
|
+
raise click.ClickException(msg)
|
|
42
|
+
elif trust or other_certs:
|
|
43
|
+
# load a validation profile using command line kwargs
|
|
44
|
+
result = init_validation_context_kwargs(
|
|
45
|
+
trust=trust,
|
|
46
|
+
trust_replace=trust_replace,
|
|
47
|
+
other_certs=other_certs,
|
|
48
|
+
retroactive_revinfo=retroactive_revinfo,
|
|
49
|
+
)
|
|
50
|
+
elif cli_config is not None:
|
|
51
|
+
# load the default settings from the CLI config
|
|
52
|
+
try:
|
|
53
|
+
result = cli_config.get_validation_context(as_dict=True)
|
|
54
|
+
except ConfigurationError as e:
|
|
55
|
+
msg = "Failed to load default validation context."
|
|
56
|
+
logger.error(msg, exc_info=e)
|
|
57
|
+
raise click.ClickException(msg)
|
|
58
|
+
else:
|
|
59
|
+
result = {}
|
|
60
|
+
|
|
61
|
+
if allow_fetching is not None:
|
|
62
|
+
result['allow_fetching'] = allow_fetching
|
|
63
|
+
else:
|
|
64
|
+
result.setdefault('allow_fetching', True)
|
|
65
|
+
|
|
66
|
+
# allow CLI --retroactive-revinfo flag to override settings
|
|
67
|
+
# if necessary
|
|
68
|
+
if retroactive_revinfo:
|
|
69
|
+
result['retroactive_revinfo'] = True
|
|
70
|
+
return result
|
|
71
|
+
except click.ClickException:
|
|
72
|
+
raise
|
|
73
|
+
except IOError as e:
|
|
74
|
+
msg = "I/O problem while reading validation config"
|
|
75
|
+
logger.error(msg, exc_info=e)
|
|
76
|
+
raise click.ClickException(msg)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
msg = "Generic processing problem while reading validation config"
|
|
79
|
+
logger.error(msg, exc_info=e)
|
|
80
|
+
raise click.ClickException(msg)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get_key_usage_settings(ctx: click.Context, validation_context: str):
|
|
84
|
+
cli_config: Optional[CLIConfig] = ctx.obj.config
|
|
85
|
+
if cli_config is None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
# note: validation_context can be None, this triggers fallback to the
|
|
89
|
+
# default validation context specified in the configuration file
|
|
90
|
+
# If we add support for specifying key usage settings as CLI arguments,
|
|
91
|
+
# using the same fallbacks as _build_cli_kwargs would probably be cleaner
|
|
92
|
+
return cli_config.get_signer_key_usages(name=validation_context)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
TRUST_OPTIONS = [
|
|
96
|
+
click.Option(
|
|
97
|
+
('--validation-context',),
|
|
98
|
+
help='use validation context from config',
|
|
99
|
+
required=False,
|
|
100
|
+
type=str,
|
|
101
|
+
),
|
|
102
|
+
click.Option(
|
|
103
|
+
('--trust',),
|
|
104
|
+
help='list trust roots (multiple allowed)',
|
|
105
|
+
required=False,
|
|
106
|
+
multiple=True,
|
|
107
|
+
type=readable_file,
|
|
108
|
+
),
|
|
109
|
+
click.Option(
|
|
110
|
+
('--trust-replace',),
|
|
111
|
+
help='listed trust roots supersede OS-provided trust store',
|
|
112
|
+
required=False,
|
|
113
|
+
type=bool,
|
|
114
|
+
is_flag=True,
|
|
115
|
+
default=False,
|
|
116
|
+
show_default=True,
|
|
117
|
+
),
|
|
118
|
+
click.Option(
|
|
119
|
+
('--other-certs',),
|
|
120
|
+
help='other certs relevant for validation',
|
|
121
|
+
required=False,
|
|
122
|
+
multiple=True,
|
|
123
|
+
type=readable_file,
|
|
124
|
+
),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
FC = TypeVar('FC', bound=click.Command)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def trust_options(f: FC) -> FC:
|
|
132
|
+
f.params.extend(TRUST_OPTIONS)
|
|
133
|
+
return f
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _prepare_vc(vc_kwargs, soft_revocation_check, force_revinfo):
|
|
137
|
+
if soft_revocation_check and force_revinfo:
|
|
138
|
+
raise click.ClickException(
|
|
139
|
+
"--soft-revocation-check is incompatible with " "--force-revinfo"
|
|
140
|
+
)
|
|
141
|
+
if force_revinfo:
|
|
142
|
+
rev_mode = 'require'
|
|
143
|
+
elif soft_revocation_check:
|
|
144
|
+
rev_mode = 'soft-fail'
|
|
145
|
+
else:
|
|
146
|
+
rev_mode = 'hard-fail'
|
|
147
|
+
vc_kwargs['revocation_mode'] = rev_mode
|
|
148
|
+
return vc_kwargs
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def grab_certs(files):
|
|
152
|
+
if not files:
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
return list(load_certs_from_pemder(files))
|
|
156
|
+
except (IOError, ValueError) as e:
|
|
157
|
+
raise click.ClickException(
|
|
158
|
+
f'Could not load certificates from {files}'
|
|
159
|
+
) from e
|
|
File without changes
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from pyhanko.cli._root import cli_root
|
|
5
|
+
from pyhanko.cli.runtime import pyhanko_exception_manager
|
|
6
|
+
from pyhanko.cli.utils import _warn_empty_passphrase, readable_file
|
|
7
|
+
from pyhanko.keys import load_certs_from_pemder
|
|
8
|
+
from pyhanko.pdf_utils import crypt
|
|
9
|
+
from pyhanko.pdf_utils.crypt import StandardSecurityHandler
|
|
10
|
+
from pyhanko.pdf_utils.crypt.permissions import PubKeyPermissions
|
|
11
|
+
from pyhanko.pdf_utils.reader import PdfFileReader
|
|
12
|
+
from pyhanko.pdf_utils.writer import copy_into_new_writer
|
|
13
|
+
|
|
14
|
+
__all__ = ['decrypt', 'encrypt_file']
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@cli_root.command(help='encrypt PDF files (AES-256 only)', name='encrypt')
|
|
18
|
+
@click.argument('infile', type=readable_file)
|
|
19
|
+
@click.argument('outfile', type=click.Path(writable=True, dir_okay=False))
|
|
20
|
+
@click.option(
|
|
21
|
+
'--password',
|
|
22
|
+
help='password to encrypt the file with',
|
|
23
|
+
required=False,
|
|
24
|
+
type=str,
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
'--recipient',
|
|
28
|
+
required=False,
|
|
29
|
+
multiple=True,
|
|
30
|
+
help='certificate(s) corresponding to entities that '
|
|
31
|
+
'can decrypt the output file',
|
|
32
|
+
type=click.Path(readable=True, dir_okay=False),
|
|
33
|
+
)
|
|
34
|
+
def encrypt_file(infile, outfile, password, recipient):
|
|
35
|
+
if password and recipient:
|
|
36
|
+
raise click.ClickException(
|
|
37
|
+
"Specify either a password or a list of recipients."
|
|
38
|
+
)
|
|
39
|
+
elif not password and not recipient:
|
|
40
|
+
password = getpass.getpass(prompt='Output file password: ')
|
|
41
|
+
|
|
42
|
+
recipient_certs = None
|
|
43
|
+
if recipient:
|
|
44
|
+
recipient_certs = list(load_certs_from_pemder(cert_files=recipient))
|
|
45
|
+
|
|
46
|
+
with pyhanko_exception_manager():
|
|
47
|
+
with open(infile, 'rb') as inf:
|
|
48
|
+
r = PdfFileReader(inf)
|
|
49
|
+
w = copy_into_new_writer(r)
|
|
50
|
+
|
|
51
|
+
if recipient_certs:
|
|
52
|
+
w.encrypt_pubkey(recipient_certs)
|
|
53
|
+
else:
|
|
54
|
+
w.encrypt(owner_pass=password)
|
|
55
|
+
|
|
56
|
+
with open(outfile, 'wb') as outf:
|
|
57
|
+
w.write(outf)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@cli_root.group(
|
|
61
|
+
help='decrypt PDF files (any standard PDF encryption scheme)',
|
|
62
|
+
name='decrypt',
|
|
63
|
+
)
|
|
64
|
+
def decrypt():
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
decrypt_force_flag = click.option(
|
|
69
|
+
'--force',
|
|
70
|
+
help='ignore access restrictions (use at your own risk)',
|
|
71
|
+
required=False,
|
|
72
|
+
type=bool,
|
|
73
|
+
is_flag=True,
|
|
74
|
+
default=False,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@decrypt.command(help='decrypt using password', name='password')
|
|
79
|
+
@click.argument('infile', type=readable_file)
|
|
80
|
+
@click.argument('outfile', type=click.Path(writable=True, dir_okay=False))
|
|
81
|
+
@click.option(
|
|
82
|
+
'--password',
|
|
83
|
+
help='password to decrypt the file with',
|
|
84
|
+
required=False,
|
|
85
|
+
type=str,
|
|
86
|
+
)
|
|
87
|
+
@decrypt_force_flag
|
|
88
|
+
def decrypt_with_password(infile, outfile, password, force):
|
|
89
|
+
with pyhanko_exception_manager():
|
|
90
|
+
with open(infile, 'rb') as inf:
|
|
91
|
+
r = PdfFileReader(inf)
|
|
92
|
+
if r.security_handler is None:
|
|
93
|
+
raise click.ClickException("File is not encrypted.")
|
|
94
|
+
elif not isinstance(r.security_handler, StandardSecurityHandler):
|
|
95
|
+
raise click.ClickException(
|
|
96
|
+
"File is not encrypted with the standard (password-based) security handler"
|
|
97
|
+
)
|
|
98
|
+
if not password:
|
|
99
|
+
password = getpass.getpass(prompt='File password: ')
|
|
100
|
+
auth_result = r.decrypt(password)
|
|
101
|
+
if auth_result.status == crypt.AuthStatus.USER and not force:
|
|
102
|
+
raise click.ClickException(
|
|
103
|
+
"Password specified was the user password, not "
|
|
104
|
+
"the owner password. Pass --force to decrypt the "
|
|
105
|
+
"file anyway."
|
|
106
|
+
)
|
|
107
|
+
elif auth_result.status == crypt.AuthStatus.FAILED:
|
|
108
|
+
raise click.ClickException("Password didn't match.")
|
|
109
|
+
w = copy_into_new_writer(r)
|
|
110
|
+
with open(outfile, 'wb') as outf:
|
|
111
|
+
w.write(outf)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@decrypt.command(help='decrypt using private key (PEM/DER)', name='pemder')
|
|
115
|
+
@click.argument('infile', type=readable_file)
|
|
116
|
+
@click.argument('outfile', type=click.Path(writable=True, dir_okay=False))
|
|
117
|
+
@click.option(
|
|
118
|
+
'--key',
|
|
119
|
+
type=readable_file,
|
|
120
|
+
required=True,
|
|
121
|
+
help='file containing the recipient\'s private key (PEM/DER)',
|
|
122
|
+
)
|
|
123
|
+
@click.option(
|
|
124
|
+
'--cert',
|
|
125
|
+
help='file containing the recipient\'s certificate (PEM/DER)',
|
|
126
|
+
type=readable_file,
|
|
127
|
+
required=True,
|
|
128
|
+
)
|
|
129
|
+
@click.option(
|
|
130
|
+
'--passfile',
|
|
131
|
+
required=False,
|
|
132
|
+
type=click.File('rb'),
|
|
133
|
+
help='file containing the passphrase for the private key',
|
|
134
|
+
show_default='stdin',
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
'--no-pass',
|
|
138
|
+
help='assume the private key file is unencrypted',
|
|
139
|
+
type=bool,
|
|
140
|
+
is_flag=True,
|
|
141
|
+
default=False,
|
|
142
|
+
show_default=True,
|
|
143
|
+
)
|
|
144
|
+
@decrypt_force_flag
|
|
145
|
+
def decrypt_with_pemder(infile, outfile, key, cert, passfile, force, no_pass):
|
|
146
|
+
if passfile is not None:
|
|
147
|
+
passphrase = passfile.read()
|
|
148
|
+
passfile.close()
|
|
149
|
+
elif 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
|
+
sedk = crypt.SimpleEnvelopeKeyDecrypter.load(
|
|
158
|
+
key, cert, key_passphrase=passphrase
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
_decrypt_pubkey(sedk, infile, outfile, force)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _decrypt_pubkey(
|
|
165
|
+
sedk: crypt.SimpleEnvelopeKeyDecrypter, infile, outfile, force
|
|
166
|
+
):
|
|
167
|
+
with pyhanko_exception_manager():
|
|
168
|
+
with open(infile, 'rb') as inf:
|
|
169
|
+
r = PdfFileReader(inf)
|
|
170
|
+
if r.security_handler is None:
|
|
171
|
+
raise click.ClickException("File is not encrypted.")
|
|
172
|
+
if not isinstance(r.security_handler, crypt.PubKeySecurityHandler):
|
|
173
|
+
raise click.ClickException(
|
|
174
|
+
"File was not encrypted with a public-key security handler."
|
|
175
|
+
)
|
|
176
|
+
auth_result = r.decrypt_pubkey(sedk)
|
|
177
|
+
if auth_result.status == crypt.AuthStatus.USER:
|
|
178
|
+
if (
|
|
179
|
+
not force
|
|
180
|
+
and auth_result.permission_flags
|
|
181
|
+
and not (
|
|
182
|
+
PubKeyPermissions.ALLOW_ENCRYPTION_CHANGE
|
|
183
|
+
in auth_result.permission_flags
|
|
184
|
+
)
|
|
185
|
+
):
|
|
186
|
+
raise click.ClickException(
|
|
187
|
+
"Change of encryption is typically not allowed with "
|
|
188
|
+
"user access. Pass --force to decrypt the file anyway."
|
|
189
|
+
)
|
|
190
|
+
elif auth_result.status == crypt.AuthStatus.FAILED:
|
|
191
|
+
raise click.ClickException("Failed to decrypt the file.")
|
|
192
|
+
w = copy_into_new_writer(r)
|
|
193
|
+
with open(outfile, 'wb') as outf:
|
|
194
|
+
w.write(outf)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@decrypt.command(help='decrypt using private key (PKCS#12)', name='pkcs12')
|
|
198
|
+
@click.argument('infile', type=readable_file)
|
|
199
|
+
@click.argument('outfile', type=click.Path(writable=True, dir_okay=False))
|
|
200
|
+
@click.argument('pfx', type=readable_file)
|
|
201
|
+
@click.option(
|
|
202
|
+
'--passfile',
|
|
203
|
+
required=False,
|
|
204
|
+
type=click.File('r'),
|
|
205
|
+
help='file containing the passphrase for the PKCS#12 file',
|
|
206
|
+
show_default='stdin',
|
|
207
|
+
)
|
|
208
|
+
@decrypt_force_flag
|
|
209
|
+
def decrypt_with_pkcs12(infile, outfile, pfx, passfile, force):
|
|
210
|
+
if passfile is None:
|
|
211
|
+
passphrase = getpass.getpass(prompt='Key passphrase: ').encode('utf-8')
|
|
212
|
+
else:
|
|
213
|
+
passphrase = passfile.readline().strip().encode('utf-8')
|
|
214
|
+
passfile.close()
|
|
215
|
+
sedk = crypt.SimpleEnvelopeKeyDecrypter.load_pkcs12(
|
|
216
|
+
pfx, passphrase=passphrase
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
_decrypt_pubkey(sedk, infile, outfile, force)
|