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 ADDED
@@ -0,0 +1,9 @@
1
+ from typing import List
2
+
3
+ from pyhanko.cli import launch
4
+
5
+ __all__: List[str] = []
6
+
7
+
8
+ if __name__ == '__main__':
9
+ launch()
@@ -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)