signature.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. # signature.py -- Signature vendors for signing and verifying Git objects
  2. # Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
  3. #
  4. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  5. # General Public License as public by the Free Software Foundation; version 2.0
  6. # or (at your option) any later version. You can redistribute it and/or
  7. # modify it under the terms of either of these two licenses.
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. #
  15. # You should have received a copy of the licenses; if not, see
  16. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  17. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  18. # License, Version 2.0.
  19. #
  20. """Signature vendors for signing and verifying Git objects."""
  21. from collections.abc import Iterable
  22. class SignatureVendor:
  23. """A signature implementation for signing and verifying Git objects."""
  24. def sign(self, data: bytes, keyid: str | None = None) -> bytes:
  25. """Sign data with a key.
  26. Args:
  27. data: The data to sign
  28. keyid: Optional key ID to use for signing. If not specified,
  29. the default key will be used.
  30. Returns:
  31. The signature as bytes
  32. """
  33. raise NotImplementedError(self.sign)
  34. def verify(
  35. self, data: bytes, signature: bytes, keyids: Iterable[str] | None = None
  36. ) -> None:
  37. """Verify a signature.
  38. Args:
  39. data: The data that was signed
  40. signature: The signature to verify
  41. keyids: Optional iterable of trusted key IDs.
  42. If the signature was not created by any key in keyids, verification will
  43. fail. If not specified, this function only verifies that the signature
  44. is valid.
  45. Raises:
  46. Exception on verification failure (implementation-specific)
  47. """
  48. raise NotImplementedError(self.verify)
  49. class GPGSignatureVendor(SignatureVendor):
  50. """Signature vendor that uses the GPG package for signing and verification."""
  51. def sign(self, data: bytes, keyid: str | None = None) -> bytes:
  52. """Sign data with a GPG key.
  53. Args:
  54. data: The data to sign
  55. keyid: Optional GPG key ID to use for signing. If not specified,
  56. the default GPG key will be used.
  57. Returns:
  58. The signature as bytes
  59. """
  60. import gpg
  61. signature: bytes
  62. with gpg.Context(armor=True) as c:
  63. if keyid is not None:
  64. key = c.get_key(keyid)
  65. with gpg.Context(armor=True, signers=[key]) as ctx:
  66. signature, _unused_result = ctx.sign(
  67. data,
  68. mode=gpg.constants.sig.mode.DETACH,
  69. )
  70. else:
  71. signature, _unused_result = c.sign(
  72. data, mode=gpg.constants.sig.mode.DETACH
  73. )
  74. return signature
  75. def verify(
  76. self, data: bytes, signature: bytes, keyids: Iterable[str] | None = None
  77. ) -> None:
  78. """Verify a GPG signature.
  79. Args:
  80. data: The data that was signed
  81. signature: The signature to verify
  82. keyids: Optional iterable of trusted GPG key IDs.
  83. If the signature was not created by any key in keyids, verification will
  84. fail. If not specified, this function only verifies that the signature
  85. is valid.
  86. Raises:
  87. gpg.errors.BadSignatures: if GPG signature verification fails
  88. gpg.errors.MissingSignatures: if the signature was not created by a key
  89. specified in keyids
  90. """
  91. import gpg
  92. with gpg.Context() as ctx:
  93. verified_data, result = ctx.verify(
  94. data,
  95. signature=signature,
  96. )
  97. if keyids:
  98. keys = [ctx.get_key(key) for key in keyids]
  99. for key in keys:
  100. for subkey in key.subkeys:
  101. for sig in result.signatures:
  102. if subkey.can_sign and subkey.fpr == sig.fpr:
  103. return
  104. raise gpg.errors.MissingSignatures(
  105. result, keys, results=(verified_data, result)
  106. )
  107. class GPGCliSignatureVendor(SignatureVendor):
  108. """Signature vendor that uses the GPG command-line tool for signing and verification."""
  109. def __init__(self, gpg_command: str = "gpg") -> None:
  110. """Initialize the GPG CLI vendor.
  111. Args:
  112. gpg_command: Path to the GPG command (defaults to 'gpg')
  113. """
  114. self.gpg_command = gpg_command
  115. def sign(self, data: bytes, keyid: str | None = None) -> bytes:
  116. """Sign data with a GPG key using the command-line tool.
  117. Args:
  118. data: The data to sign
  119. keyid: Optional GPG key ID to use for signing. If not specified,
  120. the default GPG key will be used.
  121. Returns:
  122. The signature as bytes
  123. Raises:
  124. subprocess.CalledProcessError: if GPG command fails
  125. """
  126. import subprocess
  127. args = [self.gpg_command, "--detach-sign", "--armor"]
  128. if keyid is not None:
  129. args.extend(["--local-user", keyid])
  130. result = subprocess.run(
  131. args,
  132. input=data,
  133. capture_output=True,
  134. check=True,
  135. )
  136. return result.stdout
  137. def verify(
  138. self, data: bytes, signature: bytes, keyids: Iterable[str] | None = None
  139. ) -> None:
  140. """Verify a GPG signature using the command-line tool.
  141. Args:
  142. data: The data that was signed
  143. signature: The signature to verify
  144. keyids: Optional iterable of trusted GPG key IDs.
  145. If the signature was not created by any key in keyids, verification will
  146. fail. If not specified, this function only verifies that the signature
  147. is valid.
  148. Raises:
  149. subprocess.CalledProcessError: if GPG signature verification fails
  150. ValueError: if signature was not created by a trusted key
  151. """
  152. import subprocess
  153. import tempfile
  154. # GPG requires the signature and data in separate files for verification
  155. with (
  156. tempfile.NamedTemporaryFile(mode="wb", suffix=".sig") as sig_file,
  157. tempfile.NamedTemporaryFile(mode="wb", suffix=".dat") as data_file,
  158. ):
  159. sig_file.write(signature)
  160. sig_file.flush()
  161. data_file.write(data)
  162. data_file.flush()
  163. args = [self.gpg_command, "--verify", sig_file.name, data_file.name]
  164. result = subprocess.run(
  165. args,
  166. capture_output=True,
  167. check=True,
  168. )
  169. # If keyids are specified, check that the signature was made by one of them
  170. if keyids:
  171. # Parse stderr to extract the key fingerprint/ID that made the signature
  172. stderr_text = result.stderr.decode("utf-8", errors="replace")
  173. # GPG outputs both subkey and primary key fingerprints
  174. # Collect both to check against trusted keyids
  175. signing_keys = []
  176. for line in stderr_text.split("\n"):
  177. if (
  178. "using RSA key" in line
  179. or "using DSA key" in line
  180. or "using EDDSA key" in line
  181. or "using ECDSA key" in line
  182. ):
  183. # Extract the key ID from lines like "gpg: using RSA key ABCD1234..."
  184. parts = line.split()
  185. if "key" in parts:
  186. key_idx = parts.index("key")
  187. if key_idx + 1 < len(parts):
  188. signing_keys.append(parts[key_idx + 1])
  189. elif "Primary key fingerprint:" in line:
  190. # Extract fingerprint
  191. fpr = line.split(":", 1)[1].strip().replace(" ", "")
  192. signing_keys.append(fpr)
  193. if not signing_keys:
  194. raise ValueError("Could not determine signing key from GPG output")
  195. # Check if any of the signing keys (subkey or primary) match the trusted keyids
  196. keyids_normalized = [k.replace(" ", "").upper() for k in keyids]
  197. # Check each signing key against trusted keyids
  198. for signed_by in signing_keys:
  199. signed_by_normalized = signed_by.replace(" ", "").upper()
  200. # Check if signed_by matches or is a suffix of any trusted keyid
  201. # (GPG sometimes shows short key IDs)
  202. if any(
  203. signed_by_normalized in keyid or keyid in signed_by_normalized
  204. for keyid in keyids_normalized
  205. ):
  206. return
  207. # None of the signing keys matched
  208. raise ValueError(
  209. f"Signature not created by a trusted key. "
  210. f"Signed by: {signing_keys}, trusted keys: {list(keyids)}"
  211. )
  212. # Default GPG vendor instance
  213. gpg_vendor = GPGSignatureVendor()