pytest-gpu-proof 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_gpu_proof/__init__.py +1 -0
- pytest_gpu_proof/__main__.py +3 -0
- pytest_gpu_proof/cli.py +95 -0
- pytest_gpu_proof/compare.py +49 -0
- pytest_gpu_proof/config.py +82 -0
- pytest_gpu_proof/fingerprint.py +38 -0
- pytest_gpu_proof/gitutils.py +112 -0
- pytest_gpu_proof/plugin.py +331 -0
- pytest_gpu_proof/receipt.py +143 -0
- pytest_gpu_proof/signers/__init__.py +0 -0
- pytest_gpu_proof/signers/base.py +20 -0
- pytest_gpu_proof/signers/ed25519.py +178 -0
- pytest_gpu_proof/verify.py +339 -0
- pytest_gpu_proof-0.1.0.dist-info/METADATA +400 -0
- pytest_gpu_proof-0.1.0.dist-info/RECORD +18 -0
- pytest_gpu_proof-0.1.0.dist-info/WHEEL +4 -0
- pytest_gpu_proof-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_gpu_proof-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pytest plugin entry point.
|
|
3
|
+
|
|
4
|
+
Registers CLI options, markers, and the gpu_proof_check fixture.
|
|
5
|
+
At session end, builds a signed receipt for every test that used the fixture.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
import warnings
|
|
10
|
+
from typing import Any, Dict, List
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from .compare import run_comparison
|
|
15
|
+
from .config import GpuProofConfig, load_config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _utcnow() -> str:
|
|
19
|
+
return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _has_gpu() -> bool:
|
|
23
|
+
import subprocess
|
|
24
|
+
try:
|
|
25
|
+
r = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
|
|
26
|
+
if r.returncode == 0:
|
|
27
|
+
return True
|
|
28
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
29
|
+
pass
|
|
30
|
+
try:
|
|
31
|
+
import torch
|
|
32
|
+
return torch.cuda.is_available()
|
|
33
|
+
except ImportError:
|
|
34
|
+
pass
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GpuProofPlugin:
|
|
39
|
+
def __init__(self, pytest_config):
|
|
40
|
+
self.gpu_proof_config: GpuProofConfig = load_config(pytest_config)
|
|
41
|
+
self.test_results: List[Dict[str, Any]] = []
|
|
42
|
+
self.skipped_required: List[str] = []
|
|
43
|
+
self.started_at: str = ""
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# session lifecycle
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def pytest_sessionstart(self, session):
|
|
50
|
+
self.started_at = _utcnow()
|
|
51
|
+
|
|
52
|
+
def pytest_sessionfinish(self, session, exitstatus):
|
|
53
|
+
if not self.gpu_proof_config.enabled:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
skipped_marked = [
|
|
57
|
+
t["node_id"] for t in self.test_results if t.get("outcome") == "skipped"
|
|
58
|
+
]
|
|
59
|
+
if self.gpu_proof_config.fail_on_skip and (skipped_marked or self.skipped_required):
|
|
60
|
+
names = sorted(set(skipped_marked + self.skipped_required))
|
|
61
|
+
print(
|
|
62
|
+
"\n[gpu-proof] --gpu-proof-fail-on-skip: "
|
|
63
|
+
f"{len(names)} marked test(s) were skipped:\n"
|
|
64
|
+
+ "".join(f" - {n}\n" for n in names)
|
|
65
|
+
+ " No receipt was written; session marked as failed."
|
|
66
|
+
)
|
|
67
|
+
session.exitstatus = 1
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
if not self.test_results:
|
|
71
|
+
print(
|
|
72
|
+
"\n[gpu-proof] --gpu-proof-enable is set but no gpu_proof tests were found.\n"
|
|
73
|
+
" Add @pytest.mark.gpu_proof to your tests or use the gpu_proof_check fixture.\n"
|
|
74
|
+
" No receipt was written.\n"
|
|
75
|
+
" Tip: try 'pytest examples/minimal_python_only/test_minimal.py --gpu-proof-enable -v'"
|
|
76
|
+
)
|
|
77
|
+
return
|
|
78
|
+
self._emit_receipt()
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# result collection
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def _is_marked(self, item) -> bool:
|
|
85
|
+
return bool(
|
|
86
|
+
item.get_closest_marker(self.gpu_proof_config.required_marker)
|
|
87
|
+
or item.get_closest_marker("gpu_equivalence")
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
91
|
+
def pytest_runtest_makereport(self, item, call):
|
|
92
|
+
outcome = yield
|
|
93
|
+
report = outcome.get_result()
|
|
94
|
+
|
|
95
|
+
if call.when == "setup" and report.skipped:
|
|
96
|
+
# Skipped tests never reach the "call" phase — record them here so
|
|
97
|
+
# they are visible in the receipt (and to --gpu-proof-fail-on-skip)
|
|
98
|
+
# instead of being silently dropped.
|
|
99
|
+
if item.get_closest_marker("gpu_required") and not self._is_marked(item):
|
|
100
|
+
self.skipped_required.append(item.nodeid)
|
|
101
|
+
if self._is_marked(item):
|
|
102
|
+
self.test_results.append(
|
|
103
|
+
{
|
|
104
|
+
"node_id": item.nodeid,
|
|
105
|
+
"outcome": "skipped",
|
|
106
|
+
"duration_s": round(call.duration, 4),
|
|
107
|
+
"checks": [],
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if call.when == "teardown":
|
|
113
|
+
# The gpu_proof_check fixture records its checks during fixture
|
|
114
|
+
# teardown, which happens *after* the call-phase report. Patch the
|
|
115
|
+
# already-recorded entry here so checks are not silently dropped.
|
|
116
|
+
checks = getattr(item, "_gpu_proof_checks", None)
|
|
117
|
+
if checks is not None:
|
|
118
|
+
for t in reversed(self.test_results):
|
|
119
|
+
if t["node_id"] == item.nodeid:
|
|
120
|
+
t["checks"] = checks
|
|
121
|
+
break
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
if call.when != "call":
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
uses_fixture = "gpu_proof_check" in getattr(item, "fixturenames", ())
|
|
128
|
+
if not uses_fixture and not self._is_marked(item):
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if report.passed:
|
|
132
|
+
outcome_str = "passed"
|
|
133
|
+
elif report.skipped:
|
|
134
|
+
outcome_str = "skipped"
|
|
135
|
+
else:
|
|
136
|
+
outcome_str = "failed"
|
|
137
|
+
|
|
138
|
+
self.test_results.append(
|
|
139
|
+
{
|
|
140
|
+
"node_id": item.nodeid,
|
|
141
|
+
"outcome": outcome_str,
|
|
142
|
+
"duration_s": round(call.duration, 4),
|
|
143
|
+
"checks": [], # filled in at teardown once the fixture finalizes
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# receipt emission
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def _emit_receipt(self):
|
|
152
|
+
from .receipt import build_receipt_payload, finalize_receipt, write_receipt
|
|
153
|
+
from .signers.ed25519 import SSHSigner
|
|
154
|
+
from .signers.base import VerifierError
|
|
155
|
+
|
|
156
|
+
ended_at = _utcnow()
|
|
157
|
+
cfg = self.gpu_proof_config
|
|
158
|
+
|
|
159
|
+
if cfg.signing_backend == "none":
|
|
160
|
+
try:
|
|
161
|
+
payload = build_receipt_payload(cfg, self.test_results, self.started_at, ended_at)
|
|
162
|
+
receipt = dict(payload)
|
|
163
|
+
receipt["signature"] = None
|
|
164
|
+
write_receipt(receipt, cfg.output)
|
|
165
|
+
print(f"\n[gpu-proof] Receipt written to {cfg.output}")
|
|
166
|
+
print(
|
|
167
|
+
"[gpu-proof] WARNING: signing backend is 'none' — the receipt is UNSIGNED\n"
|
|
168
|
+
" and will fail verification unless --allow-unsigned is passed."
|
|
169
|
+
)
|
|
170
|
+
except Exception as e:
|
|
171
|
+
warnings.warn(f"[gpu-proof] Failed to write receipt: {e}", stacklevel=1)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
signer = SSHSigner(key_path=cfg.key_path)
|
|
176
|
+
except VerifierError as e:
|
|
177
|
+
warnings.warn(f"[gpu-proof] Signing skipped: {e}", stacklevel=1)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
payload = build_receipt_payload(cfg, self.test_results, self.started_at, ended_at)
|
|
182
|
+
receipt = finalize_receipt(payload, signer)
|
|
183
|
+
write_receipt(receipt, cfg.output)
|
|
184
|
+
print(f"\n[gpu-proof] Receipt written to {cfg.output}")
|
|
185
|
+
print(f"[gpu-proof] Signed with key {signer.key_fingerprint()}")
|
|
186
|
+
except Exception as e:
|
|
187
|
+
warnings.warn(f"[gpu-proof] Failed to write receipt: {e}", stacklevel=1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
# plugin registration hooks (module-level, always active)
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def pytest_addoption(parser):
|
|
195
|
+
group = parser.getgroup("gpu-proof", "GPU proof receipt generation")
|
|
196
|
+
group.addoption(
|
|
197
|
+
"--gpu-proof-enable",
|
|
198
|
+
action="store_true",
|
|
199
|
+
default=False,
|
|
200
|
+
help="Enable GPU proof receipt generation",
|
|
201
|
+
)
|
|
202
|
+
group.addoption(
|
|
203
|
+
"--gpu-proof-mode",
|
|
204
|
+
default="local",
|
|
205
|
+
choices=["local", "ci-gpu"],
|
|
206
|
+
help="Execution mode: local (default) or ci-gpu",
|
|
207
|
+
)
|
|
208
|
+
group.addoption(
|
|
209
|
+
"--gpu-proof-out",
|
|
210
|
+
default="gpu-proof.json",
|
|
211
|
+
metavar="PATH",
|
|
212
|
+
help="Output path for the receipt JSON (default: gpu-proof.json)",
|
|
213
|
+
)
|
|
214
|
+
group.addoption(
|
|
215
|
+
"--gpu-proof-key",
|
|
216
|
+
default=None,
|
|
217
|
+
metavar="PATH",
|
|
218
|
+
help="Path to SSH private key (default: auto-discover from git config or ~/.ssh/)",
|
|
219
|
+
)
|
|
220
|
+
group.addoption(
|
|
221
|
+
"--gpu-proof-signing-backend",
|
|
222
|
+
default="ed25519",
|
|
223
|
+
choices=["ed25519", "none"],
|
|
224
|
+
help="Signing backend (default: ed25519 via SSH key)",
|
|
225
|
+
)
|
|
226
|
+
group.addoption(
|
|
227
|
+
"--gpu-proof-policy",
|
|
228
|
+
default=None,
|
|
229
|
+
metavar="PATH",
|
|
230
|
+
help="Path to verification policy YAML",
|
|
231
|
+
)
|
|
232
|
+
group.addoption(
|
|
233
|
+
"--gpu-proof-required-marker",
|
|
234
|
+
default="gpu_proof",
|
|
235
|
+
help="Marker name that flags a test for the receipt (default: gpu_proof)",
|
|
236
|
+
)
|
|
237
|
+
group.addoption(
|
|
238
|
+
"--gpu-proof-fail-on-skip",
|
|
239
|
+
action="store_true",
|
|
240
|
+
default=False,
|
|
241
|
+
help="Fail the session if any gpu_required test is skipped",
|
|
242
|
+
)
|
|
243
|
+
group.addoption(
|
|
244
|
+
"--gpu-proof-fingerprint-paths",
|
|
245
|
+
default="src,tests",
|
|
246
|
+
metavar="PATHS",
|
|
247
|
+
help="Comma-separated paths to fingerprint (default: src,tests)",
|
|
248
|
+
)
|
|
249
|
+
group.addoption(
|
|
250
|
+
"--gpu-proof-github-user",
|
|
251
|
+
default=None,
|
|
252
|
+
metavar="USERNAME",
|
|
253
|
+
help="GitHub username of the signer (default: auto-detect from git remote)",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def pytest_configure(config):
|
|
258
|
+
config.addinivalue_line("markers", "gpu_proof: include test in GPU proof receipt")
|
|
259
|
+
config.addinivalue_line("markers", "gpu_required: skip test if no GPU is available")
|
|
260
|
+
config.addinivalue_line(
|
|
261
|
+
"markers",
|
|
262
|
+
"gpu_equivalence: alias for gpu_proof; marks a GPU equivalence check",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
enabled = config.getoption("--gpu-proof-enable")
|
|
267
|
+
except ValueError:
|
|
268
|
+
enabled = False
|
|
269
|
+
|
|
270
|
+
if enabled:
|
|
271
|
+
plugin = GpuProofPlugin(config)
|
|
272
|
+
config.pluginmanager.register(plugin, "gpu-proof-plugin")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def pytest_collection_modifyitems(config, items):
|
|
276
|
+
try:
|
|
277
|
+
enabled = config.getoption("--gpu-proof-enable")
|
|
278
|
+
except ValueError:
|
|
279
|
+
enabled = False
|
|
280
|
+
|
|
281
|
+
skip_no_gpu = pytest.mark.skip(reason="No GPU available (gpu_required marker)")
|
|
282
|
+
has_gpu = None # lazy
|
|
283
|
+
|
|
284
|
+
for item in items:
|
|
285
|
+
if item.get_closest_marker("gpu_required"):
|
|
286
|
+
if has_gpu is None:
|
|
287
|
+
has_gpu = _has_gpu()
|
|
288
|
+
if not has_gpu:
|
|
289
|
+
item.add_marker(skip_no_gpu)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# fixture
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
@pytest.fixture
|
|
297
|
+
def gpu_proof_check(request):
|
|
298
|
+
"""
|
|
299
|
+
Fixture for GPU equivalence checking.
|
|
300
|
+
|
|
301
|
+
Usage::
|
|
302
|
+
|
|
303
|
+
def test_rnea(gpu_proof_check):
|
|
304
|
+
gpu_proof_check(
|
|
305
|
+
name="rnea",
|
|
306
|
+
reference=python_ref,
|
|
307
|
+
candidate=cuda_fn,
|
|
308
|
+
args=(q, qd, qdd),
|
|
309
|
+
compare=my_compare_fn, # optional; default: numpy.allclose / ==
|
|
310
|
+
metadata={"robot": "go2"},
|
|
311
|
+
)
|
|
312
|
+
"""
|
|
313
|
+
checks = []
|
|
314
|
+
|
|
315
|
+
def check(name, reference, candidate, args=(), kwargs=None, compare=None, metadata=None):
|
|
316
|
+
outcome, _ref, _cand, error = run_comparison(
|
|
317
|
+
reference, candidate, args or (), kwargs or {}, compare
|
|
318
|
+
)
|
|
319
|
+
checks.append(
|
|
320
|
+
{
|
|
321
|
+
"name": name,
|
|
322
|
+
"outcome": outcome,
|
|
323
|
+
"metadata": metadata or {},
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
if outcome != "passed":
|
|
327
|
+
pytest.fail(f"gpu_proof_check {name!r}: {error}")
|
|
328
|
+
|
|
329
|
+
yield check
|
|
330
|
+
|
|
331
|
+
request.node._gpu_proof_checks = checks
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Build, sign, and write the JSON receipt artifact.
|
|
3
|
+
|
|
4
|
+
Signing covers the canonical (compact, sorted-key) JSON of the receipt without
|
|
5
|
+
the signature field. The signature is then embedded as receipt["signature"].
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import datetime
|
|
10
|
+
import json
|
|
11
|
+
import platform
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from .gitutils import (
|
|
17
|
+
get_branch,
|
|
18
|
+
get_commit_sha,
|
|
19
|
+
get_gh_cli_login,
|
|
20
|
+
get_github_username,
|
|
21
|
+
get_remote_url,
|
|
22
|
+
is_dirty,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _utcnow() -> str:
|
|
27
|
+
return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _gpu_info() -> Optional[Dict[str, Any]]:
|
|
31
|
+
try:
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
[
|
|
34
|
+
"nvidia-smi",
|
|
35
|
+
"--query-gpu=name,driver_version,memory.total",
|
|
36
|
+
"--format=csv,noheader",
|
|
37
|
+
],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
timeout=10,
|
|
41
|
+
)
|
|
42
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
43
|
+
parts = [p.strip() for p in result.stdout.strip().split(",")]
|
|
44
|
+
return {
|
|
45
|
+
"name": parts[0] if len(parts) > 0 else None,
|
|
46
|
+
"driver_version": parts[1] if len(parts) > 1 else None,
|
|
47
|
+
"memory": parts[2] if len(parts) > 2 else None,
|
|
48
|
+
}
|
|
49
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
50
|
+
pass
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _env_info() -> Dict[str, Any]:
|
|
55
|
+
try:
|
|
56
|
+
import pytest
|
|
57
|
+
pytest_version = pytest.__version__
|
|
58
|
+
except ImportError:
|
|
59
|
+
pytest_version = None
|
|
60
|
+
|
|
61
|
+
from pytest_gpu_proof import __version__
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
"python_version": sys.version.split()[0],
|
|
65
|
+
"platform": platform.system().lower(),
|
|
66
|
+
"pytest_version": pytest_version,
|
|
67
|
+
"plugin_version": __version__,
|
|
68
|
+
"gpu_info": _gpu_info(),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def canonicalize(receipt_dict: dict) -> bytes:
|
|
73
|
+
return json.dumps(receipt_dict, sort_keys=True, separators=(",", ":")).encode()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_receipt_payload(
|
|
77
|
+
config,
|
|
78
|
+
test_results: List[dict],
|
|
79
|
+
started_at: str,
|
|
80
|
+
ended_at: str,
|
|
81
|
+
override_github_username: Optional[str] = None,
|
|
82
|
+
) -> dict:
|
|
83
|
+
from .fingerprint import compute_fingerprint
|
|
84
|
+
|
|
85
|
+
remote_url = get_remote_url()
|
|
86
|
+
# Signer resolution: explicit override > [tool.gpu_proof]/flag config >
|
|
87
|
+
# authenticated gh CLI login (the actual keyholder) > origin-remote owner.
|
|
88
|
+
# The last is only a guess — for org-owned repos it yields the ORG, which
|
|
89
|
+
# has no SSH keys, so verification would fail; warn when we land there.
|
|
90
|
+
github_username = override_github_username or config.github_username
|
|
91
|
+
if not github_username:
|
|
92
|
+
github_username = get_gh_cli_login()
|
|
93
|
+
if not github_username:
|
|
94
|
+
github_username = get_github_username()
|
|
95
|
+
if github_username:
|
|
96
|
+
print(
|
|
97
|
+
f"[gpu-proof] WARNING: signer '{github_username}' was derived "
|
|
98
|
+
"from the origin remote owner, which may be an org with no SSH "
|
|
99
|
+
"keys. If verification fails, set --gpu-proof-github-user or "
|
|
100
|
+
"github_username in [tool.gpu_proof] to the keyholder."
|
|
101
|
+
)
|
|
102
|
+
fingerprint = compute_fingerprint(config.fingerprint_paths)
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"schema_version": "1",
|
|
106
|
+
"mode": config.mode,
|
|
107
|
+
"repo": {
|
|
108
|
+
"remote_url": remote_url,
|
|
109
|
+
"github_username": github_username,
|
|
110
|
+
"commit_sha": get_commit_sha(),
|
|
111
|
+
"branch": get_branch(),
|
|
112
|
+
"dirty": is_dirty(),
|
|
113
|
+
},
|
|
114
|
+
"fingerprint": fingerprint,
|
|
115
|
+
"session": {
|
|
116
|
+
"started_at": started_at,
|
|
117
|
+
"ended_at": ended_at,
|
|
118
|
+
"node_ids": [t["node_id"] for t in test_results],
|
|
119
|
+
},
|
|
120
|
+
"tests": test_results,
|
|
121
|
+
"environment": _env_info(),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def finalize_receipt(payload: dict, signer) -> dict:
|
|
126
|
+
data = canonicalize(payload)
|
|
127
|
+
sig_bytes = signer.sign(data)
|
|
128
|
+
|
|
129
|
+
receipt = dict(payload)
|
|
130
|
+
receipt["signature"] = {
|
|
131
|
+
"algorithm": signer.algorithm(),
|
|
132
|
+
"backend": "ssh-local",
|
|
133
|
+
"signer": payload["repo"].get("github_username") or "unknown",
|
|
134
|
+
"key_fingerprint": signer.key_fingerprint(),
|
|
135
|
+
"value": base64.b64encode(sig_bytes).decode(),
|
|
136
|
+
}
|
|
137
|
+
return receipt
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def write_receipt(receipt: dict, path: str) -> None:
|
|
141
|
+
with open(path, "w") as f:
|
|
142
|
+
json.dump(receipt, f, indent=2, sort_keys=True)
|
|
143
|
+
f.write("\n")
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SignerBase(ABC):
|
|
5
|
+
@abstractmethod
|
|
6
|
+
def sign(self, data: bytes) -> bytes:
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def key_fingerprint(self) -> str:
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def algorithm(self) -> str:
|
|
15
|
+
"""Name of the signature algorithm actually used (e.g. 'ed25519')."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VerifierError(Exception):
|
|
20
|
+
pass
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSH-key-based signing and GitHub-key-based verification.
|
|
3
|
+
|
|
4
|
+
Signing uses the developer's existing SSH private key (the same key they use
|
|
5
|
+
to push to GitHub). Verification fetches the signer's public keys from
|
|
6
|
+
https://github.com/{username}.keys — no separate key distribution step needed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
from urllib.request import urlopen
|
|
15
|
+
|
|
16
|
+
from cryptography.exceptions import InvalidSignature
|
|
17
|
+
from cryptography.hazmat.primitives.asymmetric.ec import (
|
|
18
|
+
ECDSA,
|
|
19
|
+
EllipticCurvePrivateKey,
|
|
20
|
+
EllipticCurvePublicKey,
|
|
21
|
+
)
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
23
|
+
Ed25519PrivateKey,
|
|
24
|
+
Ed25519PublicKey,
|
|
25
|
+
)
|
|
26
|
+
from cryptography.hazmat.primitives.asymmetric.padding import MGF1, PSS
|
|
27
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
|
28
|
+
from cryptography.hazmat.primitives.hashes import SHA256
|
|
29
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
30
|
+
Encoding,
|
|
31
|
+
PublicFormat,
|
|
32
|
+
load_ssh_private_key,
|
|
33
|
+
load_ssh_public_key,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from .base import SignerBase, VerifierError
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _discover_ssh_key() -> Optional[str]:
|
|
40
|
+
from pytest_gpu_proof.gitutils import get_git_signing_key
|
|
41
|
+
|
|
42
|
+
signing_key = get_git_signing_key()
|
|
43
|
+
if signing_key and os.path.exists(signing_key):
|
|
44
|
+
return signing_key
|
|
45
|
+
|
|
46
|
+
home = Path.home()
|
|
47
|
+
for candidate in ["id_ed25519", "id_ecdsa", "id_rsa"]:
|
|
48
|
+
path = home / ".ssh" / candidate
|
|
49
|
+
if path.exists():
|
|
50
|
+
return str(path)
|
|
51
|
+
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _public_key_fingerprint(public_key) -> str:
|
|
56
|
+
"""SHA256 fingerprint matching `ssh-keygen -l -E sha256` output."""
|
|
57
|
+
ssh_line = public_key.public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH)
|
|
58
|
+
b64_part = ssh_line.split()[1]
|
|
59
|
+
raw = base64.b64decode(b64_part)
|
|
60
|
+
digest = hashlib.sha256(raw).digest()
|
|
61
|
+
return "SHA256:" + base64.b64encode(digest).decode().rstrip("=")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _sign_with_key(private_key, data: bytes) -> bytes:
|
|
65
|
+
if isinstance(private_key, Ed25519PrivateKey):
|
|
66
|
+
return private_key.sign(data)
|
|
67
|
+
if isinstance(private_key, EllipticCurvePrivateKey):
|
|
68
|
+
return private_key.sign(data, ECDSA(SHA256()))
|
|
69
|
+
if isinstance(private_key, RSAPrivateKey):
|
|
70
|
+
return private_key.sign(
|
|
71
|
+
data,
|
|
72
|
+
PSS(mgf=MGF1(SHA256()), salt_length=PSS.MAX_LENGTH),
|
|
73
|
+
SHA256(),
|
|
74
|
+
)
|
|
75
|
+
raise VerifierError(f"Unsupported private key type: {type(private_key).__name__}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _verify_with_key(public_key, signature: bytes, data: bytes) -> bool:
|
|
79
|
+
try:
|
|
80
|
+
if isinstance(public_key, Ed25519PublicKey):
|
|
81
|
+
public_key.verify(signature, data)
|
|
82
|
+
elif isinstance(public_key, EllipticCurvePublicKey):
|
|
83
|
+
public_key.verify(signature, data, ECDSA(SHA256()))
|
|
84
|
+
elif isinstance(public_key, RSAPublicKey):
|
|
85
|
+
public_key.verify(
|
|
86
|
+
signature,
|
|
87
|
+
data,
|
|
88
|
+
PSS(mgf=MGF1(SHA256()), salt_length=PSS.MAX_LENGTH),
|
|
89
|
+
SHA256(),
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
return False
|
|
93
|
+
return True
|
|
94
|
+
except InvalidSignature:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_pubkey_line(line: str):
|
|
99
|
+
parts = line.strip().split()
|
|
100
|
+
if len(parts) < 2:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
return load_ssh_public_key(f"{parts[0]} {parts[1]}".encode())
|
|
104
|
+
except Exception:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def fetch_github_public_keys(username: str) -> List:
|
|
109
|
+
url = f"https://github.com/{username}.keys"
|
|
110
|
+
try:
|
|
111
|
+
with urlopen(url, timeout=10) as resp:
|
|
112
|
+
content = resp.read().decode()
|
|
113
|
+
except Exception as e:
|
|
114
|
+
raise VerifierError(
|
|
115
|
+
f"Could not fetch public keys for GitHub user {username!r}: {e}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
keys = [_parse_pubkey_line(line) for line in content.splitlines()]
|
|
119
|
+
keys = [k for k in keys if k is not None]
|
|
120
|
+
|
|
121
|
+
if not keys:
|
|
122
|
+
raise VerifierError(
|
|
123
|
+
f"No usable public keys found for GitHub user {username!r}. "
|
|
124
|
+
"Ensure they have SSH keys registered at github.com/settings/keys."
|
|
125
|
+
)
|
|
126
|
+
return keys
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def verify_with_github_keys(data: bytes, signature: bytes, github_username: str) -> bool:
|
|
130
|
+
"""Return True if signature was made by any SSH key the user has on GitHub."""
|
|
131
|
+
keys = fetch_github_public_keys(github_username)
|
|
132
|
+
return any(_verify_with_key(k, signature, data) for k in keys)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class SSHSigner(SignerBase):
|
|
136
|
+
"""Signs with the developer's SSH private key (same key used to push to GitHub)."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, key_path: Optional[str] = None):
|
|
139
|
+
if key_path is None:
|
|
140
|
+
key_path = _discover_ssh_key()
|
|
141
|
+
if key_path is None:
|
|
142
|
+
raise VerifierError(
|
|
143
|
+
"No SSH private key found. Tried git config user.signingKey and "
|
|
144
|
+
"~/.ssh/id_ed25519, ~/.ssh/id_ecdsa, ~/.ssh/id_rsa. "
|
|
145
|
+
"Pass --gpu-proof-key=PATH to specify one explicitly."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
self._key_path = key_path
|
|
149
|
+
try:
|
|
150
|
+
key_data = Path(key_path).read_bytes()
|
|
151
|
+
except FileNotFoundError:
|
|
152
|
+
raise VerifierError(f"No SSH private key found at {key_path}")
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
self._private_key = load_ssh_private_key(key_data, password=None)
|
|
156
|
+
except TypeError:
|
|
157
|
+
import getpass
|
|
158
|
+
pw = getpass.getpass(f"Passphrase for {key_path}: ").encode()
|
|
159
|
+
self._private_key = load_ssh_private_key(key_data, password=pw)
|
|
160
|
+
|
|
161
|
+
self._public_key = self._private_key.public_key()
|
|
162
|
+
|
|
163
|
+
def sign(self, data: bytes) -> bytes:
|
|
164
|
+
return _sign_with_key(self._private_key, data)
|
|
165
|
+
|
|
166
|
+
def key_fingerprint(self) -> str:
|
|
167
|
+
return _public_key_fingerprint(self._public_key)
|
|
168
|
+
|
|
169
|
+
def algorithm(self) -> str:
|
|
170
|
+
if isinstance(self._private_key, Ed25519PrivateKey):
|
|
171
|
+
return "ed25519"
|
|
172
|
+
if isinstance(self._private_key, EllipticCurvePrivateKey):
|
|
173
|
+
return "ecdsa-sha256"
|
|
174
|
+
if isinstance(self._private_key, RSAPrivateKey):
|
|
175
|
+
return "rsa-pss-sha256"
|
|
176
|
+
raise VerifierError(
|
|
177
|
+
f"Unsupported private key type: {type(self._private_key).__name__}"
|
|
178
|
+
)
|