witseal 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.
witseal/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """WitSeal Python — the Ecosystem SDK: consume, verify, and inspect WitSeal artifacts.
2
+
3
+ The Python line is the **SDK / verifier** layer. It provides
4
+ RFC 8785 canonicalization, SHA-256 hashing, Pydantic v2 wire-format
5
+ schemas, Ed25519 receipt verification, witness-event hash-chain and
6
+ evidence-package verification, a unified ``verify_artifact`` discriminator,
7
+ and keyless ``inspect``.
8
+
9
+ It does NOT generate artifacts or run a runtime: no signing/receipt
10
+ generation, no subprocess mediation, no policy evaluation, no witness
11
+ event-log append, no approval flow, no ``witseal exec``. Canonical
12
+ generation is the Rust trust core. Public API surface is not yet
13
+ frozen.
14
+ """
15
+
16
+ from importlib.metadata import PackageNotFoundError
17
+ from importlib.metadata import version as _version
18
+
19
+ try:
20
+ # Single source of truth: the version declared in pyproject.toml, surfaced
21
+ # through the installed distribution metadata, so __version__ can never
22
+ # drift from the packaged version.
23
+ __version__ = _version("witseal")
24
+ except PackageNotFoundError: # pragma: no cover - source tree without an install
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = ["__version__"]
witseal/__main__.py ADDED
@@ -0,0 +1,292 @@
1
+ """Verifier / SDK CLI for the Python track (SDK role: consume / verify).
2
+
3
+ Scope is intentionally narrow: schema + read-side verification + keyless
4
+ inspection. No runtime execution, subprocess mediation, policy evaluation,
5
+ artifact generation, or implicit public-key discovery — generation is the
6
+ Rust track.
7
+
8
+ Commands:
9
+
10
+ witseal verify receipt <path> --public-key <path-or-hex>
11
+ witseal verify evidence <path> [--public-key <path-or-hex>]
12
+ witseal verify artifact <path> [--public-key <path-or-hex>] (auto-discriminate)
13
+ witseal inspect <path> (keyless)
14
+
15
+ All commands print a single JSON object to stdout (sorted keys); exit code
16
+ is 0 for VALID, 1 for INVALID, 2 for an input/usage error.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import sys
24
+ from collections.abc import Mapping, Sequence
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
29
+
30
+ from witseal.inspect import inspect_artifact
31
+ from witseal.schemas.receipt import ReceiptV02
32
+ from witseal.verify import (
33
+ load_public_key_pem,
34
+ verify_artifact,
35
+ verify_evidence_package,
36
+ verify_receipt,
37
+ )
38
+ from witseal.verify.result import VerificationResult
39
+
40
+
41
+ def _build_parser() -> argparse.ArgumentParser:
42
+ parser = argparse.ArgumentParser(
43
+ prog="witseal",
44
+ description=(
45
+ "WitSeal Python verifier / SDK CLI "
46
+ "(schema + read-side verification + keyless inspection only)."
47
+ ),
48
+ )
49
+ subparsers = parser.add_subparsers(dest="command", required=True)
50
+
51
+ verify_parser = subparsers.add_parser("verify", help="Verify WitSeal artifacts")
52
+ verify_subparsers = verify_parser.add_subparsers(
53
+ dest="verify_command", required=True
54
+ )
55
+
56
+ receipt_parser = verify_subparsers.add_parser(
57
+ "receipt",
58
+ help="Verify a v0.2 receipt with an explicit Ed25519 public key",
59
+ )
60
+ receipt_parser.add_argument("receipt_path", type=Path, metavar="path")
61
+ receipt_parser.add_argument(
62
+ "--public-key",
63
+ required=True,
64
+ metavar="path-or-hex",
65
+ help="Ed25519 public key as a PEM file path or 32-byte raw hex string",
66
+ )
67
+
68
+ evidence_parser = verify_subparsers.add_parser(
69
+ "evidence",
70
+ help="Verify an evidence package (chain + per-receipt integrity)",
71
+ )
72
+ evidence_parser.add_argument("artifact_path", type=Path, metavar="path")
73
+ evidence_parser.add_argument(
74
+ "--public-key",
75
+ required=False,
76
+ default=None,
77
+ metavar="path-or-hex",
78
+ help="Ed25519 public key (required only if the package holds a v0.2 receipt)",
79
+ )
80
+
81
+ artifact_parser = verify_subparsers.add_parser(
82
+ "artifact",
83
+ help="Verify any WitSeal artifact (auto-discriminate on schema_version)",
84
+ )
85
+ artifact_parser.add_argument("artifact_path", type=Path, metavar="path")
86
+ artifact_parser.add_argument(
87
+ "--public-key",
88
+ required=False,
89
+ default=None,
90
+ metavar="path-or-hex",
91
+ help="Ed25519 public key (required for v0.2 receipts / packages with one)",
92
+ )
93
+
94
+ inspect_parser = subparsers.add_parser(
95
+ "inspect",
96
+ help="Keyless inspection of a WitSeal artifact (no signature check)",
97
+ )
98
+ inspect_parser.add_argument("artifact_path", type=Path, metavar="path")
99
+
100
+ return parser
101
+
102
+
103
+ def _load_receipt(path: Path) -> ReceiptV02:
104
+ return ReceiptV02.model_validate_json(path.read_bytes())
105
+
106
+
107
+ def _load_json_mapping(path: Path) -> Mapping[str, Any]:
108
+ value = json.loads(path.read_text(encoding="utf-8"))
109
+ if not isinstance(value, Mapping):
110
+ raise ValueError("artifact must be a JSON object")
111
+ return value
112
+
113
+
114
+ def _load_public_key_hex(value: str) -> Ed25519PublicKey:
115
+ normalized = value.strip()
116
+ if normalized.startswith(("0x", "0X")):
117
+ normalized = normalized[2:]
118
+
119
+ try:
120
+ raw = bytes.fromhex(normalized)
121
+ except ValueError as exc:
122
+ raise ValueError(
123
+ "public key must be an existing PEM path or 32-byte Ed25519 public key hex"
124
+ ) from exc
125
+
126
+ if len(raw) != 32:
127
+ raise ValueError("public key hex must decode to exactly 32 bytes")
128
+
129
+ return Ed25519PublicKey.from_public_bytes(raw)
130
+
131
+
132
+ def _load_public_key(value: str) -> Ed25519PublicKey:
133
+ path = Path(value).expanduser()
134
+ if path.is_file():
135
+ return load_public_key_pem(path.read_bytes())
136
+ return _load_public_key_hex(value)
137
+
138
+
139
+ def _emit(payload: dict[str, Any]) -> None:
140
+ sys.stdout.write(json.dumps(payload, sort_keys=True) + "\n")
141
+
142
+
143
+ def _print_result(result: VerificationResult) -> None:
144
+ _emit(
145
+ {
146
+ "valid": result.valid,
147
+ "receipt_hash_valid": result.receipt_hash_valid,
148
+ "signature_valid": result.signature_valid,
149
+ "reason": result.reason,
150
+ }
151
+ )
152
+
153
+
154
+ def _input_error(message: str) -> int:
155
+ sys.stderr.write(f"witseal: {message}\n")
156
+ return 2
157
+
158
+
159
+ def _resolve_optional_key(value: str | None) -> Ed25519PublicKey | None:
160
+ if value is None:
161
+ return None
162
+ return _load_public_key(value)
163
+
164
+
165
+ def _verify_receipt_command(args: argparse.Namespace) -> int:
166
+ try:
167
+ receipt = _load_receipt(args.receipt_path)
168
+ except OSError as exc:
169
+ return _input_error(f"could not read receipt '{args.receipt_path}': {exc}")
170
+ except ValueError as exc:
171
+ return _input_error(f"invalid receipt '{args.receipt_path}': {exc}")
172
+
173
+ try:
174
+ public_key = _load_public_key(args.public_key)
175
+ except OSError as exc:
176
+ return _input_error(f"could not read public key '{args.public_key}': {exc}")
177
+ except ValueError as exc:
178
+ return _input_error(f"invalid public key: {exc}")
179
+
180
+ try:
181
+ result = verify_receipt(receipt, public_key)
182
+ except ValueError as exc:
183
+ return _input_error(f"invalid receipt signature encoding: {exc}")
184
+
185
+ _print_result(result)
186
+ return 0 if result.valid else 1
187
+
188
+
189
+ def _verify_evidence_command(args: argparse.Namespace) -> int:
190
+ try:
191
+ artifact = _load_json_mapping(args.artifact_path)
192
+ except OSError as exc:
193
+ return _input_error(f"could not read artifact '{args.artifact_path}': {exc}")
194
+ except ValueError as exc:
195
+ return _input_error(f"invalid artifact '{args.artifact_path}': {exc}")
196
+
197
+ try:
198
+ public_key = _resolve_optional_key(args.public_key)
199
+ except OSError as exc:
200
+ return _input_error(f"could not read public key '{args.public_key}': {exc}")
201
+ except ValueError as exc:
202
+ return _input_error(f"invalid public key: {exc}")
203
+
204
+ result = verify_evidence_package(artifact, public_key)
205
+ _emit(
206
+ {
207
+ "valid": result.valid,
208
+ "kind": result.kind,
209
+ "reason": result.reason,
210
+ "chain_valid": result.chain_valid,
211
+ "receipt_results": [
212
+ {"index": r.index, "valid": r.valid, "reason": r.reason}
213
+ for r in result.receipt_results
214
+ ],
215
+ }
216
+ )
217
+ return 0 if result.valid else 1
218
+
219
+
220
+ def _verify_artifact_command(args: argparse.Namespace) -> int:
221
+ try:
222
+ artifact = _load_json_mapping(args.artifact_path)
223
+ except OSError as exc:
224
+ return _input_error(f"could not read artifact '{args.artifact_path}': {exc}")
225
+ except ValueError as exc:
226
+ return _input_error(f"invalid artifact '{args.artifact_path}': {exc}")
227
+
228
+ try:
229
+ public_key = _resolve_optional_key(args.public_key)
230
+ except OSError as exc:
231
+ return _input_error(f"could not read public key '{args.public_key}': {exc}")
232
+ except ValueError as exc:
233
+ return _input_error(f"invalid public key: {exc}")
234
+
235
+ result = verify_artifact(artifact, public_key)
236
+ _emit(
237
+ {
238
+ "valid": result.valid,
239
+ "kind": result.kind,
240
+ "schema_version": result.schema_version,
241
+ "reason": result.reason,
242
+ }
243
+ )
244
+ return 0 if result.valid else 1
245
+
246
+
247
+ def _inspect_command(args: argparse.Namespace) -> int:
248
+ try:
249
+ artifact = _load_json_mapping(args.artifact_path)
250
+ except OSError as exc:
251
+ return _input_error(f"could not read artifact '{args.artifact_path}': {exc}")
252
+ except ValueError as exc:
253
+ return _input_error(f"invalid artifact '{args.artifact_path}': {exc}")
254
+
255
+ inspection = inspect_artifact(artifact)
256
+ _emit(
257
+ {
258
+ "kind": inspection.kind,
259
+ "schema_version": inspection.schema_version,
260
+ "fields": inspection.fields,
261
+ "integrity": inspection.integrity,
262
+ "notes": list(inspection.notes),
263
+ "reason": inspection.reason,
264
+ }
265
+ )
266
+ # Inspection is descriptive, not a pass/fail gate: exit 0 unless the
267
+ # artifact was unrecognized / unparseable (kind == 'unknown' or a parse
268
+ # reason was set).
269
+ if inspection.kind == "unknown" or inspection.reason is not None:
270
+ return 1
271
+ return 0
272
+
273
+
274
+ def main(argv: Sequence[str] | None = None) -> int:
275
+ parser = _build_parser()
276
+ args = parser.parse_args(argv)
277
+
278
+ if args.command == "verify":
279
+ if args.verify_command == "receipt":
280
+ return _verify_receipt_command(args)
281
+ if args.verify_command == "evidence":
282
+ return _verify_evidence_command(args)
283
+ if args.verify_command == "artifact":
284
+ return _verify_artifact_command(args)
285
+ elif args.command == "inspect":
286
+ return _inspect_command(args)
287
+
288
+ parser.error("unsupported command")
289
+
290
+
291
+ if __name__ == "__main__":
292
+ raise SystemExit(main())
@@ -0,0 +1,10 @@
1
+ """Keyless artifact inspection — the SDK *consume* surface.
2
+
3
+ Exposes :func:`inspect_artifact`, which summarizes a WitSeal receipt or
4
+ evidence package and reports the integrity checks that require no public key.
5
+ Signature verification (key-requiring) lives in :mod:`witseal.verify`.
6
+ """
7
+
8
+ from witseal.inspect._inspect import ArtifactInspection, inspect_artifact
9
+
10
+ __all__ = ["ArtifactInspection", "inspect_artifact"]
@@ -0,0 +1,189 @@
1
+ """Keyless inspection of WitSeal artifacts — the SDK *consume* surface.
2
+
3
+ ``inspect`` answers "what is this artifact and what can I confirm about it
4
+ *without a key*". It parses a receipt or evidence package, summarizes its
5
+ salient fields, and reports the integrity checks that need no public key:
6
+
7
+ - receipt ``receipt_hash`` self-consistency (recomputed over the S1
8
+ pre-image for v0.2, over the body for v0.1);
9
+ - evidence-package chain integrity (linkage, self-hashes, sequence
10
+ monotonicity) and head-after-range match.
11
+
12
+ Checks that DO need a key — Ed25519 signature verification on v0.2 receipts —
13
+ are explicitly reported as *not checked here*; use ``verify`` with a public
14
+ key for those. Inspection never fails closed on a bad artifact: it returns a
15
+ structured report (including ``kind='unknown'`` for unrecognized input) so a
16
+ consumer can see what it received.
17
+
18
+ Read-side only. Python does not generate artifacts.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hmac
24
+ from collections.abc import Mapping
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ from pydantic import ValidationError
29
+
30
+ from witseal.integrity.hash import sha256_canonical
31
+ from witseal.integrity.signing import compute_receipt_hash
32
+ from witseal.schemas.evidence_package import EvidencePackage
33
+ from witseal.schemas.receipt import ExecutionReceipt, ReceiptV02
34
+ from witseal.verify.chain import verify_chain
35
+
36
+ _RECEIPT_HASH_FIELD = "receipt_hash"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ArtifactInspection:
41
+ """Structured, keyless summary of a WitSeal artifact.
42
+
43
+ ``fields`` holds salient identifiers/values for display. ``integrity``
44
+ holds the keyless checks that were run (name -> bool). ``notes`` lists
45
+ things a consumer should know — notably which checks require a key and
46
+ were therefore not run here.
47
+ """
48
+
49
+ kind: str
50
+ schema_version: str | None = None
51
+ fields: dict[str, Any] = field(default_factory=dict)
52
+ integrity: dict[str, bool] = field(default_factory=dict)
53
+ notes: tuple[str, ...] = field(default_factory=tuple)
54
+ reason: str | None = None
55
+
56
+
57
+ def _read_schema_version(value: Any) -> str | None: # noqa: ANN401
58
+ if isinstance(value, Mapping):
59
+ sv = value.get("schema_version")
60
+ return sv if isinstance(sv, str) else None
61
+ return None
62
+
63
+
64
+ def _inspect_receipt_v01(value: Mapping[str, Any]) -> ArtifactInspection:
65
+ try:
66
+ receipt = ExecutionReceipt.model_validate(dict(value))
67
+ except (ValidationError, ValueError) as exc:
68
+ return ArtifactInspection(
69
+ kind="receipt.v0.1",
70
+ schema_version="witseal.receipt.v0.1",
71
+ reason=f"schema validation failed: {exc}",
72
+ )
73
+ body = receipt.model_dump(by_alias=True)
74
+ body.pop(_RECEIPT_HASH_FIELD, None)
75
+ receipt_hash_ok = hmac.compare_digest(
76
+ sha256_canonical(body), receipt.receipt_hash
77
+ )
78
+ return ArtifactInspection(
79
+ kind="receipt.v0.1",
80
+ schema_version="witseal.receipt.v0.1",
81
+ fields={
82
+ "receipt_id": receipt.receipt_id,
83
+ "witness_event_id": receipt.witness_event_id,
84
+ "chain_segment_id": receipt.chain_segment_id,
85
+ "outcome": receipt.outcome,
86
+ "finalized_at": receipt.finalized_at,
87
+ },
88
+ integrity={"receipt_hash_self_consistent": receipt_hash_ok},
89
+ notes=("v0.1 receipts are unsigned; no signature check applies.",),
90
+ )
91
+
92
+
93
+ def _inspect_receipt_v02(value: Mapping[str, Any]) -> ArtifactInspection:
94
+ try:
95
+ receipt = ReceiptV02.model_validate(dict(value))
96
+ except (ValidationError, ValueError) as exc:
97
+ return ArtifactInspection(
98
+ kind="receipt.v0.2",
99
+ schema_version="witseal.receipt.v0.2",
100
+ reason=f"schema validation failed: {exc}",
101
+ )
102
+ receipt_hash_ok = hmac.compare_digest(
103
+ compute_receipt_hash(receipt), receipt.receipt_hash
104
+ )
105
+ signature_algorithm = receipt.signature.split(":", 1)[0] if ":" in receipt.signature else None
106
+ return ArtifactInspection(
107
+ kind="receipt.v0.2",
108
+ schema_version="witseal.receipt.v0.2",
109
+ fields={
110
+ "receipt_id": receipt.receipt_id,
111
+ "witness_event_id": receipt.witness_event_id,
112
+ "chain_segment_id": receipt.chain_segment_id,
113
+ "outcome": receipt.outcome,
114
+ "finalized_at": receipt.finalized_at,
115
+ "artifact_type": receipt.artifact_type,
116
+ "build_id": receipt.build_id,
117
+ "git_commit": receipt.git_commit,
118
+ "signature_algorithm": signature_algorithm,
119
+ "is_genesis": receipt.prev_hash is None,
120
+ },
121
+ integrity={"receipt_hash_self_consistent": receipt_hash_ok},
122
+ notes=(
123
+ "signature verification requires a public key; not checked by "
124
+ "inspect — use `verify` with --public-key.",
125
+ ),
126
+ )
127
+
128
+
129
+ def _inspect_evidence(value: Mapping[str, Any]) -> ArtifactInspection:
130
+ envelope_raw = {**value, "receipts": []}
131
+ try:
132
+ envelope = EvidencePackage.model_validate(envelope_raw)
133
+ except (ValidationError, ValueError) as exc:
134
+ return ArtifactInspection(
135
+ kind="evidence-package.v0.1",
136
+ schema_version="witseal.evidence-package.v0.1",
137
+ reason=f"schema validation failed: {exc}",
138
+ )
139
+ chain = verify_chain(envelope.events, envelope.chain_head_before_range)
140
+ recomputed_head = envelope.events[-1].event_hash if envelope.events else None
141
+ head_ok = recomputed_head == envelope.chain_head_after_range
142
+ raw_receipts = value.get("receipts")
143
+ receipt_count = len(raw_receipts) if isinstance(raw_receipts, list) else 0
144
+ return ArtifactInspection(
145
+ kind="evidence-package.v0.1",
146
+ schema_version="witseal.evidence-package.v0.1",
147
+ fields={
148
+ "package_id": envelope.package_id,
149
+ "chain_segment_id": envelope.chain_segment_id,
150
+ "exported_at": envelope.exported_at,
151
+ "range_start": envelope.range.start_sequence,
152
+ "range_end": envelope.range.end_sequence,
153
+ "event_count": len(envelope.events),
154
+ "receipt_count": receipt_count,
155
+ "policy_pack_count": len(envelope.policy_packs),
156
+ },
157
+ integrity={
158
+ "chain_valid": chain.valid,
159
+ "chain_head_after_range_matches": head_ok,
160
+ },
161
+ notes=(
162
+ "receipt signature verification requires a public key; not "
163
+ "checked by inspect — use `verify` with --public-key.",
164
+ )
165
+ + ((f"chain broke at index {chain.broken_at}: {chain.reason}",) if not chain.valid else ()),
166
+ )
167
+
168
+
169
+ def inspect_artifact(value: Mapping[str, Any]) -> ArtifactInspection:
170
+ """Inspect *value* (a parsed JSON mapping) and return a keyless summary.
171
+
172
+ Discriminates on ``schema_version``; returns ``kind='unknown'`` for an
173
+ unrecognized artifact rather than raising.
174
+ """
175
+ schema_version = _read_schema_version(value)
176
+ if schema_version == "witseal.receipt.v0.1":
177
+ return _inspect_receipt_v01(value)
178
+ if schema_version == "witseal.receipt.v0.2":
179
+ return _inspect_receipt_v02(value)
180
+ if schema_version == "witseal.evidence-package.v0.1":
181
+ return _inspect_evidence(value)
182
+ reason = (
183
+ "no schema_version field: not a recognized WitSeal artifact"
184
+ if schema_version is None
185
+ else f"unrecognized schema_version '{schema_version}'"
186
+ )
187
+ return ArtifactInspection(
188
+ kind="unknown", schema_version=schema_version, reason=reason
189
+ )
@@ -0,0 +1,7 @@
1
+ """Integrity primitives: canonical JSON serialization, hashing, signing bytes."""
2
+
3
+ from witseal.integrity.canonical_json import canonicalize
4
+ from witseal.integrity.hash import sha256_canonical
5
+ from witseal.integrity.signing import compute_signing_bytes
6
+
7
+ __all__ = ["canonicalize", "compute_signing_bytes", "sha256_canonical"]
@@ -0,0 +1,18 @@
1
+ """Canonical JSON serialization per RFC 8785 (JSON Canonicalization Scheme)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import rfc8785
8
+
9
+
10
+ def canonicalize(value: Any) -> bytes: # noqa: ANN401
11
+ """Serialize *value* to RFC 8785 canonical JSON as UTF-8 bytes.
12
+
13
+ Output is byte-deterministic for any JSON-compatible Python value
14
+ (``dict`` with ``str`` keys, ``list``/``tuple``, ``str``, ``int``,
15
+ ``float``, ``bool``, ``None``). The result is the input to evidence-chain
16
+ hashing; see :func:`witseal.integrity.hash.sha256_canonical`.
17
+ """
18
+ return rfc8785.dumps(value)
@@ -0,0 +1,29 @@
1
+ """SHA-256 hashing primitives for the evidence chain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from typing import Any
7
+
8
+ from witseal.integrity.canonical_json import canonicalize
9
+
10
+
11
+ def sha256_hex_of_bytes(data: bytes) -> str:
12
+ """Return lowercase-hex SHA-256 of raw *data* bytes.
13
+
14
+ Used by the v0.2 verifier path: ``receipt_hash`` is the SHA-256 of the
15
+ already-canonical S1 pre-image bytes (see
16
+ :func:`witseal.integrity.signing.compute_receipt_hash`), so the input
17
+ must NOT be canonicalized again. Output matches the ``Sha256Hex``
18
+ primitive (64 lowercase hex chars).
19
+ """
20
+ return hashlib.sha256(data).hexdigest()
21
+
22
+
23
+ def sha256_canonical(value: Any) -> str: # noqa: ANN401
24
+ """Return lowercase-hex SHA-256 of the RFC 8785 canonical JSON form of *value*.
25
+
26
+ Matches the ``Sha256Hex`` primitive in :mod:`witseal.schemas._primitives`
27
+ (64 lowercase hex chars).
28
+ """
29
+ return sha256_hex_of_bytes(canonicalize(value))
@@ -0,0 +1,58 @@
1
+ """Witness-event hashing primitive — mirror of TS `src/integrity/hash-chain.ts`.
2
+
3
+ The witness-event hash chain links events via ``previous_event_hash``; each
4
+ event self-attests via ``event_hash``. The hashing rule (cross-track canon,
5
+ wire-format spec § 3.3 / TS ``hashEvent``):
6
+
7
+ event_hash = SHA-256( canonicalize( event WITHOUT the event_hash field ) )
8
+
9
+ The ``event_hash`` key is OMITTED entirely from the pre-image — not set to
10
+ ``null``, not set to ``""`` — exactly as the TS reference strips it
11
+ (``const { event_hash, ...draft } = event``). Canonicalization is RFC 8785
12
+ via :func:`witseal.integrity.canonical_json.canonicalize`, the same
13
+ byte-identity-proven canonicalizer used for the golden receipt
14
+ (``8fc29592…``). Because event_hash is a deterministic composition over that
15
+ canonicalizer with a fixed field-omission rule, an event_hash computed here
16
+ matches the value any conforming track (TS / Rust) computes for the same
17
+ event bytes.
18
+
19
+ Python is the ecosystem-facing SDK: it *consumes and verifies* events, it
20
+ does not generate them at runtime. This module is the read-side primitive
21
+ the chain verifier (:mod:`witseal.verify.chain`) builds on.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Mapping
27
+ from typing import Any
28
+
29
+ from witseal.integrity.canonical_json import canonicalize
30
+ from witseal.integrity.hash import sha256_hex_of_bytes
31
+ from witseal.schemas.witness_event import WitnessEvent
32
+
33
+ EVENT_HASH_FIELD: str = "event_hash"
34
+ PREVIOUS_EVENT_HASH_FIELD: str = "previous_event_hash"
35
+
36
+
37
+ def _event_body(event: WitnessEvent | Mapping[str, Any]) -> dict[str, Any]:
38
+ """Return the canonical wire dict of *event* (by-alias), as a plain dict.
39
+
40
+ Accepts a parsed :class:`WitnessEvent` (preferred — the §7 model
41
+ serializer applies, so unset ``identity_origin`` / ``operation_id`` are
42
+ omitted, preserving pre-§7 byte-identity) or a raw mapping (used when a
43
+ caller already holds wire bytes and only needs the hash recomputation).
44
+ """
45
+ if isinstance(event, WitnessEvent):
46
+ return event.model_dump(by_alias=True)
47
+ return dict(event)
48
+
49
+
50
+ def hash_event(event: WitnessEvent | Mapping[str, Any]) -> str:
51
+ """Compute the ``event_hash`` for *event* (lowercase hex).
52
+
53
+ Strips the ``event_hash`` field, canonicalizes the remainder per
54
+ RFC 8785, and returns the SHA-256 hex. The input is never mutated.
55
+ """
56
+ body = _event_body(event)
57
+ body.pop(EVENT_HASH_FIELD, None)
58
+ return sha256_hex_of_bytes(canonicalize(body))