Skip to content

Commit 8caa598

Browse files
committed
Merge mystor#136 (ssh signing)
2 parents bb46b22 + 310a601 commit 8caa598

File tree

3 files changed

+173
-17
lines changed

3 files changed

+173
-17
lines changed

gitrevise/odb.py

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
import re
1010
import sys
1111
from collections import defaultdict
12+
from contextlib import AbstractContextManager
1213
from enum import Enum
1314
from pathlib import Path
1415
from subprocess import DEVNULL, PIPE, CalledProcessError, Popen, run
15-
from tempfile import TemporaryDirectory
16+
from tempfile import NamedTemporaryFile, TemporaryDirectory
1617
from types import TracebackType
1718
from typing import (
1819
TYPE_CHECKING,
@@ -328,25 +329,66 @@ def sign_buffer(self, buffer: bytes) -> bytes:
328329
key_id = self.config(
329330
"user.signingKey", default=self.default_committer.signing_key
330331
)
331-
gpg = None
332-
try:
333-
gpg = sh_run(
334-
(self.gpg, "--status-fd=2", "-bsau", key_id),
335-
stdout=PIPE,
336-
stderr=PIPE,
337-
input=buffer,
338-
check=True,
332+
signer = None
333+
if self.config("gpg.format", "gpg") == b"ssh":
334+
program = self.config("gpg.ssh.program", b"ssh-keygen")
335+
is_literal_ssh_key = key_id.startswith(b"ssh-") or key_id.startswith(
336+
b"key::"
339337
)
340-
except CalledProcessError as gpg:
341-
print(gpg.stderr.decode(), file=sys.stderr, end="")
342-
print("gpg failed to sign commit", file=sys.stderr)
343-
raise
338+
if is_literal_ssh_key and key_id.startswith(b"key::"):
339+
key_id = key_id[5:]
340+
if is_literal_ssh_key:
341+
key_file_context_manager: AbstractContextManager = (
342+
NamedTemporaryFile( # pylint: disable=consider-using-with
343+
prefix=".git_signing_key_tmp"
344+
)
345+
)
346+
else:
347+
key_file_context_manager = open(key_id, "rb")
348+
with key_file_context_manager as key_file:
349+
if is_literal_ssh_key:
350+
key_file.write(key_id)
351+
key_file.flush()
352+
key_id = key_file.name.encode("utf-8")
353+
try:
354+
args = [program, "-Y", "sign", "-n", "git", "-f", key_id]
355+
if is_literal_ssh_key:
356+
args.append("-U")
357+
signer = sh_run(
358+
args, stdout=PIPE, stderr=PIPE, input=buffer, check=True
359+
)
360+
except CalledProcessError as ssh:
361+
e = ssh.stderr.decode()
362+
print(e, file=sys.stderr, end="")
363+
print(f"{program.decode()} failed to sign commit", file=sys.stderr)
364+
if "usage:" in e:
365+
print(
366+
(
367+
"ssh-keygen -Y sign is needed for ssh signing "
368+
"(available in openssh version 8.2p1+)"
369+
),
370+
file=sys.stderr,
371+
)
372+
raise
373+
else:
374+
try:
375+
signer = sh_run(
376+
(self.gpg, "--status-fd=2", "-bsau", key_id),
377+
stdout=PIPE,
378+
stderr=PIPE,
379+
input=buffer,
380+
check=True,
381+
)
382+
except CalledProcessError as gpg:
383+
print(gpg.stderr.decode(), file=sys.stderr, end="")
384+
print("gpg failed to sign commit", file=sys.stderr)
385+
raise
344386

345-
if b"\n[GNUPG:] SIG_CREATED " not in gpg.stderr:
346-
raise GPGSignError(gpg.stderr.decode())
387+
if b"\n[GNUPG:] SIG_CREATED " not in signer.stderr:
388+
raise GPGSignError(signer.stderr.decode())
347389

348390
signature = b"gpgsig"
349-
for line in gpg.stdout.splitlines():
391+
for line in signer.stdout.splitlines():
350392
signature += b" " + line + b"\n"
351393
return signature
352394

tests/test_sshsign.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from pathlib import Path
2+
from subprocess import CalledProcessError
3+
from typing import Generator
4+
5+
import pytest
6+
7+
from gitrevise.odb import Repository
8+
from gitrevise.utils import sh_run
9+
10+
from .conftest import bash, main
11+
12+
13+
@pytest.fixture(scope="function", name="ssh_private_key_path")
14+
def fixture_ssh_private_key_path(short_tmpdir: Path) -> Generator[Path, None, None]:
15+
"""
16+
Creates an SSH key and registers it with ssh-agent. De-registers it during cleanup.
17+
Yields the Path to the private key file. The corresponding public key file is that path
18+
with suffix ".pub".
19+
"""
20+
short_tmpdir.chmod(0o700)
21+
private_key_path = short_tmpdir / "test_sshsign"
22+
sh_run(
23+
[
24+
"ssh-keygen",
25+
"-q",
26+
"-N",
27+
"",
28+
"-f",
29+
private_key_path.as_posix(),
30+
"-C",
31+
"git-revise: test_sshsign",
32+
],
33+
check=True,
34+
)
35+
36+
assert private_key_path.is_file()
37+
pub_key_path = private_key_path.with_suffix(".pub")
38+
assert pub_key_path.is_file()
39+
40+
sh_run(["ssh-add", private_key_path.as_posix()], check=True)
41+
yield private_key_path
42+
sh_run(["ssh-add", "-d", private_key_path.as_posix()], check=True)
43+
44+
45+
def test_sshsign(
46+
repo: Repository,
47+
ssh_private_key_path: Path,
48+
) -> None:
49+
def commit_has_ssh_signature(refspec: str) -> bool:
50+
commit = repo.get_commit(refspec)
51+
assert commit is not None
52+
assert commit.gpgsig is not None
53+
assert commit.gpgsig.startswith(b"-----BEGIN SSH SIGNATURE-----")
54+
return True
55+
56+
bash("git commit --allow-empty -m 'commit 1'")
57+
assert repo.get_commit("HEAD").gpgsig is None
58+
59+
bash("git config gpg.format ssh")
60+
bash("git config commit.gpgSign true")
61+
62+
sh_run(
63+
["git", "config", "user.signingKey", ssh_private_key_path.as_posix()],
64+
check=True,
65+
)
66+
main(["HEAD"])
67+
assert commit_has_ssh_signature("HEAD"), "can ssh sign given key as path"
68+
69+
pubkey = ssh_private_key_path.with_suffix(".pub").read_text().strip()
70+
sh_run(
71+
[
72+
"git",
73+
"config",
74+
"user.signingKey",
75+
pubkey,
76+
],
77+
check=True,
78+
)
79+
main(["HEAD"])
80+
assert commit_has_ssh_signature("HEAD"), "can ssh sign given literal pubkey"
81+
82+
bash("git config gpg.ssh.program false")
83+
try:
84+
main(["HEAD", "--gpg-sign"])
85+
assert False, "Overridden gpg.ssh.program should fail"
86+
except CalledProcessError:
87+
pass
88+
bash("git config --unset gpg.ssh.program")
89+
90+
# Check that we can sign multiple commits.
91+
bash(
92+
"""
93+
git -c commit.gpgSign=false commit --allow-empty -m 'commit 2'
94+
git -c commit.gpgSign=false commit --allow-empty -m 'commit 3'
95+
git -c commit.gpgSign=false commit --allow-empty -m 'commit 4'
96+
"""
97+
)
98+
main(["HEAD~~", "--gpg-sign"])
99+
assert commit_has_ssh_signature("HEAD~~")
100+
assert commit_has_ssh_signature("HEAD~")
101+
assert commit_has_ssh_signature("HEAD~")
102+
103+
# Check that we can remove signatures from multiple commits.
104+
main(["HEAD~", "--no-gpg-sign"])
105+
assert repo.get_commit("HEAD~").gpgsig is None
106+
assert repo.get_commit("HEAD").gpgsig is None
107+
108+
# Check that we add signatures, even if the target commit already has one.
109+
assert commit_has_ssh_signature("HEAD~~")
110+
main(["HEAD~~", "--gpg-sign"])
111+
assert commit_has_ssh_signature("HEAD~")
112+
assert commit_has_ssh_signature("HEAD")

tox.ini

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ commands = pytest {posargs}
1818
deps =
1919
pytest ~= 7.1.2
2020
pytest-xdist ~= 2.5.0
21-
passenv = PROGRAMFILES* # to locate git-bash on windows
21+
passenv =
22+
PROGRAMFILES* # to locate git-bash on windows
23+
SSH_AUTH_SOCK # to test signing when configured with literal ssh pubkey
2224

2325
[testenv:mypy]
2426
description = typecheck with mypy

0 commit comments

Comments
 (0)