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"