Skip to content

Commit 9236005

Browse files
committed
check_signature + SignatureCriterion interface
* check_signature method for Commit/Tag as alternative to verify * SignatureCriterion interface to decouple crypto from git serialization * single simple InvalidSignature exception generic to whatever criterion * DRY re-impl for Commit/Tag verify using GpgSignatureCriterion * ssh-keygen based SignatureCriterion classes in contrib
1 parent d35b4e5 commit 9236005

File tree

2 files changed

+168
-36
lines changed

2 files changed

+168
-36
lines changed
+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Copyright (c) 2024 E. Castedo Ellerman <[email protected]>
2+
3+
# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
4+
# General Public License as public by the Free Software Foundation; version 2.0
5+
# or (at your option) any later version. You can redistribute it and/or
6+
# modify it under the terms of either of these two licenses.
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
#
14+
# You should have received a copy of the licenses; if not, see
15+
# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
16+
# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
17+
# License, Version 2.0.
18+
# fmt: off
19+
20+
import subprocess
21+
import tempfile
22+
from datetime import datetime, timezone
23+
from pathlib import Path
24+
25+
from ..objects import InvalidSignature, SignatureCriterion
26+
27+
# See the following C git implementation code for more details:
28+
# https://archive.softwareheritage.org/swh:1:cnt:07335987a6b9ceaf6edc2da71c2e636b0513372f;origin=https://github.com/git/git;visit=swh:1:snp:e72051ba1b2437b7bf3ed0346d04b289f1393982;anchor=swh:1:rev:6a11438f43469f3815f2f0fc997bd45792ff04c0;path=/gpg-interface.c;lines=450
29+
30+
### WARNING!
31+
### verify_time might or might not be in UTC.
32+
### The following code might not be handling timezone correctly.
33+
34+
35+
class SshKeygenCheckCriterion(SignatureCriterion):
36+
"""Checks signature using ssh-keygen -Y check-novalidate."""
37+
38+
def __init__(self, capture_output: bool = True):
39+
self.capture_output = capture_output
40+
41+
def _ssh_keygen_check(
42+
self, subcmdline: list[str], crypto_msg: bytes, verify_time: int
43+
) -> None:
44+
verify_dt = datetime.fromtimestamp(verify_time, tz=timezone.utc)
45+
cmdline = [
46+
*subcmdline,
47+
"-n", "git",
48+
"-O", "verify-time=" + verify_dt.strftime("%Y%m%d%H%M%SZ"),
49+
]
50+
result = subprocess.run(
51+
cmdline, input=crypto_msg, capture_output=self.capture_output
52+
)
53+
if 0 != result.returncode:
54+
raise InvalidSignature
55+
56+
def check(self, crypto_msg: bytes, signature: bytes, verify_time: int) -> None:
57+
with tempfile.NamedTemporaryFile() as sig_file:
58+
sig_file.write(signature)
59+
sig_file.flush()
60+
subcmdline = ["ssh-keygen", "-Y", "check-novalidate", "-s", sig_file.name]
61+
self._ssh_keygen_check(subcmdline, crypto_msg, verify_time)
62+
63+
64+
class SshKeygenVerifyCriterion(SshKeygenCheckCriterion):
65+
"""Verifies signature using ssh-keygen -Y verify."""
66+
67+
def __init__(self, allowed_signers: Path, capture_output: bool = True):
68+
super().__init__(capture_output)
69+
self.allowed_signers = str(allowed_signers)
70+
71+
def check(self, crypto_msg: bytes, signature: bytes, verify_time: int) -> None:
72+
with tempfile.NamedTemporaryFile() as sig_file:
73+
sig_file.write(signature)
74+
sig_file.flush()
75+
cmdline = [
76+
"ssh-keygen", "-Y", "find-principals",
77+
"-s", sig_file.name,
78+
"-f", self.allowed_signers,
79+
]
80+
result = subprocess.run(cmdline, capture_output=True)
81+
for principal in result.stdout.splitlines():
82+
subcmdline = [
83+
"ssh-keygen", "-Y", "verify",
84+
"-f", self.allowed_signers,
85+
"-I", str(principal),
86+
"-s", sig_file.name,
87+
]
88+
self._ssh_keygen_check(subcmdline, crypto_msg, verify_time)
89+
90+
#ruff: noqa: I001
91+
92+
if __name__ == "__main__":
93+
import argparse
94+
import dulwich.repo
95+
96+
parser = argparse.ArgumentParser()
97+
parser.add_argument("git_object", default="HEAD", nargs="?")
98+
parser.add_argument("--allow", type=Path, help="ssh-keygen allowed signers file")
99+
args = parser.parse_args()
100+
101+
if args.allow is None:
102+
criterion = SshKeygenCheckCriterion(capture_output=False)
103+
else:
104+
criterion = SshKeygenVerifyCriterion(args.allow, capture_output=False)
105+
106+
repo = dulwich.repo.Repo(".")
107+
commit = repo[args.git_object.encode()]
108+
print("commit", commit.id.decode())
109+
try:
110+
commit.check_signature(criterion)
111+
# signature good or not signed
112+
except InvalidSignature:
113+
pass
114+
print("Author:", commit.author.decode())
115+
print("\n ", commit.message.decode())

dulwich/objects.py

