1"""Signer implementation for OpenPGP"""
2
3from __future__ import annotations
4
5import logging
6from typing import Any
7from urllib import parse
8
9from securesystemslib import exceptions
10from securesystemslib._gpg import constants as gpg_constants
11from securesystemslib._gpg import exceptions as gpg_exceptions
12from securesystemslib._gpg import functions as gpg
13from securesystemslib.signer._key import Key
14from securesystemslib.signer._signer import SecretsHandler, Signature, Signer
15
16logger = logging.getLogger(__name__)
17
18
19class GPGKey(Key):
20 """OpenPGP Key.
21
22 *All parameters named below are not just constructor arguments but also
23 instance attributes.*
24
25 Attributes:
26 keyid: Key identifier that is unique within the metadata it is used in.
27 It is also used to identify the GnuPG local user signing key.
28 ketytype: Key type, e.g. "rsa", "dsa" or "eddsa".
29 scheme: Signing schemes, e.g. "pgp+rsa-pkcsv1.5", "pgp+dsa-fips-180-2",
30 "pgp+eddsa-ed25519".
31 keyval: Opaque key content.
32 unrecognized_fields: Dictionary of all attributes that are not managed
33 by Securesystemslib
34 """
35
36 @classmethod
37 def from_dict(cls, keyid: str, key_dict: dict[str, Any]) -> GPGKey:
38 keytype, scheme, keyval = cls._from_dict(key_dict)
39 return cls(keyid, keytype, scheme, keyval, key_dict)
40
41 def to_dict(self) -> dict:
42 return self._to_dict()
43
44 def verify_signature(self, signature: Signature, data: bytes) -> None:
45 try:
46 if not gpg.verify_signature(
47 GPGSigner._sig_to_legacy_dict(signature),
48 GPGSigner._key_to_legacy_dict(self),
49 data,
50 ):
51 raise exceptions.UnverifiedSignatureError(
52 f"Failed to verify signature by {self.keyid}"
53 )
54 except (exceptions.UnsupportedLibraryError,) as e:
55 logger.info("Key %s failed to verify sig: %s", self.keyid, str(e))
56 raise exceptions.VerificationError(
57 f"Unknown failure to verify signature by {self.keyid}"
58 ) from e
59
60
61class GPGSigner(Signer):
62 """OpenPGP Signer
63
64 Runs command in ``GNUPG`` environment variable to sign. Fallback commands are
65 ``gpg2`` and ``gpg``.
66
67 Supported signing schemes are: "pgp+rsa-pkcsv1.5", "pgp+dsa-fips-180-2" and
68 "pgp+eddsa-ed25519", with SHA-256 hashing.
69
70 GPGSigner can be instantiated with Signer.from_priv_key_uri(). These private key URI
71 schemes are supported:
72
73 * "gnupg:[<GnuPG homedir>]":
74 Signs with GnuPG key in keyring in home dir. The signing key is
75 identified with the keyid of the passed public key. If homedir is not
76 passed, the default homedir is used.
77
78 Arguments:
79 public_key: The related public key instance.
80 homedir: GnuPG home directory path. If not passed, the default homedir is used.
81
82 """
83
84 SCHEME = "gnupg"
85
86 def __init__(
87 self,
88 public_key: Key,
89 homedir: str | None = None,
90 ):
91 self.homedir = homedir
92 self._public_key = public_key
93
94 @property
95 def public_key(self) -> Key:
96 return self._public_key
97
98 @classmethod
99 def from_priv_key_uri(
100 cls,
101 priv_key_uri: str,
102 public_key: Key,
103 secrets_handler: SecretsHandler | None = None,
104 ) -> GPGSigner:
105 if not isinstance(public_key, GPGKey):
106 raise ValueError(f"expected GPGKey for {priv_key_uri}")
107
108 uri = parse.urlparse(priv_key_uri)
109
110 if uri.scheme != cls.SCHEME:
111 raise ValueError(f"GPGSigner does not support {priv_key_uri}")
112
113 homedir = uri.path or None
114
115 return cls(public_key, homedir)
116
117 @staticmethod
118 def _sig_to_legacy_dict(sig: Signature) -> dict:
119 """Helper to convert Signature to internal gpg signature dict format."""
120 sig_dict = sig.to_dict()
121 sig_dict["signature"] = sig_dict.pop("sig")
122 return sig_dict
123
124 @staticmethod
125 def _sig_from_legacy_dict(sig_dict: dict) -> Signature:
126 """Helper to convert internal gpg signature format to Signature."""
127 sig_dict["sig"] = sig_dict.pop("signature")
128 return Signature.from_dict(sig_dict)
129
130 @staticmethod
131 def _key_to_legacy_dict(key: GPGKey) -> dict[str, Any]:
132 """Returns legacy dictionary representation of self."""
133 return {
134 "keyid": key.keyid,
135 "type": key.keytype,
136 "method": key.scheme,
137 "hashes": [gpg_constants.GPG_HASH_ALGORITHM_STRING],
138 "keyval": key.keyval,
139 }
140
141 @staticmethod
142 def _key_from_legacy_dict(key_dict: dict[str, Any]) -> GPGKey:
143 """Create GPGKey from legacy dictionary representation."""
144 keyid = key_dict["keyid"]
145 keytype = key_dict["type"]
146 scheme = key_dict["method"]
147 keyval = key_dict["keyval"]
148
149 return GPGKey(keyid, keytype, scheme, keyval)
150
151 @classmethod
152 def import_(cls, keyid: str, homedir: str | None = None) -> tuple[str, Key]:
153 """Load key and signer details from GnuPG keyring.
154
155 NOTE: Information about the key validity (expiration, revocation, etc.)
156 is discarded at import and not considered when verifying a signature.
157
158 Args:
159 keyid: GnuPG local user signing key id.
160 homedir: GnuPG home directory path. If not passed, the default homedir is
161 used.
162
163 Raises:
164 UnsupportedLibraryError: The gpg command or pyca/cryptography are
165 not available.
166 ValueError: No key was found for the passed keyid.
167
168 Returns:
169 Tuple of private key uri and the public key.
170
171 """
172 uri = f"{cls.SCHEME}:{homedir or ''}"
173
174 try:
175 raw_key = gpg.export_pubkey(keyid, homedir)
176
177 except gpg_exceptions.KeyNotFoundError as e:
178 raise ValueError(e) from e
179
180 raw_keys = [raw_key] + list(raw_key.pop("subkeys", {}).values())
181 keyids = []
182
183 for key in raw_keys:
184 if key["keyid"] == keyid:
185 # TODO: Raise here if key is expired, revoked, incapable, ...
186 public_key = cls._key_from_legacy_dict(key)
187 break
188 keyids.append(key["keyid"])
189
190 else:
191 raise ValueError(
192 f"No exact match found for passed keyid {keyid}, found: {keyids}."
193 )
194
195 return (uri, public_key)
196
197 def sign(self, payload: bytes) -> Signature:
198 """Signs payload with GnuPG.
199
200 Arguments:
201 payload: bytes to be signed.
202
203 Raises:
204 ValueError: gpg command failed to create a valid signature, e.g.
205 because its keyid does not match the public key keyid.
206 OSError: gpg command is not present, or non-executable, or returned
207 a non-zero exit code.
208 securesystemslib.exceptions.UnsupportedLibraryError: gpg command is not
209 available, or the cryptography library is not installed.
210
211 Returns:
212 Signature.
213
214 """
215 try:
216 raw_sig = gpg.create_signature(payload, self.public_key.keyid, self.homedir)
217 except gpg_exceptions.KeyNotFoundError as e:
218 raise ValueError(e) from e
219
220 if raw_sig["keyid"] != self.public_key.keyid:
221 raise ValueError(
222 f"The signing key {raw_sig['keyid']} does not"
223 f" match the attached public key {self.public_key.keyid}."
224 )
225
226 return self._sig_from_legacy_dict(raw_sig)