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 +27 -0
- witseal/__main__.py +292 -0
- witseal/inspect/__init__.py +10 -0
- witseal/inspect/_inspect.py +189 -0
- witseal/integrity/__init__.py +7 -0
- witseal/integrity/canonical_json.py +18 -0
- witseal/integrity/hash.py +29 -0
- witseal/integrity/hash_chain.py +58 -0
- witseal/integrity/signing.py +104 -0
- witseal/schemas/__init__.py +92 -0
- witseal/schemas/_primitives.py +95 -0
- witseal/schemas/approval.py +85 -0
- witseal/schemas/evidence_package.py +46 -0
- witseal/schemas/execution_result.py +39 -0
- witseal/schemas/intent.py +82 -0
- witseal/schemas/policy.py +133 -0
- witseal/schemas/receipt.py +134 -0
- witseal/schemas/witness_event.py +116 -0
- witseal/verify/__init__.py +50 -0
- witseal/verify/artifact.py +170 -0
- witseal/verify/chain.py +110 -0
- witseal/verify/ed25519.py +89 -0
- witseal/verify/evidence.py +229 -0
- witseal/verify/receipt.py +79 -0
- witseal/verify/result.py +39 -0
- witseal-0.1.0.dist-info/METADATA +158 -0
- witseal-0.1.0.dist-info/RECORD +30 -0
- witseal-0.1.0.dist-info/WHEEL +4 -0
- witseal-0.1.0.dist-info/entry_points.txt +3 -0
- witseal-0.1.0.dist-info/licenses/LICENSE +201 -0
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))
|