Skip to content
Merged
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,10 @@ The `signature` check verifies the commit without any local keyring setup:
`{username}@users.noreply.github.com`) — no API call needed.
3. If neither of the above resolves a username, fall back to searching GitHub
by the commit author's email.
4. Fetch the resolved user's public keys from `github.com/{username}.gpg` and
`github.com/{username}.keys`.
4. Fetch the resolved user's public keys from `github.com/{username}.gpg`
(GPG) and the `/users/{username}/ssh_signing_keys` API (SSH keys tagged
with the **Signing key** role). Auth-only SSH keys are deliberately not
accepted — this mirrors GitHub's "Verified" badge semantics.
5. Try GPG verification: import the fetched key into a temporary keyring and
run `git verify-commit`.
6. Try SSH verification: write a temporary `allowed_signers` file and run
Expand Down
7 changes: 5 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -464,8 +464,11 @@ <h3>Signature verification</h3>
<li>If neither of the above resolves a username, fall back to
searching GitHub by the commit author's email.</li>
<li>Fetch the resolved user's public keys from
<code>github.com/{username}.gpg</code> and
<code>github.com/{username}.keys</code>.</li>
<code>github.com/{username}.gpg</code> (GPG) and
<code>/users/{username}/ssh_signing_keys</code> (SSH keys tagged
with the <strong>Signing key</strong> role). Auth-only SSH keys
are deliberately not accepted — this mirrors GitHub's
&ldquo;Verified&rdquo; badge semantics.</li>
<li>Try GPG verification using a temporary keyring.</li>
<li>Try SSH verification using a temporary <code>allowed_signers</code> file.</li>
<li>Pass if any key verifies; fail if none do.</li>
Expand Down
21 changes: 19 additions & 2 deletions src/git_commit_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,9 +336,21 @@ def _fetch_url(url):
return resp.read().decode()


def _fetch_github_signing_keys(username):
url = f"https://api.github.com/users/{username}/ssh_signing_keys"
headers = {"Accept": "application/vnd.github+json"}
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers) # noqa: S310 Audit URL open for permitted schemes
with urllib.request.urlopen(req, timeout=_git_timeout()) as resp: # noqa: S310 Audit URL open for permitted schemes
data = json.loads(resp.read())
return "\n".join(item["key"] for item in data)


def _fetch_github_keys(username):
gpg = _fetch_url(f"https://github.com/{username}.gpg")
ssh = _fetch_url(f"https://github.com/{username}.keys")
ssh = _fetch_github_signing_keys(username)
return gpg.strip(), ssh.strip()


Expand Down Expand Up @@ -450,7 +462,12 @@ def check_signature(rev, result):
if _verify_ssh(rev, email, ssh_text):
result.info("signature type: SSH", check=Check.SIGNATURE)
return
result.error("commit is not signed (GPG/SSH)", check=Check.SIGNATURE)
result.error(
"signature could not be verified — commit may be unsigned, "
"or signed with a key not uploaded as a Signing key on "
"https://github.com/settings/keys",
check=Check.SIGNATURE,
)
except subprocess.TimeoutExpired:
result.error(
"git operation timed out — cannot verify signature",
Expand Down
64 changes: 62 additions & 2 deletions tests/test_git_commit_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
_ensure_nltk_data,
_fetch_github_commit_author,
_fetch_github_keys,
_fetch_github_signing_keys,
_fetch_github_username,
_fetch_url,
_get_author_email,
Expand Down Expand Up @@ -735,10 +736,69 @@ def mock_urlopen(req, **_):
assert captured[0].get_header("Authorization") == "Bearer ghtoken"


class TestFetchGithubSigningKeys:
def _mock_response(self, data):
mock_resp = MagicMock()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
mock_resp.read.return_value = json.dumps(data).encode()
return mock_resp

def test_returns_keys_joined_by_newline(self):
resp = self._mock_response(
[{"key": "ssh-ed25519 AAAA"}, {"key": "ssh-rsa BBBB"}]
)
with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
assert (
_fetch_github_signing_keys("testuser")
== "ssh-ed25519 AAAA\nssh-rsa BBBB"
)

def test_empty_list_returns_empty_string(self):
resp = self._mock_response([])
with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
assert _fetch_github_signing_keys("testuser") == ""

def test_github_token_sent_in_header(self):
resp = self._mock_response([])
captured = []

def mock_urlopen(req, **_):
captured.append(req)
return resp

with (
patch("git_commit_guard.urllib.request.urlopen", side_effect=mock_urlopen),
patch.dict("os.environ", {"GITHUB_TOKEN": "mytoken"}, clear=False),
):
_fetch_github_signing_keys("testuser")
assert captured[0].get_header("Authorization") == "Bearer mytoken"

def test_gh_token_used_when_github_token_absent(self):
resp = self._mock_response([])
captured = []

def mock_urlopen(req, **_):
captured.append(req)
return resp

env = {k: v for k, v in os.environ.items() if k != "GITHUB_TOKEN"}
env["GH_TOKEN"] = "ghtoken" # noqa: S105 Possible hardcoded password assigned to: "GH_TOKEN"
with (
patch("git_commit_guard.urllib.request.urlopen", side_effect=mock_urlopen),
patch.dict("os.environ", env, clear=True),
):
_fetch_github_signing_keys("testuser")
assert captured[0].get_header("Authorization") == "Bearer ghtoken"


class TestFetchGithubKeys:
def test_returns_gpg_and_ssh(self):
with patch(
"git_commit_guard._fetch_url", side_effect=["GPG KEY\n", "SSH KEY\n"]
with (
patch("git_commit_guard._fetch_url", return_value="GPG KEY\n"),
patch(
"git_commit_guard._fetch_github_signing_keys", return_value="SSH KEY\n"
),
):
gpg, ssh = _fetch_github_keys("testuser")
assert gpg == "GPG KEY"
Expand Down
Loading