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.
@@ -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
+ )