openwright-core 0.6.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.
Files changed (57) hide show
  1. openwright/__init__.py +20 -0
  2. openwright/_ed25519_pure.py +98 -0
  3. openwright/adapters/__init__.py +37 -0
  4. openwright/adapters/a2a.py +71 -0
  5. openwright/adapters/base.py +24 -0
  6. openwright/adapters/langfuse.py +56 -0
  7. openwright/adapters/otel_genai.py +180 -0
  8. openwright/adapters/policy.py +63 -0
  9. openwright/adapters/receipt.py +198 -0
  10. openwright/adapters/sarif_in.py +68 -0
  11. openwright/anchor.py +134 -0
  12. openwright/authz.py +68 -0
  13. openwright/browser_verifier.py +197 -0
  14. openwright/canonical.py +119 -0
  15. openwright/checkpoint_store.py +140 -0
  16. openwright/cli.py +445 -0
  17. openwright/connectors/__init__.py +236 -0
  18. openwright/connectors/builtin.py +88 -0
  19. openwright/crosswalk.py +372 -0
  20. openwright/crosswalk_loader.py +53 -0
  21. openwright/crosswalks/CHANGELOG.md +47 -0
  22. openwright/crosswalks/__init__.py +7 -0
  23. openwright/crosswalks/eu_ai_act.yaml +294 -0
  24. openwright/crosswalks/eu_ai_act_v1.yaml +107 -0
  25. openwright/crosswalks/gdpr.yaml +304 -0
  26. openwright/crosswalks/iso_42001.yaml +209 -0
  27. openwright/crosswalks/nist_ai_rmf.yaml +204 -0
  28. openwright/crosswalks/soc2.yaml +246 -0
  29. openwright/dashboard.py +100 -0
  30. openwright/demo.py +270 -0
  31. openwright/events.py +210 -0
  32. openwright/identity.py +61 -0
  33. openwright/ingest/__init__.py +13 -0
  34. openwright/ingest/durable.py +144 -0
  35. openwright/ingest/fanout.py +38 -0
  36. openwright/ingest/grpc_server.py +44 -0
  37. openwright/ingest/http_server.py +163 -0
  38. openwright/ingest/otlp_common.py +69 -0
  39. openwright/ingest/pipeline.py +307 -0
  40. openwright/ledger.py +479 -0
  41. openwright/merkle.py +262 -0
  42. openwright/report.py +388 -0
  43. openwright/sbom.py +42 -0
  44. openwright/scheduler.py +97 -0
  45. openwright/sdk.py +297 -0
  46. openwright/signing.py +343 -0
  47. openwright/spec.py +109 -0
  48. openwright/vault.py +55 -0
  49. openwright/verify.py +392 -0
  50. openwright/web_demo.py +275 -0
  51. openwright/witness.py +90 -0
  52. openwright/witness_service.py +136 -0
  53. openwright_core-0.6.0.dist-info/METADATA +174 -0
  54. openwright_core-0.6.0.dist-info/RECORD +57 -0
  55. openwright_core-0.6.0.dist-info/WHEEL +4 -0
  56. openwright_core-0.6.0.dist-info/entry_points.txt +3 -0
  57. openwright_core-0.6.0.dist-info/licenses/LICENSE +52 -0
