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.
- hs_verify-0.1.0/PKG-INFO +41 -0
- hs_verify-0.1.0/README.md +29 -0
- hs_verify-0.1.0/hs_verify/__init__.py +114 -0
- hs_verify-0.1.0/hs_verify.egg-info/PKG-INFO +41 -0
- hs_verify-0.1.0/hs_verify.egg-info/SOURCES.txt +10 -0
- hs_verify-0.1.0/hs_verify.egg-info/dependency_links.txt +1 -0
- hs_verify-0.1.0/hs_verify.egg-info/entry_points.txt +2 -0
- hs_verify-0.1.0/hs_verify.egg-info/requires.txt +1 -0
- hs_verify-0.1.0/hs_verify.egg-info/top_level.txt +1 -0
- hs_verify-0.1.0/pyproject.toml +19 -0
- hs_verify-0.1.0/setup.cfg +4 -0
- hs_verify-0.1.0/tests/test_verify.py +14 -0
hs_verify-0.1.0/PKG-INFO
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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"
|