Prechádzať zdrojové kódy

Implement commit signing

springheeledjack0 2 rokov pred
rodič
commit
2b484eb322
4 zmenil súbory, kde vykonal 137 pridanie a 1 odobranie
  1. 56 0
      dulwich/objects.py
  2. 5 1
      dulwich/porcelain.py
  3. 12 0
      dulwich/repo.py
  4. 64 0
      dulwich/tests/test_porcelain.py

+ 56 - 0
dulwich/objects.py

@@ -1420,6 +1420,62 @@ class Commit(ShaFile):
 
         # TODO: optionally check for duplicate parents
 
+    def sign(self, keyid: Optional[str] = None):
+        import gpg
+        with gpg.Context(armor=True) as c:
+            if keyid is not None:
+                key = c.get_key(keyid)
+                with gpg.Context(armor=True, signers=[key]) as ctx:
+                    self.gpgsig, unused_result = ctx.sign(
+                        self.as_raw_string(),
+                        mode=gpg.constants.sig.mode.DETACH,
+                    )
+            else:
+                self.gpgsig, unused_result = c.sign(
+                    self.as_raw_string(), mode=gpg.constants.sig.mode.DETACH
+                )
+
+    def verify(self, keyids: Optional[Iterable[str]] = None):
+        """Verify GPG signature for this commit (if it is signed).
+
+        Args:
+          keyids: Optional iterable of trusted keyids for this commit.
+            If this commit is not signed by any key in keyids verification will
+            fail. If not specified, this function only verifies that the commit
+            has a valid signature.
+
+        Raises:
+          gpg.errors.BadSignatures: if GPG signature verification fails
+          gpg.errors.MissingSignatures: if commit was not signed by a key
+            specified in keyids
+        """
+        if self._gpgsig is None:
+            return
+
+        import gpg
+
+        with gpg.Context() as ctx:
+            self_without_gpgsig = self.copy()
+            self_without_gpgsig._gpgsig = None
+            self_without_gpgsig.gpgsig = None
+            data, result = ctx.verify(
+                self_without_gpgsig.as_raw_string(),
+                signature=self._gpgsig,
+            )
+            if keyids:
+                keys = [
+                    ctx.get_key(key)
+                    for key in keyids
+                ]
+                for key in keys:
+                    for subkey in keys:
+                        for sig in result.signatures:
+                            if subkey.can_sign and subkey.fpr == sig.fpr:
+                                return
+                raise gpg.errors.MissingSignatures(
+                    result, keys, results=(data, result)
+                )
+
     def _serialize(self):
         chunks = []
         tree_bytes = self._tree.id if isinstance(self._tree, Tree) else self._tree

+ 5 - 1
dulwich/porcelain.py

@@ -332,6 +332,7 @@ def commit(
     committer=None,
     encoding=None,
     no_verify=False,
+    signoff=False,
 ):
     """Create a new commit.
 
@@ -341,10 +342,12 @@ def commit(
       author: Optional author name and email
       committer: Optional committer name and email
       no_verify: Skip pre-commit and commit-msg hooks
+      signoff: GPG Sign the commit (bool, defaults to False,
+        pass True to use default GPG key,
+        pass a str containing Key ID to use a specific GPG key)
     Returns: SHA1 of the new commit
     """
     # FIXME: Support --all argument
-    # FIXME: Support --signoff argument
     if getattr(message, "encode", None):
         message = message.encode(encoding or DEFAULT_ENCODING)
     if getattr(author, "encode", None):
@@ -358,6 +361,7 @@ def commit(
             committer=committer,
             encoding=encoding,
             no_verify=no_verify,
+            sign=signoff if isinstance(signoff, (str, bool)) else None,
         )
 
 

+ 12 - 0
dulwich/repo.py

@@ -876,6 +876,7 @@ class BaseRepo(object):
         ref=b"HEAD",
         merge_heads=None,
         no_verify=False,
+        sign=False,
     ):
         """Create a new commit.
 
@@ -899,6 +900,9 @@ class BaseRepo(object):
           ref: Optional ref to commit to (defaults to current branch)
           merge_heads: Merge heads (defaults to .git/MERGE_HEAD)
           no_verify: Skip pre-commit and commit-msg hooks
+          sign: GPG Sign the commit (bool, defaults to False,
+            pass True to use default GPG key,
+            pass a str containing Key ID to use a specific GPG key)
 
         Returns:
           New commit SHA1
@@ -970,14 +974,20 @@ class BaseRepo(object):
         except KeyError:  # no hook defined, message not modified
             c.message = message
 
+        keyid = sign if isinstance(sign, str) else None
+
         if ref is None:
             # Create a dangling commit
             c.parents = merge_heads
+            if sign:
+                c.sign(keyid)
             self.object_store.add_object(c)
         else:
             try:
                 old_head = self.refs[ref]
                 c.parents = [old_head] + merge_heads
+                if sign:
+                    c.sign(keyid)
                 self.object_store.add_object(c)
                 ok = self.refs.set_if_equals(
                     ref,
@@ -990,6 +1000,8 @@ class BaseRepo(object):
                 )
             except KeyError:
                 c.parents = merge_heads
+                if sign:
+                    c.sign(keyid)
                 self.object_store.add_object(c)
                 ok = self.refs.add_if_new(
                     ref,

+ 64 - 0
dulwich/tests/test_porcelain.py

@@ -419,6 +419,70 @@ class CommitTests(PorcelainTestCase):
         self.assertEqual(len(sha), 40)
 
 
+@skipIf(platform.python_implementation() == "PyPy" or sys.platform == "win32", "gpgme not easily available or supported on Windows and PyPy")
+class CommitSignTests(PorcelainGpgTestCase):
+
+    def test_default_key(self):
+        c1, c2, c3 = build_commit_graph(
+            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
+        )
+        self.repo.refs[b"HEAD"] = c3.id
+        cfg = self.repo.get_config()
+        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
+        self.import_default_key()
+
+        sha = porcelain.commit(
+            self.repo.path,
+            message="Some message",
+            author="Joe <joe@example.com>",
+            committer="Bob <bob@example.com>",
+            signoff=True,
+        )
+        self.assertIsInstance(sha, bytes)
+        self.assertEqual(len(sha), 40)
+
+        commit = self.repo.get_object(sha)
+        # GPG Signatures aren't deterministic, so we can't do a static assertion.
+        commit.verify()
+        commit.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
+
+        self.import_non_default_key()
+        self.assertRaises(
+            gpg.errors.MissingSignatures,
+            commit.verify,
+            keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID],
+        )
+
+        commit.committer = b"Alice <alice@example.com>"
+        self.assertRaises(
+            gpg.errors.BadSignatures,
+            commit.verify,
+        )
+
+    def test_non_default_key(self):
+        c1, c2, c3 = build_commit_graph(
+            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
+        )
+        self.repo.refs[b"HEAD"] = c3.id
+        cfg = self.repo.get_config()
+        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
+        self.import_non_default_key()
+
+        sha = porcelain.commit(
+            self.repo.path,
+            message="Some message",
+            author="Joe <joe@example.com>",
+            committer="Bob <bob@example.com>",
+            signoff=PorcelainGpgTestCase.NON_DEFAULT_KEY_ID,
+        )
+        self.assertIsInstance(sha, bytes)
+        self.assertEqual(len(sha), 40)
+
+        commit = self.repo.get_object(sha)
+        # GPG Signatures aren't deterministic, so we can't do a static assertion.
+        commit.verify()
+
+
 class CleanTests(PorcelainTestCase):
     def put_files(self, tracked, ignored, untracked, empty_dirs):
         """Put the described files in the wd"""