trooth 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.
- trooth/__init__.py +14 -0
- trooth/integrations/__init__.py +16 -0
- trooth/integrations/langchain.py +33 -0
- trooth/integrations/llama_index.py +34 -0
- trooth/integrations/openai_agents.py +44 -0
- trooth-0.1.0.dist-info/METADATA +88 -0
- trooth-0.1.0.dist-info/RECORD +20 -0
- trooth-0.1.0.dist-info/WHEEL +5 -0
- trooth-0.1.0.dist-info/entry_points.txt +2 -0
- trooth-0.1.0.dist-info/licenses/LICENSE +70 -0
- trooth-0.1.0.dist-info/top_level.txt +2 -0
- trooth_semanto_verify/__init__.py +94 -0
- trooth_semanto_verify/__main__.py +8 -0
- trooth_semanto_verify/canonical.py +88 -0
- trooth_semanto_verify/cli.py +246 -0
- trooth_semanto_verify/client.py +127 -0
- trooth_semanto_verify/httpsig.py +217 -0
- trooth_semanto_verify/jws.py +255 -0
- trooth_semanto_verify/merkle.py +110 -0
- trooth_semanto_verify/verify.py +541 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Trooth, LLC
|
|
3
|
+
#
|
|
4
|
+
# semanto-verify for Python: the same commands as verifier/js/cli.mjs. Prints
|
|
5
|
+
# one line per check in plain words and exits 0 only when every check passed.
|
|
6
|
+
#
|
|
7
|
+
# semanto-verify file <semanto.json> [--pages <dir>] [--keys <keys.json>] [--now <iso>] [--revocations <file>]
|
|
8
|
+
# semanto-verify page <url> [--trooth <base>] [--now <iso>] [--allow-unwitnessed] [--allow-unlogged] [--allow-unsigned]
|
|
9
|
+
# semanto-verify response <url> [--keys <keys.json>]
|
|
10
|
+
# semanto-verify log --from <n> --to <m> [--log <url>] [--keys <keys.json>]
|
|
11
|
+
#
|
|
12
|
+
# Keys are kept apart by role: the company's JWK Set (from its domain) checks
|
|
13
|
+
# company signatures; Trooth's checks witness statements, tree heads and
|
|
14
|
+
# revocation lists. `page` is strict by default: the version header must be
|
|
15
|
+
# fetched, and a missing witness statement or log entry fails unless allowed.
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.request
|
|
24
|
+
from typing import Any, Optional
|
|
25
|
+
from urllib.parse import urljoin, urlsplit
|
|
26
|
+
|
|
27
|
+
from .jws import parse_dns_key_records
|
|
28
|
+
from .verify import (
|
|
29
|
+
parse_link,
|
|
30
|
+
state_of,
|
|
31
|
+
verify_consistency,
|
|
32
|
+
verify_page_proof,
|
|
33
|
+
verify_response,
|
|
34
|
+
verify_revocation_list,
|
|
35
|
+
verify_semanto,
|
|
36
|
+
verify_tree_head,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
TROOTH_API = "https://api.trooth.co"
|
|
40
|
+
# Trooth's witness keys, served by the Semanto Worker; --trooth <base> reads <base>/.well-known/trooth-keys.json instead.
|
|
41
|
+
TROOTH_KEYS_BASE = f"{TROOTH_API}/public/semanto"
|
|
42
|
+
DEFAULT_LOG = f"{TROOTH_API}/public/semanto/log"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_json(path: str) -> Any:
|
|
46
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
47
|
+
return json.load(f)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fetch(url: str, accept: str = "application/json") -> tuple[int, dict, bytes]:
|
|
51
|
+
req = urllib.request.Request(url, headers={"Accept": accept, "User-Agent": "semanto-verify/0.1 (python)"})
|
|
52
|
+
try:
|
|
53
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
54
|
+
return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read()
|
|
55
|
+
except urllib.error.HTTPError as e:
|
|
56
|
+
return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _fetch_json(url: str) -> Any:
|
|
60
|
+
status, _, body = _fetch(url)
|
|
61
|
+
if status != 200:
|
|
62
|
+
raise RuntimeError(f"{url} answered {status}")
|
|
63
|
+
return json.loads(body)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _print(result: dict, title: str) -> bool:
|
|
67
|
+
print(title)
|
|
68
|
+
for c in result.get("checks") or []:
|
|
69
|
+
print(f" {'ok ' if c['ok'] else 'FAIL'} {c['name']}: {c['detail']}")
|
|
70
|
+
if "checks" not in result:
|
|
71
|
+
print(f" {'ok ' if result['ok'] else 'FAIL'} {result.get('reason') or ''}")
|
|
72
|
+
for n in result.get("notes") or []:
|
|
73
|
+
print(f" note {n}")
|
|
74
|
+
print("Every check passed." if result["ok"] else f"Failed: {result.get('reason')}.")
|
|
75
|
+
print()
|
|
76
|
+
return bool(result["ok"])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _load_keys(domain: str, args: argparse.Namespace) -> Any:
|
|
80
|
+
if args.keys:
|
|
81
|
+
return _read_json(args.keys)
|
|
82
|
+
keys: dict = {"company": {"keys": []}, "trooth": {"keys": []}}
|
|
83
|
+
for role, url in (("company", f"https://{domain}/.well-known/trooth-keys.json"), ("trooth", f"{args.trooth or TROOTH_KEYS_BASE}/.well-known/trooth-keys.json")):
|
|
84
|
+
try:
|
|
85
|
+
keys[role] = _fetch_json(url)
|
|
86
|
+
print(f" ok fetched the {'company' if role == 'company' else 'Trooth'} JWK Set at {url}")
|
|
87
|
+
except Exception as e: # noqa: BLE001 - we report and continue
|
|
88
|
+
print(f" note {url} could not be read ({e})")
|
|
89
|
+
return keys
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _dns_note(domain: str, kid: Optional[str]) -> None:
|
|
93
|
+
if not kid:
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
import subprocess
|
|
97
|
+
|
|
98
|
+
out = subprocess.run(["dig", "+short", "TXT", f"_trooth-keys.{domain}"], capture_output=True, text=True, timeout=10).stdout
|
|
99
|
+
kids = parse_dns_key_records([l.strip().strip('"') for l in out.splitlines()])
|
|
100
|
+
except Exception: # noqa: BLE001
|
|
101
|
+
print(f"DNS binding\n note _trooth-keys.{domain} was not checked (no dig on this machine).\n")
|
|
102
|
+
return
|
|
103
|
+
if kid in kids:
|
|
104
|
+
print(f"DNS binding\n ok _trooth-keys.{domain} TXT names key {kid}.\n")
|
|
105
|
+
elif kids:
|
|
106
|
+
print(f"DNS binding\n note _trooth-keys.{domain} TXT names {', '.join(kids)}, not the signing key {kid}.\n")
|
|
107
|
+
else:
|
|
108
|
+
print(f"DNS binding\n note _trooth-keys.{domain} has no v=trooth1 record.\n")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_file(args: argparse.Namespace) -> int:
|
|
112
|
+
doc = _read_json(args.path)
|
|
113
|
+
file = doc.get("file", doc)
|
|
114
|
+
base = os.path.dirname(os.path.abspath(args.path))
|
|
115
|
+
if args.keys:
|
|
116
|
+
keys = _read_json(args.keys)
|
|
117
|
+
elif os.path.exists(os.path.join(base, "keys.json")):
|
|
118
|
+
keys = _read_json(os.path.join(base, "keys.json"))
|
|
119
|
+
elif args.offline:
|
|
120
|
+
keys = {}
|
|
121
|
+
else:
|
|
122
|
+
keys = _load_keys(file["header"]["identity"]["domain"], args)
|
|
123
|
+
pages_dir = args.pages or (os.path.join(base, "pages") if os.path.isdir(os.path.join(base, "pages")) else None)
|
|
124
|
+
pages: dict = {}
|
|
125
|
+
if pages_dir:
|
|
126
|
+
for p in file["header"]["pages_manifest"]:
|
|
127
|
+
f = os.path.join(pages_dir, f"{p['id']}.md")
|
|
128
|
+
if os.path.exists(f):
|
|
129
|
+
with open(f, "rb") as fh:
|
|
130
|
+
pages[p["url"]] = fh.read()
|
|
131
|
+
print(f"Pages: {len(pages)} of {len(file['header']['pages_manifest'])} found in {pages_dir}.")
|
|
132
|
+
revocations = None
|
|
133
|
+
if args.revocations:
|
|
134
|
+
r = _read_json(args.revocations)
|
|
135
|
+
v = verify_revocation_list(r.get("jws", r), keys, now=args.now)
|
|
136
|
+
print(f"Revocation list: {'signed by Trooth' if v['ok'] else 'not accepted (' + v['reason'] + ')'}.")
|
|
137
|
+
if v["ok"]:
|
|
138
|
+
revocations = v["payload"]
|
|
139
|
+
result = verify_semanto(file, pages, keys, now=args.now or doc.get("now"), revocations=revocations)
|
|
140
|
+
return 0 if _print(result, f"Semanto file {args.path} (version {result.get('version_id') or 'unknown'})") else 1
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_response(args: argparse.Namespace, also_proof: bool = False) -> int:
|
|
144
|
+
status, headers, body = _fetch(args.url, accept="text/markdown")
|
|
145
|
+
print(f"GET {args.url} with Accept: text/markdown answered {status} {headers.get('content-type', '')}")
|
|
146
|
+
domain = urlsplit(args.url).hostname or ""
|
|
147
|
+
keys = _load_keys(domain, args)
|
|
148
|
+
resp = verify_response(status, headers, body, keys, now=args.now)
|
|
149
|
+
ok = _print(resp, "Signed response")
|
|
150
|
+
_dns_note(domain, resp.get("keyid"))
|
|
151
|
+
if also_proof:
|
|
152
|
+
links = parse_link(headers.get("link"))
|
|
153
|
+
proof_link = next((l for l in links if any(r in ("proof", "https://trooth.co/rel/proof") for r in (l["params"].get("rel") or "").split())), None)
|
|
154
|
+
if not proof_link:
|
|
155
|
+
print('Proof\n FAIL the response has no Link rel="https://trooth.co/rel/proof".\n')
|
|
156
|
+
ok = False
|
|
157
|
+
else:
|
|
158
|
+
proof_url = urljoin(args.url, proof_link["url"])
|
|
159
|
+
try:
|
|
160
|
+
doc = _fetch_json(proof_url)
|
|
161
|
+
proof = doc.get("proof", doc)
|
|
162
|
+
header = doc.get("header")
|
|
163
|
+
if header is None and isinstance(proof.get("version"), int):
|
|
164
|
+
import re as _re
|
|
165
|
+
|
|
166
|
+
version_url = _re.sub(r"/pages/[^/]+/proof$", f"/versions/{proof['version']}", proof_url)
|
|
167
|
+
try:
|
|
168
|
+
header = _fetch_json(version_url).get("header")
|
|
169
|
+
print(f" ok fetched the version header at {version_url}")
|
|
170
|
+
except Exception as e: # noqa: BLE001
|
|
171
|
+
# Strict: a header that cannot be fetched is a failure, not a skip.
|
|
172
|
+
print(f" FAIL {version_url} could not be read ({e}); without the header the proof is not tied to a signed version.")
|
|
173
|
+
pr = verify_page_proof(proof, body, keys, now=args.now, header=header, allow_unwitnessed=getattr(args, "allow_unwitnessed", False), allow_unlogged=getattr(args, "allow_unlogged", False), allow_unsigned=getattr(args, "allow_unsigned", False))
|
|
174
|
+
ok = _print(pr, f"Proof at {proof_url}") and ok
|
|
175
|
+
if header:
|
|
176
|
+
print(f"State now: {state_of({'header': header, 'root_hash': proof.get('root_hash'), 'signature': proof.get('signature')}, args.now)}.\n")
|
|
177
|
+
except Exception as e: # noqa: BLE001
|
|
178
|
+
print(f"Proof\n FAIL {proof_url} could not be read or parsed ({e}).\n")
|
|
179
|
+
ok = False
|
|
180
|
+
return 0 if ok else 1
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def cmd_log(args: argparse.Namespace) -> int:
|
|
184
|
+
log = args.log or DEFAULT_LOG
|
|
185
|
+
# Tree heads are Trooth's statements: only Trooth's keys check them.
|
|
186
|
+
keys = _read_json(args.keys) if args.keys else {"trooth": _fetch_json(f"{args.trooth or TROOTH_KEYS_BASE}/.well-known/trooth-keys.json")}
|
|
187
|
+
old = _fetch_json(f"{log}/tree-head?size={args.from_size}")
|
|
188
|
+
new = _fetch_json(f"{log}/tree-head?size={args.to_size}")
|
|
189
|
+
cons = _fetch_json(f"{log}/consistency?from={args.from_size}&to={args.to_size}")
|
|
190
|
+
ok = True
|
|
191
|
+
for name, head in ((f"Tree head at size {args.from_size}", old), (f"Tree head at size {args.to_size}", new)):
|
|
192
|
+
v = verify_tree_head(head, keys, now=args.now)
|
|
193
|
+
print(f"{name}: {'signed by Trooth key ' + v['kid'] + ', root ' + v['root_hash'] if v['ok'] else 'FAIL ' + v['reason']}")
|
|
194
|
+
ok = ok and v["ok"]
|
|
195
|
+
root_of = lambda head: (head.get("payload") or {}).get("root_hash") or head.get("root_hash") # noqa: E731
|
|
196
|
+
c = verify_consistency({"from": args.from_size, "to": args.to_size, "old_root": root_of(old), "new_root": root_of(new), "path": cons.get("path", cons)})
|
|
197
|
+
print(f"Consistency {args.from_size} to {args.to_size}: " + ("the older tree is a prefix of the newer one; nothing was removed or rewritten" if c else "FAIL the proof does not connect the two roots"))
|
|
198
|
+
return 0 if ok and c else 1
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main(argv: Optional[list] = None) -> int:
|
|
202
|
+
p = argparse.ArgumentParser(prog="semanto-verify", description="Check a Semanto file, a page, a signed response or the public log. Exit 0 means every check passed.")
|
|
203
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
204
|
+
f = sub.add_parser("file", help="check a Semanto file offline")
|
|
205
|
+
f.add_argument("path")
|
|
206
|
+
f.add_argument("--pages")
|
|
207
|
+
f.add_argument("--keys")
|
|
208
|
+
f.add_argument("--now")
|
|
209
|
+
f.add_argument("--revocations")
|
|
210
|
+
f.add_argument("--offline", action="store_true")
|
|
211
|
+
f.add_argument("--trooth")
|
|
212
|
+
for name in ("page", "response"):
|
|
213
|
+
s = sub.add_parser(name, help="fetch a url with Accept: text/markdown and check the signed answer" + (" and its proof" if name == "page" else ""))
|
|
214
|
+
s.add_argument("url")
|
|
215
|
+
s.add_argument("--keys")
|
|
216
|
+
s.add_argument("--now")
|
|
217
|
+
s.add_argument("--trooth")
|
|
218
|
+
if name == "page":
|
|
219
|
+
s.add_argument("--allow-unwitnessed", action="store_true")
|
|
220
|
+
s.add_argument("--allow-unlogged", action="store_true")
|
|
221
|
+
s.add_argument("--allow-unsigned", action="store_true")
|
|
222
|
+
l = sub.add_parser("log", help="check two tree heads and the consistency proof between them")
|
|
223
|
+
l.add_argument("--from", dest="from_size", type=int, required=True)
|
|
224
|
+
l.add_argument("--to", dest="to_size", type=int, required=True)
|
|
225
|
+
l.add_argument("--log")
|
|
226
|
+
l.add_argument("--keys")
|
|
227
|
+
l.add_argument("--now")
|
|
228
|
+
l.add_argument("--trooth")
|
|
229
|
+
args = p.parse_args(argv)
|
|
230
|
+
try:
|
|
231
|
+
if args.cmd == "file":
|
|
232
|
+
return cmd_file(args)
|
|
233
|
+
if args.cmd == "page":
|
|
234
|
+
return cmd_response(args, also_proof=True)
|
|
235
|
+
if args.cmd == "response":
|
|
236
|
+
return cmd_response(args)
|
|
237
|
+
if args.cmd == "log":
|
|
238
|
+
return cmd_log(args)
|
|
239
|
+
except Exception as e: # noqa: BLE001
|
|
240
|
+
print(f"semanto-verify: {e}", file=sys.stderr)
|
|
241
|
+
return 1
|
|
242
|
+
return 2
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Trooth, LLC
|
|
3
|
+
"""SemantoClient: read a company's signed copy and check it in one call.
|
|
4
|
+
|
|
5
|
+
It fetches the page as markdown, the company's key set on its domain and
|
|
6
|
+
Trooth's, checks the response signature with verify_response, and checks the
|
|
7
|
+
signing key against the domain's _trooth-keys TXT record over DNS over HTTPS.
|
|
8
|
+
Key sets and DNS answers are kept in memory for cache_seconds (300 by default).
|
|
9
|
+
The standard library does the HTTP; pass `fetch` to use your own.
|
|
10
|
+
|
|
11
|
+
from trooth_semanto_verify.client import SemantoClient
|
|
12
|
+
r = SemantoClient().read("https://acme.com/pricing")
|
|
13
|
+
if r["ok"]:
|
|
14
|
+
use(r["text"])
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
from .jws import parse_dns_key_records
|
|
26
|
+
from .verify import verify_response
|
|
27
|
+
|
|
28
|
+
TROOTH_API = "https://api.trooth.co"
|
|
29
|
+
DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query"
|
|
30
|
+
|
|
31
|
+
# fetch(url, headers) -> (status, headers with lowercase names, body bytes)
|
|
32
|
+
Fetch = Callable[[str, Dict[str, str]], Tuple[int, Dict[str, str], bytes]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def urllib_fetch(url: str, headers: Dict[str, str], timeout: float = 10.0) -> Tuple[int, Dict[str, str], bytes]:
|
|
36
|
+
req = urllib.request.Request(url, headers={**headers, "User-Agent": "trooth-semanto-verify-python"})
|
|
37
|
+
try:
|
|
38
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (https URLs chosen by the caller)
|
|
39
|
+
return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read()
|
|
40
|
+
except urllib.error.HTTPError as e:
|
|
41
|
+
return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SemantoClient:
|
|
45
|
+
def __init__(self, fetch: Optional[Fetch] = None, api_base: str = TROOTH_API, doh: Optional[str] = DOH_ENDPOINT, cache_seconds: int = 300, now: Optional[Callable[[], float]] = None):
|
|
46
|
+
self.fetch: Fetch = fetch or urllib_fetch
|
|
47
|
+
self.api_base = api_base.rstrip("/")
|
|
48
|
+
self.doh = doh
|
|
49
|
+
self.cache_seconds = cache_seconds
|
|
50
|
+
self.now = now or time.time
|
|
51
|
+
self._cache: Dict[str, Tuple[float, Any]] = {}
|
|
52
|
+
|
|
53
|
+
def _cached(self, key: str, load: Callable[[], Any]) -> Any:
|
|
54
|
+
hit = self._cache.get(key)
|
|
55
|
+
if hit and self.now() - hit[0] < self.cache_seconds:
|
|
56
|
+
return hit[1]
|
|
57
|
+
value = load()
|
|
58
|
+
self._cache[key] = (self.now(), value)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
def _json(self, url: str) -> Any:
|
|
62
|
+
status, _, body = self.fetch(url, {"Accept": "application/json"})
|
|
63
|
+
if status != 200:
|
|
64
|
+
raise ValueError(f"{url} answered {status}")
|
|
65
|
+
return json.loads(body)
|
|
66
|
+
|
|
67
|
+
def keys_for(self, domain: str) -> Dict[str, Any]:
|
|
68
|
+
def load() -> Dict[str, Any]:
|
|
69
|
+
keys: Dict[str, Any] = {"company": {"keys": []}, "trooth": {"keys": []}}
|
|
70
|
+
notes: List[str] = []
|
|
71
|
+
for role, url in (("company", f"https://{domain}/.well-known/trooth-keys.json"), ("trooth", f"{self.api_base}/public/semanto/.well-known/trooth-keys.json")):
|
|
72
|
+
try:
|
|
73
|
+
keys[role] = self._json(url)
|
|
74
|
+
except Exception as e: # noqa: BLE001 (any failure is a note, never a crash)
|
|
75
|
+
notes.append(f"{url} could not be read ({e})")
|
|
76
|
+
return {"keys": keys, "notes": notes}
|
|
77
|
+
|
|
78
|
+
return self._cached(f"keys:{domain}", load)
|
|
79
|
+
|
|
80
|
+
def dns_kids(self, domain: str) -> Optional[List[str]]:
|
|
81
|
+
if not self.doh:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def load() -> Optional[List[str]]:
|
|
85
|
+
try:
|
|
86
|
+
q = urllib.parse.urlencode({"name": f"_trooth-keys.{domain}", "type": "TXT"})
|
|
87
|
+
status, _, body = self.fetch(f"{self.doh}?{q}", {"Accept": "application/dns-json"})
|
|
88
|
+
if status != 200:
|
|
89
|
+
return None
|
|
90
|
+
answers = json.loads(body).get("Answer") or []
|
|
91
|
+
txt = [str(a.get("data", "")).strip('"').replace('" "', "") for a in answers if a.get("type") == 16]
|
|
92
|
+
return parse_dns_key_records(txt)
|
|
93
|
+
except Exception: # noqa: BLE001
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
return self._cached(f"dns:{domain}", load)
|
|
97
|
+
|
|
98
|
+
def read(self, url: str, accept: str = "text/markdown") -> Dict[str, Any]:
|
|
99
|
+
"""Read one URL and check it. ok is True only when the signature checks against a published key."""
|
|
100
|
+
domain = urllib.parse.urlsplit(url).hostname or ""
|
|
101
|
+
status, headers, body = self.fetch(url, {"Accept": accept})
|
|
102
|
+
loaded = self.keys_for(domain)
|
|
103
|
+
res = verify_response(status, headers, body, loaded["keys"], now=int(self.now()))
|
|
104
|
+
dns: Optional[bool] = None
|
|
105
|
+
if res.get("keyid") and (headers.get("trooth-signed-by") or "").lower() != "witness":
|
|
106
|
+
kids = self.dns_kids(domain)
|
|
107
|
+
dns = None if kids is None else res["keyid"] in kids
|
|
108
|
+
return {
|
|
109
|
+
"ok": res["ok"],
|
|
110
|
+
"reason": None if res["ok"] else res.get("reason"),
|
|
111
|
+
"status": status,
|
|
112
|
+
"text": body.decode("utf-8", "replace"),
|
|
113
|
+
"version": headers.get("trooth-semanto-version"),
|
|
114
|
+
"signed_by": headers.get("trooth-signed-by"),
|
|
115
|
+
"keyid": res.get("keyid"),
|
|
116
|
+
"dns": dns,
|
|
117
|
+
"checks": res.get("checks", []),
|
|
118
|
+
"notes": loaded["notes"],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
def facts(self, trooth_id: str) -> Dict[str, Any]:
|
|
122
|
+
r = self.read(f"{self.api_base}/public/semanto/{urllib.parse.quote(trooth_id)}/facts.jsonld", accept="application/ld+json")
|
|
123
|
+
try:
|
|
124
|
+
r["data"] = json.loads(r["text"])
|
|
125
|
+
except ValueError:
|
|
126
|
+
r["data"] = None
|
|
127
|
+
return r
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Trooth, LLC
|
|
3
|
+
#
|
|
4
|
+
# HTTP Message Signatures, RFC 9421, with Content-Digest, RFC 9530. Verifies a
|
|
5
|
+
# Semanto response (tag trooth-semanto) and a Web Bot Auth request (tag
|
|
6
|
+
# web-bot-auth, draft-ietf-webbotauth-httpsig-protocol). Ed25519 only.
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
from .jws import ed25519_verify, key_valid_at_signing
|
|
19
|
+
|
|
20
|
+
SEMANTO_RESPONSE_COMPONENTS = ["@status", "content-type", "content-digest", "trooth-semanto-version", "trooth-page-hash"]
|
|
21
|
+
# Covered whenever the response carries them; the Trooth ones and Location must be covered when present.
|
|
22
|
+
SEMANTO_RESPONSE_OPTIONAL_COMPONENTS = ["trooth-signed-page-hash", "trooth-signed-by", "trooth-semanto-state", "location", "content-signal", "link"]
|
|
23
|
+
SEMANTO_RESPONSE_MUST_COVER_WHEN_PRESENT = ["trooth-signed-page-hash", "trooth-signed-by", "trooth-semanto-state", "location"]
|
|
24
|
+
SEMANTO_RESPONSE_TAG = "trooth-semanto"
|
|
25
|
+
WEB_BOT_AUTH_TAG = "web-bot-auth"
|
|
26
|
+
WEB_BOT_AUTH_REQUIRED = ["@authority", "signature-agent"]
|
|
27
|
+
|
|
28
|
+
_INPUT_RE = re.compile(r'([A-Za-z0-9_*.-]+)=\(((?:\s*"(?:[^"\\]|\\.)*")*)\s*\)((?:;[A-Za-z*][A-Za-z0-9_.*-]*(?:=(?:"(?:[^"\\]|\\.)*"|[^;,\s]+))?)*)')
|
|
29
|
+
_COMP_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
|
|
30
|
+
_PARAM_RE = re.compile(r';([A-Za-z*][A-Za-z0-9_.*-]*)(?:=("(?:[^"\\]|\\.)*"|[^;,\s]+))?')
|
|
31
|
+
_SIG_RE = re.compile(r"([A-Za-z0-9_*.-]+)=:([A-Za-z0-9+/=]+):")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def header_get(headers: Dict[str, Any], name: str) -> Optional[str]:
|
|
35
|
+
n = name.lower()
|
|
36
|
+
for k, v in headers.items():
|
|
37
|
+
if k.lower() == n:
|
|
38
|
+
if v is None:
|
|
39
|
+
return None
|
|
40
|
+
if isinstance(v, (list, tuple)):
|
|
41
|
+
return ", ".join(str(x).strip() for x in v)
|
|
42
|
+
return str(v)
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _canon_field(v: str) -> str:
|
|
47
|
+
return re.sub(r"[\r\n]+[ \t]+", " ", v).strip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sf_string(s: str) -> str:
|
|
51
|
+
if re.search(r"[^\x20-\x7e]", s):
|
|
52
|
+
raise ValueError("non-ASCII in structured field string")
|
|
53
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _derived(name: str, m: dict) -> str:
|
|
57
|
+
u = urlsplit(m["url"]) if m.get("url") else None
|
|
58
|
+
if name == "@method":
|
|
59
|
+
if m.get("kind") != "request" or not m.get("method"):
|
|
60
|
+
raise ValueError("@method needs a request")
|
|
61
|
+
return m["method"].upper()
|
|
62
|
+
if name == "@target-uri":
|
|
63
|
+
if not u:
|
|
64
|
+
raise ValueError("@target-uri needs a url")
|
|
65
|
+
return m["url"]
|
|
66
|
+
if name == "@authority":
|
|
67
|
+
if not u:
|
|
68
|
+
raise ValueError("@authority needs a url")
|
|
69
|
+
return u.netloc.lower()
|
|
70
|
+
if name == "@scheme":
|
|
71
|
+
if not u:
|
|
72
|
+
raise ValueError("@scheme needs a url")
|
|
73
|
+
return u.scheme.lower()
|
|
74
|
+
if name == "@path":
|
|
75
|
+
if not u:
|
|
76
|
+
raise ValueError("@path needs a url")
|
|
77
|
+
return u.path or "/"
|
|
78
|
+
if name == "@query":
|
|
79
|
+
if not u:
|
|
80
|
+
raise ValueError("@query needs a url")
|
|
81
|
+
return "?" + u.query if u.query else "?"
|
|
82
|
+
if name == "@status":
|
|
83
|
+
if m.get("kind") != "response" or m.get("status") is None:
|
|
84
|
+
raise ValueError("@status needs a response")
|
|
85
|
+
return str(m["status"])
|
|
86
|
+
raise ValueError(f"unsupported derived component {name}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_signature_input(value: str) -> Dict[str, dict]:
|
|
90
|
+
out: Dict[str, dict] = {}
|
|
91
|
+
for m in _INPUT_RE.finditer(value):
|
|
92
|
+
label, comps_raw, params_raw = m.group(1), m.group(2), m.group(3)
|
|
93
|
+
components = [re.sub(r"\\(.)", r"\1", c) for c in _COMP_RE.findall(comps_raw)]
|
|
94
|
+
params: Dict[str, Any] = {}
|
|
95
|
+
for pm in _PARAM_RE.finditer(params_raw):
|
|
96
|
+
k, v = pm.group(1), pm.group(2)
|
|
97
|
+
if v is None:
|
|
98
|
+
v = ""
|
|
99
|
+
elif v.startswith('"'):
|
|
100
|
+
v = re.sub(r"\\(.)", r"\1", v[1:-1])
|
|
101
|
+
if k in ("created", "expires"):
|
|
102
|
+
# RFC 9421 2.3: Integers only; anything else is kept as "bad" and refused as malformed.
|
|
103
|
+
params[k] = int(v) if re.fullmatch(r"-?\d{1,15}", v) else "bad"
|
|
104
|
+
elif k in ("keyid", "alg", "nonce", "tag"):
|
|
105
|
+
params[k] = v
|
|
106
|
+
if "created" not in params or not isinstance(params.get("keyid"), str):
|
|
107
|
+
continue
|
|
108
|
+
out[label] = {"components": components, "params": params, "raw": f"({comps_raw.strip()}){params_raw}"}
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_signature(value: str) -> Dict[str, bytes]:
|
|
113
|
+
return {m.group(1): base64.b64decode(m.group(2)) for m in _SIG_RE.finditer(value)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def signature_base(m: dict, components: List[str], raw_params: str) -> str:
|
|
117
|
+
lines = []
|
|
118
|
+
seen = set()
|
|
119
|
+
for c in components:
|
|
120
|
+
key = c.lower()
|
|
121
|
+
if key in seen:
|
|
122
|
+
raise ValueError(f"duplicate component {c}")
|
|
123
|
+
seen.add(key)
|
|
124
|
+
if c.startswith("@"):
|
|
125
|
+
value = _derived(c, m)
|
|
126
|
+
else:
|
|
127
|
+
v = header_get(m["headers"], c)
|
|
128
|
+
if v is None:
|
|
129
|
+
raise ValueError(f"missing header {c}")
|
|
130
|
+
value = _canon_field(v)
|
|
131
|
+
lines.append(f"{_sf_string(key)}: {value}")
|
|
132
|
+
lines.append(f'"@signature-params": {raw_params}')
|
|
133
|
+
return "\n".join(lines)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def verify_content_digest(header: Optional[str], body: bytes) -> bool:
|
|
137
|
+
if not header:
|
|
138
|
+
return False
|
|
139
|
+
m = re.search(r"sha-256=:([A-Za-z0-9+/=]+):", header)
|
|
140
|
+
if not m:
|
|
141
|
+
return False
|
|
142
|
+
return m.group(1) == base64.b64encode(hashlib.sha256(body).digest()).decode("ascii")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def verify_message(
|
|
146
|
+
m: dict,
|
|
147
|
+
resolve_key: Callable[[str], Optional[dict]],
|
|
148
|
+
required: Optional[Iterable[str]] = None,
|
|
149
|
+
tag: Optional[str] = None,
|
|
150
|
+
now: Optional[int] = None,
|
|
151
|
+
max_age_seconds: Optional[int] = None,
|
|
152
|
+
skew_seconds: int = 60,
|
|
153
|
+
label: Optional[str] = None,
|
|
154
|
+
) -> dict:
|
|
155
|
+
"""m = {"kind": "request"|"response", "method", "url", "status", "headers"}.
|
|
156
|
+
|
|
157
|
+
label: verify that label only. The key is judged at the signature's created.
|
|
158
|
+
"""
|
|
159
|
+
si = header_get(m["headers"], "signature-input")
|
|
160
|
+
sg = header_get(m["headers"], "signature")
|
|
161
|
+
if not si or not sg:
|
|
162
|
+
return {"ok": False, "reason": "no_signature"}
|
|
163
|
+
inputs = parse_signature_input(si)
|
|
164
|
+
sigs = parse_signature(sg)
|
|
165
|
+
labels = [l for l in inputs if l in sigs and (label is None or l == label)]
|
|
166
|
+
if not labels:
|
|
167
|
+
return {"ok": False, "reason": "malformed"}
|
|
168
|
+
now = int(time.time()) if now is None else now
|
|
169
|
+
last = {"ok": False, "reason": "malformed"}
|
|
170
|
+
req = [r.lower() for r in (required or [])]
|
|
171
|
+
for lb in labels:
|
|
172
|
+
components, params, raw = inputs[lb]["components"], inputs[lb]["params"], inputs[lb]["raw"]
|
|
173
|
+
if not isinstance(params.get("created"), int) or params.get("expires") == "bad":
|
|
174
|
+
last = {"ok": False, "reason": "malformed"}
|
|
175
|
+
continue
|
|
176
|
+
if params.get("alg") and params["alg"] != "ed25519":
|
|
177
|
+
last = {"ok": False, "reason": "unsupported_alg"}
|
|
178
|
+
continue
|
|
179
|
+
if tag and params.get("tag") != tag:
|
|
180
|
+
last = {"ok": False, "reason": "wrong_tag"}
|
|
181
|
+
continue
|
|
182
|
+
lower = [c.lower() for c in components]
|
|
183
|
+
if any(r not in lower for r in req):
|
|
184
|
+
last = {"ok": False, "reason": "missing_component"}
|
|
185
|
+
continue
|
|
186
|
+
if params.get("expires") is not None and now >= params["expires"]:
|
|
187
|
+
last = {"ok": False, "reason": "expired"}
|
|
188
|
+
continue
|
|
189
|
+
if params["created"] > now + skew_seconds:
|
|
190
|
+
last = {"ok": False, "reason": "not_yet_valid"}
|
|
191
|
+
continue
|
|
192
|
+
if max_age_seconds is not None and now - params["created"] > max_age_seconds:
|
|
193
|
+
last = {"ok": False, "reason": "too_old"}
|
|
194
|
+
continue
|
|
195
|
+
jwk = resolve_key(params["keyid"])
|
|
196
|
+
if jwk is None:
|
|
197
|
+
last = {"ok": False, "reason": "unknown_key"}
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
created_at = datetime.fromtimestamp(params["created"], tz=timezone.utc)
|
|
201
|
+
except (OverflowError, OSError, ValueError):
|
|
202
|
+
last = {"ok": False, "reason": "malformed"}
|
|
203
|
+
continue
|
|
204
|
+
kv = key_valid_at_signing(jwk, created_at)
|
|
205
|
+
if not kv["ok"]:
|
|
206
|
+
last = {"ok": False, "reason": kv["reason"]}
|
|
207
|
+
continue
|
|
208
|
+
try:
|
|
209
|
+
base = signature_base(m, components, raw)
|
|
210
|
+
except ValueError:
|
|
211
|
+
last = {"ok": False, "reason": "missing_component"}
|
|
212
|
+
continue
|
|
213
|
+
if not ed25519_verify(jwk, sigs[lb], base.encode("utf-8")):
|
|
214
|
+
last = {"ok": False, "reason": "bad_signature"}
|
|
215
|
+
continue
|
|
216
|
+
return {"ok": True, "label": lb, "keyid": params["keyid"], "params": params, "components": components, "notes": kv["notes"]}
|
|
217
|
+
return last
|