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.
- openwright/__init__.py +20 -0
- openwright/_ed25519_pure.py +98 -0
- openwright/adapters/__init__.py +37 -0
- openwright/adapters/a2a.py +71 -0
- openwright/adapters/base.py +24 -0
- openwright/adapters/langfuse.py +56 -0
- openwright/adapters/otel_genai.py +180 -0
- openwright/adapters/policy.py +63 -0
- openwright/adapters/receipt.py +198 -0
- openwright/adapters/sarif_in.py +68 -0
- openwright/anchor.py +134 -0
- openwright/authz.py +68 -0
- openwright/browser_verifier.py +197 -0
- openwright/canonical.py +119 -0
- openwright/checkpoint_store.py +140 -0
- openwright/cli.py +445 -0
- openwright/connectors/__init__.py +236 -0
- openwright/connectors/builtin.py +88 -0
- openwright/crosswalk.py +372 -0
- openwright/crosswalk_loader.py +53 -0
- openwright/crosswalks/CHANGELOG.md +47 -0
- openwright/crosswalks/__init__.py +7 -0
- openwright/crosswalks/eu_ai_act.yaml +294 -0
- openwright/crosswalks/eu_ai_act_v1.yaml +107 -0
- openwright/crosswalks/gdpr.yaml +304 -0
- openwright/crosswalks/iso_42001.yaml +209 -0
- openwright/crosswalks/nist_ai_rmf.yaml +204 -0
- openwright/crosswalks/soc2.yaml +246 -0
- openwright/dashboard.py +100 -0
- openwright/demo.py +270 -0
- openwright/events.py +210 -0
- openwright/identity.py +61 -0
- openwright/ingest/__init__.py +13 -0
- openwright/ingest/durable.py +144 -0
- openwright/ingest/fanout.py +38 -0
- openwright/ingest/grpc_server.py +44 -0
- openwright/ingest/http_server.py +163 -0
- openwright/ingest/otlp_common.py +69 -0
- openwright/ingest/pipeline.py +307 -0
- openwright/ledger.py +479 -0
- openwright/merkle.py +262 -0
- openwright/report.py +388 -0
- openwright/sbom.py +42 -0
- openwright/scheduler.py +97 -0
- openwright/sdk.py +297 -0
- openwright/signing.py +343 -0
- openwright/spec.py +109 -0
- openwright/vault.py +55 -0
- openwright/verify.py +392 -0
- openwright/web_demo.py +275 -0
- openwright/witness.py +90 -0
- openwright/witness_service.py +136 -0
- openwright_core-0.6.0.dist-info/METADATA +174 -0
- openwright_core-0.6.0.dist-info/RECORD +57 -0
- openwright_core-0.6.0.dist-info/WHEEL +4 -0
- openwright_core-0.6.0.dist-info/entry_points.txt +3 -0
- 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))
|