diff --git a/README.md b/README.md
index 7ade672..71109e6 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/index.html b/docs/index.html
index 95d053f..0a1cb92 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -464,8 +464,11 @@
Signature verification
If neither of the above resolves a username, fall back to
searching GitHub by the commit author's email.
Fetch the resolved user's public keys from
- github.com/{username}.gpg and
- github.com/{username}.keys.
+ github.com/{username}.gpg (GPG) and
+ /users/{username}/ssh_signing_keys (SSH keys tagged
+ with the Signing key role). Auth-only SSH keys
+ are deliberately not accepted — this mirrors GitHub's
+ “Verified” badge semantics.
Try GPG verification using a temporary keyring.
Try SSH verification using a temporary allowed_signers file.
Pass if any key verifies; fail if none do.
diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py
index 05a9975..99c048f 100644
--- a/src/git_commit_guard/__init__.py
+++ b/src/git_commit_guard/__init__.py
@@ -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()
@@ -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",
diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py
index 0c96f9e..73f7c16 100644
--- a/tests/test_git_commit_guard.py
+++ b/tests/test_git_commit_guard.py
@@ -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,
@@ -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"