+53-36
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,19 @@ class EmptyFileException(FileFormatException):
8585
"""An unexpectedly empty file was encountered."""
8686

8787

88+
class InvalidSignature(Exception):
89+
"""A signature was rejected by a signature criterion."""
90+
91+
92+
class SignatureCriterion:
93+
def check(self, crypto_msg: bytes, signature: bytes, verify_time: int) -> None:
94+
"""Check/verify signature for a cryptographic message.
95+
96+
Raises:
97+
InvalidSignature
98+
"""
99+
100+
88101
def S_ISGITLINK(m):
89102
"""Check if a mode indicates a submodule.
90103
@@ -927,6 +940,10 @@ def raw_without_sig(self) -> bytes:
927940
ret = ret[: -len(self._signature)]
928941
return ret
929942

943+
def check_signature(self, criterion: SignatureCriterion) -> None:
944+
if self.signature:
945+
criterion.check(self.raw_without_sig(), self.signature, self.tag_time)
946+
930947
def verify(self, keyids: Optional[Iterable[str]] = None) -> None:
931948
"""Verify GPG signature for this tag (if it is signed).
932949
@@ -941,24 +958,10 @@ def verify(self, keyids: Optional[Iterable[str]] = None) -> None:
941958
gpg.errors.MissingSignatures: if tag was not signed by a key
942959
specified in keyids
943960
"""
944-
if self._signature is None:
945-
return
946-
947-
import gpg
948-
949-
with gpg.Context() as ctx:
950-
data, result = ctx.verify(
951-
self.raw_without_sig(),
952-
signature=self._signature,
953-
)
954-
if keyids:
955-
keys = [ctx.get_key(key) for key in keyids]
956-
for key in keys:
957-
for subkey in keys:
958-
for sig in result.signatures:
959-
if subkey.can_sign and subkey.fpr == sig.fpr:
960-
return
961-
raise gpg.errors.MissingSignatures(result, keys, results=(data, result))
961+
try:
962+
self.check_signature(GpgSignatureCriterion(keyids))
963+
except InvalidSignature as ex:
964+
raise ex.__cause__ from None # type: ignore[misc]
962965

963966

964967
class TreeEntry(namedtuple("TreeEntry", ["path", "mode", "sha"])):
@@ -1531,6 +1534,10 @@ def raw_without_sig(self) -> bytes:
15311534
tmp.gpgsig = None
15321535
return tmp.as_raw_string()
15331536

1537+
def check_signature(self, criterion: SignatureCriterion) -> None:
1538+
if self.gpgsig:
1539+
criterion.check(self.raw_without_sig(), self.gpgsig, self.commit_time)
1540+
15341541
def verify(self, keyids: Optional[Iterable[str]] = None) -> None:
15351542
"""Verify GPG signature for this commit (if it is signed).
15361543
@@ -1545,24 +1552,10 @@ def verify(self, keyids: Optional[Iterable[str]] = None) -> None:
15451552
gpg.errors.MissingSignatures: if commit was not signed by a key
15461553
specified in keyids
15471554
"""
1548-
if self._gpgsig is None:
1549-
return
1550-
1551-
import gpg
1552-
1553-
with gpg.Context() as ctx:
1554-
data, result = ctx.verify(
1555-
self.raw_without_sig(),
1556-
signature=self._gpgsig,
1557-
)
1558-
if keyids:
1559-
keys = [ctx.get_key(key) for key in keyids]
1560-
for key in keys:
1561-
for subkey in keys:
1562-
for sig in result.signatures:
1563-
if subkey.can_sign and subkey.fpr == sig.fpr:
1564-
return
1565-
raise gpg.errors.MissingSignatures(result, keys, results=(data, result))
1555+
try:
1556+
self.check_signature(GpgSignatureCriterion(keyids))
1557+
except InvalidSignature as ex:
1558+
raise ex.__cause__ from None # type: ignore[misc]
15661559

15671560
def _serialize(self):
15681561
headers = []
@@ -1681,6 +1674,30 @@ def _get_extra(self):
16811674
_TYPE_MAP[cls.type_num] = cls
16821675

16831676

1677+
class GpgSignatureCriterion(SignatureCriterion):
1678+
"""Verifies GPG signature."""
1679+
1680+
def __init__(self, keyids: Optional[Iterable[str]] = None):
1681+
self.keyids = keyids
1682+
1683+
def check(self, crypto_msg: bytes, signature: bytes, verify_time: int) -> None:
1684+
import gpg
1685+
1686+
with gpg.Context() as ctx:
1687+
try:
1688+
data, result = ctx.verify(crypto_msg, signature=signature)
1689+
except gpg.errors.BadSignatures as ex:
1690+
raise InvalidSignature from ex
1691+
if self.keyids is not None:
1692+
keys = [ctx.get_key(keyid) for keyid in self.keyids]
1693+
for key in keys:
1694+
for sig in result.signatures:
1695+
if key.can_sign and key.fpr == sig.fpr:
1696+
return
1697+
ex2 = gpg.errors.MissingSignatures(result, keys, results=(data, result))
1698+
raise InvalidSignature from ex2
1699+
1700+
16841701
# Hold on to the pure-python implementations for testing
16851702
_parse_tree_py = parse_tree
16861703
_sorted_tree_items_py = sorted_tree_items

0 commit comments

Comments
 (0)