@@ -0,0 +1,198 @@
1
+ """External receipt-primitive ingest — the "sit on top of receipts" adapter.
2
+
3
+ (Proposal §A1; adapter isolation FR-NRM-03; untrusted input FR-ING-10.)
4
+
5
+ OpenWright does not reinvent cryptographic action receipts — it *consumes* them and
6
+ turns them into compliance evidence. This adapter verifies a signed receipt's
7
+ Ed25519 signature **before** ingesting it, then normalizes it into a canonical
8
+ ``ComplianceEvent(kind=tool_call)``. We attest events ourselves with the in-house
9
+ Merkle stack *or* anchor on receipts produced upstream; this is the layer above
10
+ the receipt primitive, not a competitor to any one producer.
11
+
12
+ Other receipt formats (sigstore-style, a first-party format) plug in behind the
13
+ same :class:`ReceiptSource` interface — the receipt primitive is interchangeable.
14
+
15
+ A receipt is untrusted input (FR-ING-10, NFR-SEC-01): an unsigned, malformed, or
16
+ tampered receipt is rejected with :class:`ReceiptVerificationError` and never
17
+ enters the ledger as a normal event. Verification precedes ingestion.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import abc
23
+ import base64
24
+ from typing import Any, Dict, Optional
25
+
26
+ from ..canonical import canonical_bytes, to_rfc3339
27
+ from ..events import Actor, ComplianceEvent, EventKind, IdentityClaim, IORef, ToolInfo
28
+ from ..signing import KeySource, key_id_for, verify_signature
29
+
30
+
31
+ class ReceiptVerificationError(Exception):
32
+ """A receipt's signature was missing, malformed, or did not verify.
33
+
34
+ The receipt is NOT turned into a tool_call event — verification precedes
35
+ ingestion.
36
+ """
37
+
38
+
39
+ def receipt_signing_bytes(receipt: Dict[str, Any]) -> bytes:
40
+ """The exact bytes a receipt signs over: its canonical form minus ``sig``.
41
+
42
+ Deterministic and verifier-reproducible (the same JCS canonicalization the
43
+ rest of OpenWright uses), so any party holding the signer's public key can
44
+ re-derive these bytes and check the signature independently.
45
+ """
46
+ return canonical_bytes({k: v for k, v in receipt.items() if k != "sig"})
47
+
48
+
49
+ class ReceiptSource(abc.ABC):
50
+ """Interchangeable receipt-primitive adapter.
51
+
52
+ Implement :meth:`verify_and_normalize` to teach OpenWright a new signed-receipt
53
+ format. Every source verifies its own signature scheme and emits the same
54
+ canonical :class:`ComplianceEvent`, so receipt primitives are swappable
55
+ behind one interface.
56
+ """
57
+
58
+ #: ``source.format`` tag stamped on events produced by this source.
59
+ format: str
60
+
61
+ @abc.abstractmethod
62
+ def verify_and_normalize(
63
+ self, receipt: Dict[str, Any], *, agent_id_default: Optional[str] = None
64
+ ) -> ComplianceEvent:
65
+ """Verify ``receipt``'s signature and return a canonical event, or raise."""
66
+
67
+
68
+ class SignedActionReceiptSource(ReceiptSource):
69
+ """Consumes a signed Ed25519 **action receipt** — the commoditizing receipt
70
+ primitive several crypto-audit tools emit. This adapter targets the generic,
71
+ public shape of such a receipt so OpenWright can verify + ingest it; it is not
72
+ tied to any one producer (other formats plug in behind :class:`ReceiptSource`).
73
+
74
+ Receipt shape (all values JSON; ``signer.pubkey`` is base64 of the raw
75
+ 32-byte Ed25519 key and ``sig`` is base64 of the signature)::
76
+
77
+ {"action": {"tool": ..., "params_hash": "sha256:...", "target": ...},
78
+ "signer": {"pubkey": "<b64>", "name": ..., "owner": ...},
79
+ "ts": <rfc3339|unix>, "nonce": ..., "transport": ..., "sig": "<b64>"}
80
+
81
+ Mapping → ``ComplianceEvent(kind=tool_call)``: ``tool.name`` ← action.tool;
82
+ ``io.arguments_ref`` ← action.params_hash (already a ``sha256:`` ref — fits
83
+ cleanly with no PII); ``actor.agent_id`` ← signer.name; signer pubkey/owner →
84
+ ``actor.identity_claim``; nonce/target/transport → ``attributes``.
85
+ """
86
+
87
+ format = "receipt/action-v1"
88
+
89
+ def verify_and_normalize(
90
+ self, receipt: Dict[str, Any], *, agent_id_default: Optional[str] = None
91
+ ) -> ComplianceEvent:
92
+ if not isinstance(receipt, dict):
93
+ raise ReceiptVerificationError("receipt must be a JSON object")
94
+ action = receipt.get("action")
95
+ signer = receipt.get("signer")
96
+ if not isinstance(action, dict) or not isinstance(signer, dict):
97
+ raise ReceiptVerificationError("receipt missing action/signer object")
98
+
99
+ sig_b64 = receipt.get("sig")
100
+ pubkey_b64 = signer.get("pubkey")
101
+ if not sig_b64 or not pubkey_b64:
102
+ raise ReceiptVerificationError("receipt missing signature or signer pubkey")
103
+ try:
104
+ sig = base64.b64decode(sig_b64)
105
+ pub_raw = base64.b64decode(pubkey_b64)
106
+ except (ValueError, TypeError) as exc:
107
+ raise ReceiptVerificationError(f"malformed base64 in receipt: {exc}") from exc
108
+
109
+ # Verify BEFORE ingest. The signed bytes are the canonical receipt minus
110
+ # the signature itself, so any tampered field flips this to False.
111
+ if not verify_signature(pub_raw, sig, receipt_signing_bytes(receipt)):
112
+ raise ReceiptVerificationError("receipt Ed25519 signature did not verify")
113
+
114
+ ts = receipt.get("ts")
115
+ if ts is None:
116
+ raise ReceiptVerificationError("receipt missing ts")
117
+
118
+ # The signer pubkey/owner are carried (verifiably) into the identity
119
+ # claim; extra="allow" on IdentityClaim preserves the non-AgentCard fields.
120
+ identity_extra: Dict[str, Any] = {"public_key_b64": pubkey_b64}
121
+ if signer.get("owner") is not None:
122
+ identity_extra["owner"] = signer["owner"]
123
+ identity = IdentityClaim(
124
+ claim_type="receipt-signer",
125
+ public_key_id=key_id_for(pub_raw),
126
+ signature_b64=sig_b64,
127
+ **identity_extra,
128
+ )
129
+
130
+ attributes: Dict[str, Any] = {"receipt_verified": True}
131
+ if receipt.get("nonce") is not None:
132
+ attributes["nonce"] = receipt["nonce"]
133
+ if action.get("target") is not None:
134
+ attributes["target"] = action["target"]
135
+ if receipt.get("transport") is not None:
136
+ attributes["transport"] = receipt["transport"]
137
+
138
+ params_hash = action.get("params_hash")
139
+ return ComplianceEvent(
140
+ timestamp=to_rfc3339(ts),
141
+ kind=EventKind.TOOL_CALL,
142
+ actor=Actor(
143
+ agent_id=signer.get("name") or agent_id_default or "unknown-agent",
144
+ identity_claim=identity,
145
+ ),
146
+ io=IORef(arguments_ref=params_hash) if params_hash else None,
147
+ tool=ToolInfo(name=action.get("tool")) if action.get("tool") else None,
148
+ attributes=attributes,
149
+ source={"format": self.format},
150
+ )
151
+
152
+
153
+ _DEFAULT_SOURCE = SignedActionReceiptSource()
154
+
155
+
156
+ def receipt_to_event(
157
+ receipt: Dict[str, Any], *, agent_id_default: Optional[str] = None
158
+ ) -> ComplianceEvent:
159
+ """Verify + normalize a signed action receipt via the default receipt source."""
160
+ return _DEFAULT_SOURCE.verify_and_normalize(receipt, agent_id_default=agent_id_default)
161
+
162
+
163
+ def sign_receipt(
164
+ key: KeySource,
165
+ *,
166
+ tool: str,
167
+ params_hash: str,
168
+ signer_name: str,
169
+ ts: str,
170
+ nonce: str,
171
+ target: Optional[str] = None,
172
+ owner: Optional[str] = None,
173
+ transport: Optional[str] = None,
174
+ ) -> Dict[str, Any]:
175
+ """Produce a signed Ed25519 action receipt.
176
+
177
+ Stands in for an upstream receipt producer in the demo and tests, and defines
178
+ the canonical signing bytes once so producer and verifier agree.
179
+ ``signer.pubkey`` and ``sig`` are base64.
180
+ """
181
+ receipt: Dict[str, Any] = {
182
+ "action": {"tool": tool, "params_hash": params_hash},
183
+ "signer": {
184
+ "pubkey": base64.b64encode(key.public_key_raw()).decode("ascii"),
185
+ "name": signer_name,
186
+ },
187
+ "ts": ts,
188
+ "nonce": nonce,
189
+ }
190
+ if target is not None:
191
+ receipt["action"]["target"] = target
192
+ if owner is not None:
193
+ receipt["signer"]["owner"] = owner
194
+ if transport is not None:
195
+ receipt["transport"] = transport
196
+ sig = key.sign(receipt_signing_bytes(receipt))
197
+ receipt["sig"] = base64.b64encode(sig).decode("ascii")
198
+ return receipt
@@ -0,0 +1,68 @@
1
+ """SARIF side-channel ingest (FR-ING-07).
2
+
3
+ Consumes SARIF 2.1.0 findings from conformance/security scanners (e.g. a2a-tck,
4
+ Cisco a2a-scanner) and attaches them as ``conformance_finding`` events to an
5
+ agent. We *consume* scanner output; we do not re-implement scanning (OOS-03).
6
+ SARIF is treated as untrusted input (FR-ING-10, NFR-SEC-01): structure is
7
+ validated defensively and malformed entries are skipped, not crashed on.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from ..events import ComplianceEvent, EventKind
15
+
16
+
17
+ def sarif_to_events(
18
+ sarif: Dict[str, Any],
19
+ *,
20
+ agent_id: str,
21
+ timestamp: str,
22
+ task_id: Optional[str] = None,
23
+ ) -> List[ComplianceEvent]:
24
+ """Convert a SARIF log into conformance-finding events (best-effort, safe)."""
25
+ events: List[ComplianceEvent] = []
26
+ if not isinstance(sarif, dict):
27
+ return events
28
+ runs = sarif.get("runs")
29
+ if not isinstance(runs, list):
30
+ return events
31
+
32
+ for run in runs:
33
+ if not isinstance(run, dict):
34
+ continue
35
+ driver = (((run.get("tool") or {}).get("driver")) or {})
36
+ tool_name = driver.get("name") if isinstance(driver, dict) else None
37
+ results = run.get("results")
38
+ if not isinstance(results, list):
39
+ continue
40
+ for res in results:
41
+ if not isinstance(res, dict):
42
+ continue
43
+ message = ""
44
+ if isinstance(res.get("message"), dict):
45
+ message = str(res["message"].get("text", ""))
46
+ locations = []
47
+ for loc in res.get("locations", []) if isinstance(res.get("locations"), list) else []:
48
+ phys = loc.get("physicalLocation", {}) if isinstance(loc, dict) else {}
49
+ art = phys.get("artifactLocation", {}) if isinstance(phys, dict) else {}
50
+ region = phys.get("region", {}) if isinstance(phys, dict) else {}
51
+ locations.append({"uri": art.get("uri"), "startLine": region.get("startLine")})
52
+ events.append(
53
+ ComplianceEvent(
54
+ timestamp=timestamp,
55
+ kind=EventKind.CONFORMANCE_FINDING,
56
+ actor={"agent_id": agent_id},
57
+ provenance={"task_id": task_id} if task_id else {},
58
+ attributes={
59
+ "tool": tool_name,
60
+ "rule_id": res.get("ruleId"),
61
+ "level": res.get("level", "warning"),
62
+ "message": message,
63
+ "locations": locations,
64
+ },
65
+ source={"format": "sarif"},
66
+ )
67
+ )
68
+ return events
openwright/anchor.py ADDED
@@ -0,0 +1,134 @@
1
+ """External anchoring + non-equivocation detection (B7, FR-ATT-08, V14).
2
+
3
+ A single producer can *equivocate*: show one validly-signed history to party A
4
+ and a divergent one to party B (a "split view"). Signatures alone don't prevent
5
+ this — both views are signed by the producer's own key. The defense is to anchor
6
+ every signed checkpoint to an **external append-only mechanism** that independent
7
+ monitors read, so two inconsistent histories become simultaneously visible and a
8
+ third party can *prove* the equivocation.
9
+
10
+ This module provides:
11
+
12
+ * :class:`TransparencyAnchor` — an append-only log of published checkpoints (a
13
+ stand-in for a public transparency-log gossip endpoint / witness network). The
14
+ producer publishes every checkpoint; monitors read all of them. A file-backed
15
+ variant persists across processes so independent observers share one view.
16
+ * :func:`detect_equivocation` — given checkpoints gathered from the anchor and/or
17
+ different parties, returns an :class:`EquivocationProof` if the producer signed
18
+ two inconsistent tree heads, else ``None``.
19
+
20
+ Two inconsistencies are caught:
21
+
22
+ 1. **Same ``tree_size``, different roots** — both validly signed: a direct fork.
23
+ 2. **Non-extension** — for sizes ``m < n``, a supplied consistency proof between
24
+ the two signed roots fails to verify (the larger tree does not extend the
25
+ smaller), i.e. history was rewritten.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Dict, List, Optional, Tuple
34
+
35
+ from .merkle import verify_consistency
36
+ from .signing import Checkpoint
37
+
38
+
39
+ @dataclass
40
+ class EquivocationProof:
41
+ """Self-contained evidence that a producer presented divergent histories."""
42
+
43
+ reason: str
44
+ tree_size: int
45
+ checkpoint_a: dict
46
+ checkpoint_b: dict
47
+
48
+
49
+ class TransparencyAnchor:
50
+ """Append-only anchor of published checkpoints (in-memory or file-backed).
51
+
52
+ A file-backed anchor persists across processes, so independent observers
53
+ (producer + monitors) share exactly one append-only view.
54
+ """
55
+
56
+ def __init__(self, path: Optional[str] = None) -> None:
57
+ self.path = Path(path) if path else None
58
+ self._mem: List[dict] = []
59
+ if self.path is not None:
60
+ self.path.parent.mkdir(parents=True, exist_ok=True)
61
+ self.path.touch(exist_ok=True)
62
+
63
+ def publish(self, checkpoint: Checkpoint) -> None:
64
+ if self.path is None:
65
+ self._mem.append(checkpoint.model_dump())
66
+ return
67
+ with open(self.path, "a", encoding="utf-8") as fh:
68
+ fh.write(json.dumps(checkpoint.model_dump(), separators=(",", ":")) + "\n")
69
+
70
+ def checkpoints(self) -> List[dict]:
71
+ if self.path is None:
72
+ return list(self._mem)
73
+ with open(self.path, "r", encoding="utf-8") as fh:
74
+ return [json.loads(ln) for ln in fh if ln.strip()]
75
+
76
+
77
+ def _is_validly_signed(cp: dict, public_key_raw: bytes) -> bool:
78
+ try:
79
+ return Checkpoint.model_validate(cp).verify(public_key_raw)
80
+ except Exception: # noqa: BLE001
81
+ return False
82
+
83
+
84
+ def detect_equivocation(
85
+ checkpoints: List[dict],
86
+ public_key_raw: bytes,
87
+ *,
88
+ consistency_proofs: Optional[Dict[Tuple[int, int], List[str]]] = None,
89
+ ) -> Optional[EquivocationProof]:
90
+ """Return proof of a split view among ``checkpoints``, or ``None`` if consistent.
91
+
92
+ Only checkpoints validly signed by ``public_key_raw`` are considered (a forged
93
+ checkpoint is not the producer equivocating). ``consistency_proofs`` maps
94
+ ``(smaller_size, larger_size)`` to the producer-supplied proof hex; any pair
95
+ whose proof fails to verify is an equivocation.
96
+ """
97
+ valid = [cp for cp in checkpoints if _is_validly_signed(cp, public_key_raw)]
98
+
99
+ # 1. Two signed checkpoints at the same size with different roots = a fork.
100
+ by_size: Dict[int, dict] = {}
101
+ for cp in valid:
102
+ sz = int(cp["tree_size"])
103
+ prev = by_size.get(sz)
104
+ if prev is not None and prev["root_hash"] != cp["root_hash"]:
105
+ return EquivocationProof(
106
+ "two signed checkpoints at the same tree_size have different roots",
107
+ sz,
108
+ prev,
109
+ cp,
110
+ )
111
+ by_size[sz] = cp
112
+
113
+ # 2. A supplied consistency proof that fails = a rewritten (non-extending) history.
114
+ if consistency_proofs:
115
+ for (m, n), proof in consistency_proofs.items():
116
+ a, b = by_size.get(m), by_size.get(n)
117
+ if a is None or b is None or m >= n:
118
+ continue
119
+ ok = verify_consistency(
120
+ m,
121
+ n,
122
+ bytes.fromhex(a["root_hash"]),
123
+ bytes.fromhex(b["root_hash"]),
124
+ [bytes.fromhex(h) for h in proof],
125
+ )
126
+ if not ok:
127
+ return EquivocationProof(
128
+ "consistency proof between two signed checkpoints does not verify "
129
+ "(history was rewritten, not extended)",
130
+ n,
131
+ a,
132
+ b,
133
+ )
134
+ return None
openwright/authz.py ADDED
@@ -0,0 +1,68 @@
1
+ """Authorization for who may write evidence vs. generate reports (NFR-SEC-04).
2
+
3
+ A small capability model with segregation of duties: the party that *writes
4
+ evidence* need not be the party that *generates reports*, and neither need hold
5
+ the checkpoint signing key. Authorization is opt-in — pass a ``principal`` +
6
+ ``Authorizer`` to the SDK / report builder to enforce it; omit them to run
7
+ unauthenticated (the default, e.g. for the demo). The collector supports
8
+ optional bearer-token authentication mapping tokens to principals.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import enum
14
+ from dataclasses import dataclass, field
15
+ from typing import Dict, Optional, Set
16
+
17
+
18
+ class Capability(str, enum.Enum):
19
+ WRITE_EVIDENCE = "write_evidence"
20
+ GENERATE_REPORT = "generate_report"
21
+ ADMIN = "admin"
22
+
23
+
24
+ class AuthorizationError(PermissionError):
25
+ pass
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Principal:
30
+ id: str
31
+ capabilities: frozenset = field(default_factory=frozenset)
32
+
33
+ def has(self, cap: Capability) -> bool:
34
+ return Capability.ADMIN in self.capabilities or cap in self.capabilities
35
+
36
+
37
+ class Authorizer:
38
+ """Enforces capabilities. With no principal supplied, enforcement is off."""
39
+
40
+ def require(self, principal: Optional[Principal], capability: Capability) -> None:
41
+ if principal is None:
42
+ return # unauthenticated mode (opt-in auth)
43
+ if not principal.has(capability):
44
+ raise AuthorizationError(
45
+ f"principal {principal.id!r} lacks capability {capability.value!r}"
46
+ )
47
+
48
+
49
+ class TokenAuthority:
50
+ """Maps opaque bearer tokens to principals for the collector (NFR-SEC-04)."""
51
+
52
+ def __init__(self, tokens: Optional[Dict[str, Principal]] = None) -> None:
53
+ self._tokens = dict(tokens or {})
54
+
55
+ def add(self, token: str, principal: Principal) -> None:
56
+ self._tokens[token] = principal
57
+
58
+ def principal_for(self, token: Optional[str]) -> Optional[Principal]:
59
+ if not token:
60
+ return None
61
+ return self._tokens.get(token)
62
+
63
+ def authorize_bearer(self, authorization_header: Optional[str], capability: Capability) -> bool:
64
+ token = None
65
+ if authorization_header and authorization_header.lower().startswith("bearer "):
66
+ token = authorization_header[7:].strip()
67
+ principal = self.principal_for(token)
68
+ return principal is not None and principal.has(capability)
@@ -0,0 +1,197 @@
1
+ """OpenWright standalone verifier — ONE file, ZERO third-party dependencies.
2
+
3
+ This is the auditable root of trust in its purest form (FR-VER-03): pure Python,
4
+ standard library only, no `cryptography`, no network. It runs in CPython and,
5
+ unchanged, inside a stock WASM Python (Pyodide) in the browser (FR-VER-04 [P2],
6
+ see verifier.html). It is intentionally self-contained so a reviewer can read the
7
+ entire trust-critical surface in a single file.
8
+
9
+ It verifies a OpenWright signed report against its signed checkpoint:
10
+ * report + checkpoint Ed25519 signatures (pure-Python RFC 8032 verification),
11
+ * every event's leaf hash recomputed from hash-only content (no raw payloads),
12
+ * every inclusion proof against the signed root, and the full-tree root,
13
+ * that cited evidence events are present.
14
+
15
+ Usage (CLI): python openwright_verifier.py report.json public_key.pem
16
+ """
17
+
18
+ import base64
19
+ import hashlib
20
+ import json
21
+ import sys
22
+
23
+ # --- canonical JSON (RFC 8785 / JCS-compatible subset; must match producer) ---
24
+
25
+ def _clean(obj):
26
+ if isinstance(obj, dict):
27
+ return {k: _clean(v) for k, v in obj.items() if v is not None}
28
+ if isinstance(obj, (list, tuple)):
29
+ return [_clean(v) for v in obj]
30
+ if isinstance(obj, float):
31
+ raise ValueError("floats are not allowed in canonical evidence")
32
+ return obj
33
+
34
+ def canonical_bytes(obj):
35
+ return json.dumps(_clean(obj), sort_keys=True, separators=(",", ":"),
36
+ ensure_ascii=False, allow_nan=False).encode("utf-8")
37
+
38
+ # --- RFC 6962 Merkle verification ---
39
+
40
+ def _leaf_hash(data): return hashlib.sha256(b"\x00" + data).digest()
41
+ def _node_hash(l, r): return hashlib.sha256(b"\x01" + l + r).digest()
42
+
43
+ def _largest_pow2_lt(n):
44
+ k = 1
45
+ while k * 2 < n:
46
+ k *= 2
47
+ return k
48
+
49
+ def _tree_hash(leaves):
50
+ n = len(leaves)
51
+ if n == 0:
52
+ return hashlib.sha256(b"").digest()
53
+ if n == 1:
54
+ return leaves[0]
55
+ k = _largest_pow2_lt(n)
56
+ return _node_hash(_tree_hash(leaves[:k]), _tree_hash(leaves[k:]))
57
+
58
+ def _verify_inclusion(leaf_index, tree_size, leaf, proof, root):
59
+ if leaf_index >= tree_size or leaf_index < 0:
60
+ return False
61
+ fn, sn, r = leaf_index, tree_size - 1, leaf
62
+ for p in proof:
63
+ if sn == 0:
64
+ return False
65
+ if (fn & 1) == 1 or fn == sn:
66
+ r = _node_hash(p, r)
67
+ if (fn & 1) == 0:
68
+ while (fn & 1) == 0 and fn != 0:
69
+ fn >>= 1
70
+ sn >>= 1
71
+ else:
72
+ r = _node_hash(r, p)
73
+ fn >>= 1
74
+ sn >>= 1
75
+ return sn == 0 and r == root
76
+
77
+ # --- pure-Python Ed25519 verification (RFC 8032) ---
78
+
79
+ _P = 2**255 - 19
80
+ _LO = 2**252 + 27742317777372353535851937790883648493
81
+ def _inv(x): return pow(x, _P - 2, _P)
82
+ _D = (-121665 * _inv(121666)) % _P
83
+ _II = pow(2, (_P - 1) // 4, _P)
84
+ def _xrecover(y):
85
+ xx = (y * y - 1) * _inv(_D * y * y + 1)
86
+ x = pow(xx, (_P + 3) // 8, _P)
87
+ if (x * x - xx) % _P != 0:
88
+ x = (x * _II) % _P
89
+ if x % 2 != 0:
90
+ x = _P - x
91
+ return x
92
+ _BY = (4 * _inv(5)) % _P
93
+ _B = (_xrecover(_BY) % _P, _BY)
94
+ def _add(p1, p2):
95
+ x1, y1 = p1; x2, y2 = p2
96
+ x3 = (x1 * y2 + x2 * y1) * _inv(1 + _D * x1 * x2 * y1 * y2) % _P
97
+ y3 = (y1 * y2 + x1 * x2) * _inv(1 - _D * x1 * x2 * y1 * y2) % _P
98
+ return (x3, y3)
99
+ def _mul(point, e):
100
+ result, addend = (0, 1), point
101
+ while e > 0:
102
+ if e & 1:
103
+ result = _add(result, addend)
104
+ addend = _add(addend, addend)
105
+ e >>= 1
106
+ return result
107
+ def _on_curve(point):
108
+ x, y = point
109
+ return (-x * x + y * y - 1 - _D * x * x * y * y) % _P == 0
110
+ def _decodepoint(s):
111
+ val = int.from_bytes(s, "little")
112
+ y = val & ((1 << 255) - 1)
113
+ x = _xrecover(y)
114
+ if (x & 1) != ((val >> 255) & 1):
115
+ x = _P - x
116
+ point = (x, y)
117
+ if not _on_curve(point):
118
+ raise ValueError("bad point")
119
+ return point
120
+ def ed25519_verify(public_key, signature, message):
121
+ if len(signature) != 64 or len(public_key) != 32:
122
+ return False
123
+ try:
124
+ R = _decodepoint(signature[:32]); A = _decodepoint(public_key)
125
+ except Exception:
126
+ return False
127
+ S = int.from_bytes(signature[32:], "little")
128
+ if S >= _LO:
129
+ return False
130
+ h = int.from_bytes(hashlib.sha512(signature[:32] + public_key + message).digest(), "little") % _LO
131
+ return _mul(_B, S) == _add(R, _mul(A, h))
132
+
133
+ def pubkey_raw_from_pem(pem):
134
+ body = "".join(line for line in pem.splitlines() if "-----" not in line)
135
+ der = base64.b64decode(body)
136
+ return der[-32:] # Ed25519 SPKI ends with the 32-byte raw key
137
+
138
+ # --- report verification ---
139
+
140
+ def _key_id(pub): return "ed25519:" + hashlib.sha256(pub).hexdigest()[:32]
141
+
142
+ def verify_report(report, trusted_public_key_raw):
143
+ checks, valid = [], True
144
+ def chk(name, ok):
145
+ nonlocal valid
146
+ checks.append((name, ok))
147
+ valid = valid and ok
148
+
149
+ sig = report.get("signature", {})
150
+ chk("report_signature", ed25519_verify(
151
+ trusted_public_key_raw, base64.b64decode(sig.get("signature", "")),
152
+ canonical_bytes({k: v for k, v in report.items() if k != "signature"})))
153
+
154
+ cp = report.get("checkpoint", {})
155
+ cp_bytes = canonical_bytes({"origin": cp.get("origin"), "root_hash": cp.get("root_hash"),
156
+ "timestamp": cp.get("timestamp"), "tree_size": cp.get("tree_size")})
157
+ chk("checkpoint_signature", ed25519_verify(trusted_public_key_raw,
158
+ base64.b64decode(cp.get("signature", "")), cp_bytes)
159
+ and cp.get("public_key_id") == _key_id(trusted_public_key_raw))
160
+
161
+ root = bytes.fromhex(cp["root_hash"]); n = int(cp["tree_size"])
162
+ leaves, leaf_ok, incl_ok, present = {}, True, True, set()
163
+ for item in report.get("events", []):
164
+ ev = item.get("event", {}); idx = item.get("leaf_index")
165
+ present.add(ev.get("event_id"))
166
+ recomputed = _leaf_hash(canonical_bytes({k: v for k, v in ev.items() if k != "ledger"}))
167
+ stored = item.get("leaf_hash", "")
168
+ if recomputed != bytes.fromhex(stored.split(":")[-1] if stored else ""):
169
+ leaf_ok = False
170
+ if not _verify_inclusion(idx, n, recomputed, [bytes.fromhex(h) for h in item.get("inclusion_proof", [])], root):
171
+ incl_ok = False
172
+ leaves[idx] = recomputed
173
+ chk("event_leaf_hashes", leaf_ok)
174
+ chk("inclusion_proofs", incl_ok)
175
+ if len(leaves) == n and all(i in leaves for i in range(n)):
176
+ chk("full_tree_root", _tree_hash([leaves[i] for i in range(n)]) == root)
177
+
178
+ ev_ok = all(eid in present for c in report.get("controls", []) for eid in c.get("evidence_event_ids", []))
179
+ chk("evidence_events_present", ev_ok)
180
+ return valid, checks
181
+
182
+
183
+ def main(argv):
184
+ if len(argv) != 3:
185
+ print("usage: python openwright_verifier.py report.json public_key.pem")
186
+ return 2
187
+ report = json.load(open(argv[1]))
188
+ pub = pubkey_raw_from_pem(open(argv[2]).read())
189
+ valid, checks = verify_report(report, pub)
190
+ print("VALID:", valid)
191
+ for name, ok in checks:
192
+ print(f" [{'OK' if ok else 'FAIL'}] {name}")
193
+ return 0 if valid else 1
194
+
195
+
196
+ if __name__ == "__main__":
197
+ sys.exit(main(sys.argv))