Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 58 additions & 16 deletions gitrevise/odb.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
import re
import sys
from collections import defaultdict
from contextlib import AbstractContextManager
from enum import Enum
from pathlib import Path
from subprocess import DEVNULL, PIPE, CalledProcessError, Popen, run
from tempfile import TemporaryDirectory
from tempfile import NamedTemporaryFile, TemporaryDirectory
from types import TracebackType
from typing import (
TYPE_CHECKING,
Expand Down Expand Up @@ -328,25 +329,66 @@ def sign_buffer(self, buffer: bytes) -> bytes:
key_id = self.config(
"user.signingKey", default=self.default_committer.signing_key
)
gpg = None
try:
gpg = sh_run(
(self.gpg, "--status-fd=2", "-bsau", key_id),
stdout=PIPE,
stderr=PIPE,
input=buffer,
check=True,
signer = None
if self.config("gpg.format", "gpg") == b"ssh":
program = self.config("gpg.ssh.program", b"ssh-keygen")
is_literal_ssh_key = key_id.startswith(b"ssh-") or key_id.startswith(
b"key::"
)
except CalledProcessError as gpg:
print(gpg.stderr.decode(), file=sys.stderr, end="")
print("gpg failed to sign commit", file=sys.stderr)
raise
if is_literal_ssh_key and key_id.startswith(b"key::"):
key_id = key_id[5:]
if is_literal_ssh_key:
key_file_context_manager: AbstractContextManager = (
NamedTemporaryFile( # pylint: disable=consider-using-with
prefix=".git_signing_key_tmp"
)
)
else:
key_file_context_manager = open(key_id, "rb")
with key_file_context_manager as key_file:
if is_literal_ssh_key:
key_file.write(key_id)
key_file.flush()
key_id = key_file.name.encode("utf-8")
try:
args = [program, "-Y", "sign", "-n", "git", "-f", key_id]
if is_literal_ssh_key:
args.append("-U")
signer = sh_run(
args, stdout=PIPE, stderr=PIPE, input=buffer, check=True
)
except CalledProcessError as ssh:
e = ssh.stderr.decode()
print(e, file=sys.stderr, end="")
print(f"{program.decode()} failed to sign commit", file=sys.stderr)
if "usage:" in e:
print(
(
"ssh-keygen -Y sign is needed for ssh signing "
"(available in openssh version 8.2p1+)"
),
file=sys.stderr,
)
raise
else:
try:
signer = sh_run(
(self.gpg, "--status-fd=2", "-bsau", key_id),
stdout=PIPE,
stderr=PIPE,
input=buffer,
check=True,
)
except CalledProcessError as gpg:
print(gpg.stderr.decode(), file=sys.stderr, end="")
print("gpg failed to sign commit", file=sys.stderr)
raise

if b"\n[GNUPG:] SIG_CREATED " not in gpg.stderr:
raise GPGSignError(gpg.stderr.decode())
if b"\n[GNUPG:] SIG_CREATED " not in signer.stderr:
raise GPGSignError(signer.stderr.decode())

signature = b"gpgsig"
for line in gpg.stdout.splitlines():
for line in signer.stdout.splitlines():
signature += b" " + line + b"\n"
return signature

Expand Down
112 changes: 112 additions & 0 deletions tests/test_sshsign.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from pathlib import Path
from subprocess import CalledProcessError
from typing import Generator

import pytest

from gitrevise.odb import Repository
from gitrevise.utils import sh_run

from .conftest import bash, main


@pytest.fixture(scope="function", name="ssh_private_key_path")
def fixture_ssh_private_key_path(short_tmpdir: Path) -> Generator[Path, None, None]:
"""
Creates an SSH key and registers it with ssh-agent. De-registers it during cleanup.
Yields the Path to the private key file. The corresponding public key file is that path
with suffix ".pub".
"""
short_tmpdir.chmod(0o700)
private_key_path = short_tmpdir / "test_sshsign"
sh_run(
[
"ssh-keygen",
"-q",
"-N",
"",
"-f",
private_key_path.as_posix(),
"-C",
"git-revise: test_sshsign",
],
check=True,
)

assert private_key_path.is_file()
pub_key_path = private_key_path.with_suffix(".pub")
assert pub_key_path.is_file()

sh_run(["ssh-add", private_key_path.as_posix()], check=True)
yield private_key_path
sh_run(["ssh-add", "-d", private_key_path.as_posix()], check=True)


def test_sshsign(
repo: Repository,
ssh_private_key_path: Path,
) -> None:
def commit_has_ssh_signature(refspec: str) -> bool:
commit = repo.get_commit(refspec)
assert commit is not None
assert commit.gpgsig is not None
assert commit.gpgsig.startswith(b"-----BEGIN SSH SIGNATURE-----")
return True

bash("git commit --allow-empty -m 'commit 1'")
assert repo.get_commit("HEAD").gpgsig is None

bash("git config gpg.format ssh")
bash("git config commit.gpgSign true")

sh_run(
["git", "config", "user.signingKey", ssh_private_key_path.as_posix()],
check=True,
)
main(["HEAD"])
assert commit_has_ssh_signature("HEAD"), "can ssh sign given key as path"

pubkey = ssh_private_key_path.with_suffix(".pub").read_text().strip()
sh_run(
[
"git",
"config",
"user.signingKey",
pubkey,
],
check=True,
)
main(["HEAD"])
assert commit_has_ssh_signature("HEAD"), "can ssh sign given literal pubkey"

bash("git config gpg.ssh.program false")
try:
main(["HEAD", "--gpg-sign"])
assert False, "Overridden gpg.ssh.program should fail"
except CalledProcessError:
pass
bash("git config --unset gpg.ssh.program")

# Check that we can sign multiple commits.
bash(
"""
git -c commit.gpgSign=false commit --allow-empty -m 'commit 2'
git -c commit.gpgSign=false commit --allow-empty -m 'commit 3'
git -c commit.gpgSign=false commit --allow-empty -m 'commit 4'
"""
)
main(["HEAD~~", "--gpg-sign"])
assert commit_has_ssh_signature("HEAD~~")
assert commit_has_ssh_signature("HEAD~")
assert commit_has_ssh_signature("HEAD~")

# Check that we can remove signatures from multiple commits.
main(["HEAD~", "--no-gpg-sign"])
assert repo.get_commit("HEAD~").gpgsig is None
assert repo.get_commit("HEAD").gpgsig is None

# Check that we add signatures, even if the target commit already has one.
assert commit_has_ssh_signature("HEAD~~")
main(["HEAD~~", "--gpg-sign"])
assert commit_has_ssh_signature("HEAD~")
assert commit_has_ssh_signature("HEAD")
4 changes: 3 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ commands = pytest {posargs}
deps =
pytest ~= 7.1.2
pytest-xdist ~= 2.5.0
passenv = PROGRAMFILES* # to locate git-bash on windows
passenv =
PROGRAMFILES* # to locate git-bash on windows
SSH_AUTH_SOCK # to test signing when configured with literal ssh pubkey

[testenv:mypy]
description = typecheck with mypy
Expand Down