hs-verify 0.1.0__tar.gz

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,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: hs-verify
3
+ Version: 0.1.0
4
+ Summary: Keyless verification of Hunter-Seeker Verdicts (Ed25519 detached JWS over RFC 8785 JCS).
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/dmilstein-match/hunter-seeker-verify
7
+ Project-URL: Source, https://github.com/dmilstein-match/hunter-seeker-verify
8
+ Project-URL: JWKS, https://hunter-seeker.net/.well-known/jwks.json
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: cryptography>=42
12
+
13
+ # hs-verify
14
+
15
+ Keyless verification of a **Hunter-Seeker Verdict** — the signed decision an AI agent receives
16
+ when it asks Hunter-Seeker who to act on and why. No account, no API key, no call to
17
+ Hunter-Seeker beyond fetching the public keys.
18
+
19
+ ```python
20
+ from hs_verify import verify
21
+ verify(verdict, signature) # fetches the published JWKS
22
+ verify(verdict, signature, jwks=jwks) # fully offline
23
+ ```
24
+ ```bash
25
+ hs-verify verdict.json signature.json [jwks.json]
26
+ ```
27
+
28
+ Returns exactly one of `valid`, `invalid_signature`, `expired`, `unknown_key`. The verifier is
29
+ not an oracle: those four words are the whole output.
30
+
31
+ A Verdict with no signature is unverifiable and reports `invalid_signature`, never `valid`.
32
+
33
+ Ed25519 detached JWS (RFC 7797, `b64:false`) over RFC 8785 canonical JSON. The TypeScript
34
+ twin — `@hunter-seeker/verify` — tests against the same vectors, which is how the two are
35
+ proven interoperable.
36
+
37
+ **Pre-release:** the hosted JWKS is not serving yet and the committed test vectors are signed
38
+ with a pre-release key. Full status, trust model and vectors:
39
+ https://github.com/dmilstein-match/hunter-seeker-verify
40
+
41
+ Apache-2.0.
@@ -0,0 +1,29 @@
1
+ # hs-verify
2
+
3
+ Keyless verification of a **Hunter-Seeker Verdict** — the signed decision an AI agent receives
4
+ when it asks Hunter-Seeker who to act on and why. No account, no API key, no call to
5
+ Hunter-Seeker beyond fetching the public keys.
6
+
7
+ ```python
8
+ from hs_verify import verify
9
+ verify(verdict, signature) # fetches the published JWKS
10
+ verify(verdict, signature, jwks=jwks) # fully offline
11
+ ```
12
+ ```bash
13
+ hs-verify verdict.json signature.json [jwks.json]
14
+ ```
15
+
16
+ Returns exactly one of `valid`, `invalid_signature`, `expired`, `unknown_key`. The verifier is
17
+ not an oracle: those four words are the whole output.
18
+
19
+ A Verdict with no signature is unverifiable and reports `invalid_signature`, never `valid`.
20
+
21
+ Ed25519 detached JWS (RFC 7797, `b64:false`) over RFC 8785 canonical JSON. The TypeScript
22
+ twin — `@hunter-seeker/verify` — tests against the same vectors, which is how the two are
23
+ proven interoperable.
24
+
25
+ **Pre-release:** the hosted JWKS is not serving yet and the committed test vectors are signed
26
+ with a pre-release key. Full status, trust model and vectors:
27
+ https://github.com/dmilstein-match/hunter-seeker-verify
28
+
29
+ Apache-2.0.
@@ -0,0 +1,114 @@
1
+ """hs-verify — keyless verification of a Hunter-Seeker Verdict.
2
+
3
+ from hs_verify import verify
4
+ status = verify(verdict, signature) # fetches the published JWKS
5
+ status = verify(verdict, signature, jwks=jwks) # offline
6
+
7
+ Returns exactly one of: "valid" | "invalid_signature" | "expired" | "unknown_key".
8
+ No account, no API key, no call to Hunter-Seeker beyond fetching the public keys.
9
+ Dependencies: `cryptography` only.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import json
15
+ import os
16
+ import math
17
+ import urllib.request
18
+ from datetime import datetime, timezone
19
+ from typing import Any, Mapping, Optional
20
+
21
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
22
+
23
+ JWKS_URL = "https://hunter-seeker.net/.well-known/jwks.json"
24
+ __all__ = ["verify", "canonicalize", "fetch_jwks", "JWKS_URL"]
25
+
26
+ _ESC = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
27
+
28
+
29
+ def _s(s: str) -> str:
30
+ return '"' + "".join(_ESC.get(c, f"\\u{ord(c):04x}" if ord(c) < 0x20 else c) for c in s) + '"'
31
+
32
+
33
+ def _n(x: float | int) -> str:
34
+ if isinstance(x, int):
35
+ return str(x)
36
+ if math.isnan(x) or math.isinf(x):
37
+ raise ValueError("NaN/Infinity")
38
+ if x == 0:
39
+ return "0"
40
+ if x.is_integer() and abs(x) < 1e21:
41
+ return str(int(x))
42
+ r = repr(x)
43
+ if "e" in r:
44
+ m, e = r.split("e"); ei = int(e)
45
+ r = f"{m}e{'+' if ei >= 0 else '-'}{abs(ei)}"
46
+ return r
47
+
48
+
49
+ def canonicalize(v: Any) -> str:
50
+ """RFC 8785 JCS (the subset a Verdict uses)."""
51
+ if v is None: return "null"
52
+ if v is True: return "true"
53
+ if v is False: return "false"
54
+ if isinstance(v, (int, float)): return _n(v)
55
+ if isinstance(v, str): return _s(v)
56
+ if isinstance(v, (list, tuple)): return "[" + ",".join(canonicalize(x) for x in v) + "]"
57
+ if isinstance(v, dict):
58
+ items = sorted(v.items(), key=lambda kv: list(str(kv[0]).encode("utf-16-be")))
59
+ return "{" + ",".join(f"{_s(str(k))}:{canonicalize(x)}" for k, x in items) + "}"
60
+ raise TypeError(type(v).__name__)
61
+
62
+
63
+ def _b64u(s: str) -> bytes:
64
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
65
+
66
+
67
+ def fetch_jwks(url: Optional[str] = None, timeout: float = 5.0) -> dict:
68
+ """Fetch the published keys. `url` defaults to HS_JWKS_URL, else JWKS_URL.
69
+
70
+ Resolved at CALL time, not bound as a default argument: a default binds the value at import,
71
+ so `hs_verify.JWKS_URL = ...` silently did nothing and the library kept fetching the public
72
+ host. Anyone self-hosting an engine, or testing against a staging one, hits that — and it
73
+ fails as a 404 that looks like the service being down rather than like a setting being
74
+ ignored.
75
+ """
76
+ url = url or os.environ.get("HS_JWKS_URL") or JWKS_URL
77
+ with urllib.request.urlopen(url, timeout=timeout) as r: # noqa: S310 - https, fixed host
78
+ return json.load(r)
79
+
80
+
81
+ def verify(verdict: Mapping[str, Any], signature: Mapping[str, str], *,
82
+ jwks: Optional[Mapping[str, Any]] = None, now: Optional[datetime] = None) -> str:
83
+ # OUTSIDE the try, deliberately. A JWKS that cannot be fetched is a transport failure, not
84
+ # a verification result: swallowing it into the uniform "invalid_signature" below told the
85
+ # caller a genuine Verdict was FORGED because their DNS was down — the one error that
86
+ # makes an auditor reject a real decision. The four-word contract describes what the
87
+ # verifier concluded about the Verdict; it has no word for "I could not reach the keys",
88
+ # so that stays an exception the caller can see.
89
+ if jwks is None:
90
+ jwks = fetch_jwks()
91
+ try:
92
+ header = json.loads(_b64u(signature["protected"]))
93
+ if header.get("alg") != "EdDSA" or header.get("b64") is not False:
94
+ return "invalid_signature"
95
+ key = next((k for k in jwks.get("keys", []) if k.get("kid") == header.get("kid")), None)
96
+ if key is None:
97
+ return "unknown_key"
98
+ pub = Ed25519PublicKey.from_public_bytes(_b64u(key["x"]))
99
+ pub.verify(_b64u(signature["signature"]),
100
+ signature["protected"].encode("ascii") + b"." + canonicalize(verdict).encode())
101
+ except Exception: # noqa: BLE001 - uniform failure
102
+ return "invalid_signature"
103
+ exp = verdict.get("expires_at")
104
+ now = now or datetime.now(timezone.utc)
105
+ if isinstance(exp, str) and now.strftime("%Y-%m-%dT%H:%M:%SZ") > exp:
106
+ return "expired"
107
+ return "valid"
108
+
109
+
110
+ def main() -> None: # `hs-verify verdict.json signature.json [jwks.json]`
111
+ import sys
112
+ v = json.load(open(sys.argv[1])); s = json.load(open(sys.argv[2]))
113
+ j = json.load(open(sys.argv[3])) if len(sys.argv) > 3 else None
114
+ print(verify(v, s, jwks=j))
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: hs-verify
3
+ Version: 0.1.0
4
+ Summary: Keyless verification of Hunter-Seeker Verdicts (Ed25519 detached JWS over RFC 8785 JCS).
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/dmilstein-match/hunter-seeker-verify
7
+ Project-URL: Source, https://github.com/dmilstein-match/hunter-seeker-verify
8
+ Project-URL: JWKS, https://hunter-seeker.net/.well-known/jwks.json
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: cryptography>=42
12
+
13
+ # hs-verify
14
+
15
+ Keyless verification of a **Hunter-Seeker Verdict** — the signed decision an AI agent receives
16
+ when it asks Hunter-Seeker who to act on and why. No account, no API key, no call to
17
+ Hunter-Seeker beyond fetching the public keys.
18
+
19
+ ```python
20
+ from hs_verify import verify
21
+ verify(verdict, signature) # fetches the published JWKS
22
+ verify(verdict, signature, jwks=jwks) # fully offline
23
+ ```
24
+ ```bash
25
+ hs-verify verdict.json signature.json [jwks.json]
26
+ ```
27
+
28
+ Returns exactly one of `valid`, `invalid_signature`, `expired`, `unknown_key`. The verifier is
29
+ not an oracle: those four words are the whole output.
30
+
31
+ A Verdict with no signature is unverifiable and reports `invalid_signature`, never `valid`.
32
+
33
+ Ed25519 detached JWS (RFC 7797, `b64:false`) over RFC 8785 canonical JSON. The TypeScript
34
+ twin — `@hunter-seeker/verify` — tests against the same vectors, which is how the two are
35
+ proven interoperable.
36
+
37
+ **Pre-release:** the hosted JWKS is not serving yet and the committed test vectors are signed
38
+ with a pre-release key. Full status, trust model and vectors:
39
+ https://github.com/dmilstein-match/hunter-seeker-verify
40
+
41
+ Apache-2.0.
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ hs_verify/__init__.py
4
+ hs_verify.egg-info/PKG-INFO
5
+ hs_verify.egg-info/SOURCES.txt
6
+ hs_verify.egg-info/dependency_links.txt
7
+ hs_verify.egg-info/entry_points.txt
8
+ hs_verify.egg-info/requires.txt
9
+ hs_verify.egg-info/top_level.txt
10
+ tests/test_verify.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hs-verify = hs_verify:main
@@ -0,0 +1 @@
1
+ cryptography>=42
@@ -0,0 +1 @@
1
+ hs_verify
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "hs-verify"
3
+ version = "0.1.0"
4
+ description = "Keyless verification of Hunter-Seeker Verdicts (Ed25519 detached JWS over RFC 8785 JCS)."
5
+ requires-python = ">=3.10"
6
+ dependencies = ["cryptography>=42"]
7
+ license = {text = "Apache-2.0"}
8
+ readme = "README.md"
9
+
10
+ [project.urls]
11
+ Homepage = "https://github.com/dmilstein-match/hunter-seeker-verify"
12
+ Source = "https://github.com/dmilstein-match/hunter-seeker-verify"
13
+ JWKS = "https://hunter-seeker.net/.well-known/jwks.json"
14
+
15
+ [project.scripts]
16
+ hs-verify = "hs_verify:main"
17
+
18
+ [tool.pytest.ini_options]
19
+ pythonpath = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,14 @@
1
+ import json, pathlib
2
+ from datetime import datetime, timezone
3
+ from hs_verify import verify, canonicalize
4
+
5
+ VEC = json.loads((pathlib.Path(__file__).parents[2] / "vectors.json").read_text())
6
+
7
+ def test_canonical_matches_engine():
8
+ assert canonicalize(VEC["payload"]) == VEC["canonical"]
9
+
10
+ def test_valid_tampered_expired_unknown():
11
+ assert verify(VEC["payload"], VEC["signature"], jwks=VEC["jwks"]) == "valid"
12
+ assert verify(VEC["tampered"], VEC["signature"], jwks=VEC["jwks"]) == "invalid_signature"
13
+ assert verify(VEC["payload"], VEC["signature"], jwks={"keys": []}) == "unknown_key"
14
+ assert verify(VEC["payload"], VEC["signature"], jwks=VEC["jwks"], now=datetime(2100, 1, 1, tzinfo=timezone.utc)) == "expired"