1"""
2<Module Name>
3 functions.py
4
5<Author>
6 Santiago Torres-Arias <santiago@nyu.edu>
7
8<Started>
9 Nov 15, 2017
10
11<Copyright>
12 See LICENSE for licensing information.
13
14<Purpose>
15 publicly-usable functions for exporting public-keys, signing data and
16 verifying signatures.
17"""
18
19import logging
20import os
21import subprocess
22import time
23from pathlib import PureWindowsPath
24
25from securesystemslib import exceptions
26from securesystemslib._gpg.common import (
27 get_pubkey_bundle,
28 parse_signature_packet,
29)
30from securesystemslib._gpg.constants import (
31 FULLY_SUPPORTED_MIN_VERSION,
32 GPG_TIMEOUT,
33 NO_GPG_MSG,
34 SHA256,
35 gpg_command,
36 gpg_export_pubkey_command,
37 gpg_sign_command,
38 have_gpg,
39)
40from securesystemslib._gpg.exceptions import KeyExpirationError
41from securesystemslib._gpg.handlers import SIGNATURE_HANDLERS
42from securesystemslib._gpg.rsa import CRYPTO
43
44log = logging.getLogger(__name__)
45
46NO_CRYPTO_MSG = "GPG support requires the cryptography library"
47
48
49def _homedir_to_gpg_arg(homedir: str) -> str:
50 """Convert a homedir path to a GPG-compatible --homedir argument.
51
52 On Windows, path format depends on the GPG binary:
53 - Native Gpg4win (.exe): accepts forward-slash Windows paths (C:/path)
54 - Cygwin/MSYS2 GPG: requires cygwin-style paths (/c/path)
55 See https://github.com/secure-systems-lab/securesystemslib/issues/517
56 """
57 if gpg_command().endswith(".exe"):
58 return homedir.replace("\\", "/")
59 if os.name == "nt":
60 p = PureWindowsPath(homedir)
61 if p.drive:
62 drive_letter = p.drive[0].lower()
63 rest = p.as_posix()[len(p.drive) :]
64 return f"/{drive_letter}{rest}"
65 return homedir.replace("\\", "/")
66
67
68def create_signature(content, keyid=None, homedir=None, timeout=GPG_TIMEOUT):
69 """
70 <Purpose>
71 Calls the gpg command line utility to sign the passed content with the key
72 identified by the passed keyid from the gpg keyring at the passed homedir.
73
74 The executed base command is defined in
75 securesystemslib._gpg.constants.gpg_sign_command.
76
77 NOTE: On not fully supported versions of GPG, i.e. versions below
78 securesystemslib._gpg.constants.FULLY_SUPPORTED_MIN_VERSION the returned
79 signature does not contain the full keyid. As a work around, we export the
80 public key bundle identified by the short keyid to compute the full keyid
81 and add it to the returned signature.
82
83 <Arguments>
84 content:
85 The content to be signed. (bytes)
86
87 keyid: (optional)
88 The keyid of the gpg signing keyid. If not passed the default
89 key in the keyring is used.
90
91 homedir: (optional)
92 Path to the gpg keyring. If not passed the default keyring is used.
93
94 timeout (optional):
95 gpg command timeout in seconds. Default is 10.
96
97 <Exceptions>
98
99 ValueError:
100 If the gpg command failed to create a valid signature.
101
102 OSError:
103 If the gpg command is not present, or non-executable,
104 or returned a non-zero exit code
105
106 securesystemslib.exceptions.UnsupportedLibraryError:
107 If the gpg command is not available, or
108 the cryptography library is not installed.
109
110 securesystemslib._gpg.exceptions.KeyNotFoundError:
111 If the used gpg version is not fully supported
112 and no public key can be found for short keyid.
113
114 <Side Effects>
115 None.
116
117 <Returns>
118 A signature dict.
119
120 """
121 if not have_gpg(): # pragma: no cover
122 raise exceptions.UnsupportedLibraryError(NO_GPG_MSG)
123
124 if not CRYPTO: # pragma: no cover
125 raise exceptions.UnsupportedLibraryError(NO_CRYPTO_MSG)
126
127 keyarg = ""
128 if keyid:
129 keyarg = f"--local-user {keyid}"
130
131 homearg = ""
132 if homedir:
133 homearg = f"--homedir {_homedir_to_gpg_arg(homedir)}"
134
135 command = gpg_sign_command(keyarg=keyarg, homearg=homearg)
136
137 gpg_process = subprocess.run( # noqa: S603
138 command,
139 input=content,
140 check=False,
141 capture_output=True,
142 timeout=timeout,
143 )
144
145 # TODO: It's suggested to take a look at `--status-fd` for proper error
146 # reporting, as there is no clear distinction between the return codes
147 # https://lists.gnupg.org/pipermail/gnupg-devel/2005-December/022559.html
148 if gpg_process.returncode != 0:
149 raise OSError(
150 f"Command '{gpg_process.args}' returned "
151 f"non-zero exit status '{gpg_process.returncode}', "
152 f"stderr was:\n{gpg_process.stderr.decode()}."
153 )
154
155 signature_data = gpg_process.stdout
156 signature = parse_signature_packet(signature_data)
157
158 # On GPG < 2.1 we cannot derive the full keyid from the signature data.
159 # Instead we try to compute the keyid from the public part of the signing
160 # key or its subkeys, identified by the short keyid.
161 # parse_signature_packet is guaranteed to return at least one of keyid or
162 # short_keyid.
163 # Exclude the following code from coverage for consistent coverage across
164 # test environments.
165 if not signature["keyid"]: # pragma: no cover
166 log.warning(
167 "The created signature does not include the hashed subpacket"
168 " '33' (full keyid). You probably have a gpg version"
169 f" <{FULLY_SUPPORTED_MIN_VERSION}."
170 " We will export the public keys associated with the short keyid to"
171 " compute the full keyid."
172 )
173
174 short_keyid = signature["short_keyid"]
175
176 # Export public key bundle (master key including with optional subkeys)
177 public_key_bundle = export_pubkey(short_keyid, homedir)
178
179 # Test if the short keyid matches the master key ...
180 master_key_full_keyid = public_key_bundle["keyid"]
181 if master_key_full_keyid.endswith(short_keyid.lower()):
182 signature["keyid"] = master_key_full_keyid
183
184 # ... or one of the subkeys, and add the full keyid to the signature dict.
185 else:
186 for sub_key_full_keyid in list(public_key_bundle.get("subkeys", {}).keys()):
187 if sub_key_full_keyid.endswith(short_keyid.lower()):
188 signature["keyid"] = sub_key_full_keyid
189 break
190
191 # If there is still no full keyid something went wrong
192 if not signature["keyid"]: # pragma: no cover
193 raise ValueError(
194 f"Full keyid could not be determined for signature '{signature}'"
195 )
196
197 # It is okay now to remove the optional short keyid to save space
198 signature.pop("short_keyid", None)
199
200 return signature
201
202
203def verify_signature(signature_object, pubkey_info, content):
204 """
205 <Purpose>
206 Verifies the passed signature against the passed content using the
207 passed public key, or one of its subkeys, associated by the signature's
208 keyid.
209
210 The function selects the appropriate verification algorithm (rsa or dsa)
211 based on the "type" field in the passed public key object.
212
213 <Arguments>
214 signature_object:
215 A signature dict.
216
217 pubkey_info:
218 A public key dict.
219
220 content:
221 The content to be verified. (bytes)
222
223 <Exceptions>
224 securesystemslib._gpg.exceptions.KeyExpirationError:
225 if the passed public key has expired
226
227 securesystemslib.exceptions.UnsupportedLibraryError:
228 if the cryptography module is unavailable
229
230 <Side Effects>
231 None.
232
233 <Returns>
234 True if signature verification passes, False otherwise.
235
236 """
237 if not CRYPTO: # pragma: no cover
238 raise exceptions.UnsupportedLibraryError(NO_CRYPTO_MSG)
239
240 handler = SIGNATURE_HANDLERS[pubkey_info["type"]]
241 sig_keyid = signature_object["keyid"]
242
243 verification_key = pubkey_info
244
245 # If the keyid on the signature matches a subkey of the passed key,
246 # we use that subkey for verification instead of the master key.
247 if sig_keyid in list(pubkey_info.get("subkeys", {}).keys()):
248 verification_key = pubkey_info["subkeys"][sig_keyid]
249
250 creation_time = verification_key.get("creation_time")
251 validity_period = verification_key.get("validity_period")
252
253 if (
254 creation_time
255 and validity_period
256 and creation_time + validity_period < time.time()
257 ):
258 raise KeyExpirationError(verification_key)
259
260 return handler.verify_signature(signature_object, verification_key, content, SHA256)
261
262
263def export_pubkey(keyid, homedir=None, timeout=GPG_TIMEOUT):
264 """Exports a public key from a GnuPG keyring.
265
266 Arguments:
267 keyid: An OpenPGP keyid..
268 homedir (optional): A path to the GnuPG home directory. If not set the
269 default GnuPG home directory is used.
270 timeout (optional): gpg command timeout in seconds. Default is 10.
271
272 Raises:
273 UnsupportedLibraryError: The gpg command or pyca/cryptography are not
274 available.
275 KeyNotFoundError: No key or subkey was found for that keyid.
276
277 Side Effects:
278 Calls system gpg command in a subprocess.
279
280 Returns:
281 An OpenPGP public key dict.
282
283 """
284 if not have_gpg(): # pragma: no cover
285 raise exceptions.UnsupportedLibraryError(NO_GPG_MSG)
286
287 if not CRYPTO: # pragma: no cover
288 raise exceptions.UnsupportedLibraryError(NO_CRYPTO_MSG)
289
290 homearg = ""
291 if homedir:
292 homearg = f"--homedir {_homedir_to_gpg_arg(homedir)}"
293
294 # TODO: Consider adopting command error handling from `create_signature`
295 # above, e.g. in a common 'run gpg command' utility function
296 command = gpg_export_pubkey_command(keyid=keyid, homearg=homearg)
297 gpg_process = subprocess.run( # noqa: S603
298 command,
299 capture_output=True,
300 timeout=timeout,
301 check=True,
302 )
303
304 key_packet = gpg_process.stdout
305 key_bundle = get_pubkey_bundle(key_packet, keyid)
306
307 return key_bundle
308
309
310def export_pubkeys(keyids, homedir=None, timeout=GPG_TIMEOUT):
311 """Exports multiple public keys from a GnuPG keyring.
312
313 Arguments:
314 keyids: A list of OpenPGP keyids.
315 homedir (optional): A path to the GnuPG home directory. If not set the
316 default GnuPG home directory is used.
317 timeout (optional): gpg command timeout in seconds. Default is 10.
318
319 Raises:
320 TypeError: Keyids is not iterable.
321 ValueError: A Keyid is not a string.
322 UnsupportedLibraryError: The gpg command or pyca/cryptography are not
323 available.
324 KeyNotFoundError: No key or subkey was found for that keyid.
325
326 Side Effects:
327 Calls system gpg command in a subprocess.
328
329 Returns:
330 A dict of OpenPGP public key dicts as values,
331 and their keyids as dict keys.
332
333
334 """
335 public_key_dict = {}
336 for gpg_keyid in keyids:
337 public_key = export_pubkey(gpg_keyid, homedir=homedir, timeout=timeout)
338 keyid = public_key["keyid"]
339 public_key_dict[keyid] = public_key
340
341 return public_key_dict