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