witnora 0.9.3 → 0.10.2

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 (77) hide show
  1. package/README.md +76 -9
  2. package/dist/assurance-contract.js +312 -0
  3. package/dist/assurance-loop-demo.js +245 -0
  4. package/dist/authorization-state-machine.js +35 -0
  5. package/dist/authorization-v02.js +60 -0
  6. package/dist/bundle.js +22 -0
  7. package/dist/canonical-v02.js +197 -0
  8. package/dist/canonical.js +31 -0
  9. package/dist/cli.js +132 -0
  10. package/dist/command-help.js +81 -0
  11. package/dist/control-plane.js +12 -3
  12. package/dist/credentials.js +1 -1
  13. package/dist/design-partner-pilot.js +402 -0
  14. package/dist/design-partner-v02.js +449 -0
  15. package/dist/evidence-v02.js +118 -0
  16. package/dist/generic-eval.js +160 -0
  17. package/dist/github-design-partner-v02.js +399 -0
  18. package/dist/github-live-v02.js +45 -0
  19. package/dist/guided-setup.js +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/offline-verifier.js +375 -0
  22. package/dist/onboard.js +132 -0
  23. package/dist/onboarding-templates.js +49 -6
  24. package/dist/outcome-evaluator.js +51 -0
  25. package/dist/privacy-manifest.js +273 -0
  26. package/dist/probe-protocol-v02.js +101 -0
  27. package/dist/runtime-context.js +75 -0
  28. package/dist/sandbox.js +1 -1
  29. package/dist/schema-validator.js +141 -3
  30. package/dist/trust-v02.js +111 -0
  31. package/dist/try.js +1 -1
  32. package/dist/vendor/design-partner/failure-exercises.mjs +353 -0
  33. package/dist/vendor/onegent-runtime/browser-enforcement-runtime.d.ts +8 -0
  34. package/dist/vendor/onegent-runtime/browser-enforcement-runtime.d.ts.map +1 -1
  35. package/dist/vendor/onegent-runtime/browser-enforcement-runtime.js +43 -1
  36. package/dist/vendor/witnora-probe/canonical.d.ts +3 -0
  37. package/dist/vendor/witnora-probe/canonical.js +39 -0
  38. package/dist/vendor/witnora-probe/cli.d.ts +2 -0
  39. package/dist/vendor/witnora-probe/cli.js +36 -0
  40. package/dist/vendor/witnora-probe/config.d.ts +2 -0
  41. package/dist/vendor/witnora-probe/config.js +63 -0
  42. package/dist/vendor/witnora-probe/credential-resolver.d.ts +6 -0
  43. package/dist/vendor/witnora-probe/credential-resolver.js +17 -0
  44. package/dist/vendor/witnora-probe/crypto.d.ts +2 -0
  45. package/dist/vendor/witnora-probe/crypto.js +14 -0
  46. package/dist/vendor/witnora-probe/emulator.d.ts +51 -0
  47. package/dist/vendor/witnora-probe/emulator.js +164 -0
  48. package/dist/vendor/witnora-probe/index.d.ts +11 -0
  49. package/dist/vendor/witnora-probe/index.js +11 -0
  50. package/dist/vendor/witnora-probe/logger.d.ts +3 -0
  51. package/dist/vendor/witnora-probe/logger.js +26 -0
  52. package/dist/vendor/witnora-probe/metrics.d.ts +12 -0
  53. package/dist/vendor/witnora-probe/metrics.js +31 -0
  54. package/dist/vendor/witnora-probe/probe.d.ts +22 -0
  55. package/dist/vendor/witnora-probe/probe.js +208 -0
  56. package/dist/vendor/witnora-probe/request-verifier.d.ts +9 -0
  57. package/dist/vendor/witnora-probe/request-verifier.js +84 -0
  58. package/dist/vendor/witnora-probe/server.d.ts +9 -0
  59. package/dist/vendor/witnora-probe/server.js +85 -0
  60. package/dist/vendor/witnora-probe/storage.d.ts +16 -0
  61. package/dist/vendor/witnora-probe/storage.js +114 -0
  62. package/dist/vendor/witnora-probe/types.d.ts +126 -0
  63. package/dist/vendor/witnora-probe/types.js +2 -0
  64. package/dist/vendor/witnora-verifier-python/README.md +43 -0
  65. package/dist/vendor/witnora-verifier-python/pyproject.toml +23 -0
  66. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/__init__.py +33 -0
  67. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/canonical.py +218 -0
  68. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/cli.py +75 -0
  69. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/conformance.py +124 -0
  70. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/crypto.py +139 -0
  71. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/errors.py +15 -0
  72. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/event_chain.py +80 -0
  73. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/evidence.py +336 -0
  74. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/probe.py +188 -0
  75. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/producer.py +127 -0
  76. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/trust.py +337 -0
  77. package/package.json +5 -5
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ from decimal import Decimal, InvalidOperation
7
+ from typing import Any
8
+
9
+ from .errors import VerificationError, require
10
+
11
+ CANONICAL_VERSION = "witnora.canonical_json.v0.2"
12
+ MAX_SAFE_INTEGER = (1 << 53) - 1
13
+ MAX_DEPTH = 64
14
+
15
+
16
+ def loads_strict(data: str | bytes | bytearray) -> Any:
17
+ """Parse JSON while retaining enough lexical information to reject ambiguity."""
18
+
19
+ if isinstance(data, (bytes, bytearray)):
20
+ try:
21
+ text = bytes(data).decode("utf-8", "strict")
22
+ except UnicodeDecodeError as exc:
23
+ raise VerificationError("INVALID_UTF8", "JSON input is not valid UTF-8.") from exc
24
+ elif isinstance(data, str):
25
+ text = data
26
+ else:
27
+ raise VerificationError("INVALID_INPUT", "JSON input must be text or UTF-8 bytes.")
28
+
29
+ def object_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
30
+ output: dict[str, Any] = {}
31
+ for key, value in pairs:
32
+ if key in output:
33
+ raise VerificationError(
34
+ "DUPLICATE_KEY", f"Duplicate object key {key!r} is forbidden."
35
+ )
36
+ output[key] = value
37
+ return output
38
+
39
+ def parse_int(token: str) -> int:
40
+ if token == "-0":
41
+ raise VerificationError("NEGATIVE_ZERO", "Negative zero is forbidden.")
42
+ value = int(token, 10)
43
+ require(
44
+ abs(value) <= MAX_SAFE_INTEGER,
45
+ "UNSAFE_INTEGER",
46
+ "Integer exceeds the interoperable safe range.",
47
+ )
48
+ return value
49
+
50
+ def parse_decimal(token: str) -> int:
51
+ try:
52
+ value = Decimal(token)
53
+ except InvalidOperation as exc:
54
+ raise VerificationError("INVALID_NUMBER", "Decimal token is invalid.") from exc
55
+ require(value.is_finite(), "NON_FINITE_NUMBER", "Non-finite numbers are forbidden.")
56
+ require(
57
+ not value.is_zero() or not value.is_signed(),
58
+ "NEGATIVE_ZERO",
59
+ "Negative zero is forbidden.",
60
+ )
61
+ require(
62
+ value == value.to_integral_value(),
63
+ "NON_INTEGER_NUMBER",
64
+ "JSON numbers must be safe integers; encode decimals as normalized strings.",
65
+ )
66
+ integer = int(value)
67
+ require(
68
+ abs(integer) <= MAX_SAFE_INTEGER,
69
+ "UNSAFE_INTEGER",
70
+ "Integer exceeds the interoperable safe range.",
71
+ )
72
+ return integer
73
+
74
+ def parse_constant(token: str) -> Any:
75
+ raise VerificationError("NON_FINITE_NUMBER", f"{token} is forbidden.")
76
+
77
+ try:
78
+ value = json.loads(
79
+ text,
80
+ object_pairs_hook=object_pairs,
81
+ parse_int=parse_int,
82
+ parse_float=parse_decimal,
83
+ parse_constant=parse_constant,
84
+ )
85
+ except VerificationError:
86
+ raise
87
+ except (json.JSONDecodeError, UnicodeError) as exc:
88
+ raise VerificationError("INVALID_JSON", "Input is not strict JSON.") from exc
89
+ _validate_value(value, depth=0)
90
+ return value
91
+
92
+
93
+ def canonical_json(value: Any) -> str:
94
+ """Return the frozen Witnora v0.2 canonical JSON representation."""
95
+
96
+ _validate_value(value, depth=0)
97
+ return _encode(value)
98
+
99
+
100
+ def canonical_bytes(value: Any) -> bytes:
101
+ try:
102
+ return canonical_json(value).encode("utf-8", "strict")
103
+ except UnicodeEncodeError as exc:
104
+ raise VerificationError(
105
+ "INVALID_UNICODE", "Canonical JSON contains an unpaired surrogate."
106
+ ) from exc
107
+
108
+
109
+ def sha256_canonical(value: Any) -> str:
110
+ return hashlib.sha256(canonical_bytes(value)).hexdigest()
111
+
112
+
113
+ def _encode(value: Any) -> str:
114
+ if value is None:
115
+ return "null"
116
+ if value is True:
117
+ return "true"
118
+ if value is False:
119
+ return "false"
120
+ if isinstance(value, str):
121
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
122
+ if isinstance(value, int):
123
+ return str(value)
124
+ if isinstance(value, Decimal):
125
+ return str(int(value))
126
+ if isinstance(value, float):
127
+ return str(int(value))
128
+ if isinstance(value, (list, tuple)):
129
+ return "[" + ",".join(_encode(item) for item in value) + "]"
130
+ if isinstance(value, dict):
131
+ return (
132
+ "{"
133
+ + ",".join(
134
+ f"{json.dumps(key, ensure_ascii=False, separators=(',', ':'))}:{_encode(value[key])}"
135
+ for key in sorted(value, key=_utf16_sort_key)
136
+ )
137
+ + "}"
138
+ )
139
+ raise VerificationError(
140
+ "UNSUPPORTED_TYPE", f"Type {type(value).__name__} is not canonical JSON."
141
+ )
142
+
143
+
144
+ def _validate_value(value: Any, depth: int) -> None:
145
+ require(
146
+ depth <= MAX_DEPTH,
147
+ "MAX_DEPTH_EXCEEDED",
148
+ f"JSON exceeds the v0.2 nesting limit of {MAX_DEPTH}.",
149
+ )
150
+ if value is None or isinstance(value, bool):
151
+ return
152
+ if isinstance(value, str):
153
+ _validate_unicode(value)
154
+ return
155
+ if isinstance(value, int):
156
+ require(
157
+ abs(value) <= MAX_SAFE_INTEGER,
158
+ "UNSAFE_INTEGER",
159
+ "Integer exceeds the interoperable safe range.",
160
+ )
161
+ return
162
+ if isinstance(value, Decimal):
163
+ _validate_number(value)
164
+ return
165
+ if isinstance(value, float):
166
+ require(math.isfinite(value), "NON_FINITE_NUMBER", "Non-finite numbers are forbidden.")
167
+ require(
168
+ not (value == 0 and math.copysign(1.0, value) < 0),
169
+ "NEGATIVE_ZERO",
170
+ "Negative zero is forbidden.",
171
+ )
172
+ _validate_number(Decimal(str(value)))
173
+ return
174
+ if isinstance(value, (list, tuple)):
175
+ for item in value:
176
+ _validate_value(item, depth + 1)
177
+ return
178
+ if isinstance(value, dict):
179
+ for key, item in value.items():
180
+ require(isinstance(key, str), "NON_STRING_KEY", "Object keys must be strings.")
181
+ _validate_unicode(key)
182
+ _validate_value(item, depth + 1)
183
+ return
184
+ raise VerificationError(
185
+ "UNSUPPORTED_TYPE", f"Type {type(value).__name__} is not canonical JSON."
186
+ )
187
+
188
+
189
+ def _validate_unicode(value: str) -> None:
190
+ try:
191
+ value.encode("utf-8", "strict")
192
+ except UnicodeEncodeError as exc:
193
+ raise VerificationError(
194
+ "INVALID_UNICODE", "Unpaired Unicode surrogates are forbidden."
195
+ ) from exc
196
+
197
+
198
+ def _validate_number(value: Decimal) -> None:
199
+ require(value.is_finite(), "NON_FINITE_NUMBER", "Non-finite numbers are forbidden.")
200
+ require(
201
+ not value.is_zero() or not value.is_signed(), "NEGATIVE_ZERO", "Negative zero is forbidden."
202
+ )
203
+ require(
204
+ value == value.to_integral_value(),
205
+ "NON_INTEGER_NUMBER",
206
+ "JSON numbers must be safe integers; encode decimals as normalized strings.",
207
+ )
208
+ require(
209
+ abs(value) <= MAX_SAFE_INTEGER,
210
+ "UNSAFE_INTEGER",
211
+ "Integer exceeds the interoperable safe range.",
212
+ )
213
+
214
+
215
+ def _utf16_sort_key(value: str) -> bytes:
216
+ """Match JavaScript Array.sort() ordering for cross-language object keys."""
217
+
218
+ return value.encode("utf-16-be", "surrogatepass")
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ from .canonical import canonical_json, loads_strict, sha256_canonical
10
+ from .conformance import verify_conformance_directory
11
+ from .errors import VerificationError
12
+ from .event_chain import verify_event_chain
13
+ from .evidence import verify_evidence_packet
14
+
15
+
16
+ def main(argv: Sequence[str] | None = None) -> int:
17
+ parser = argparse.ArgumentParser(
18
+ prog="witnora-verify", description="Offline Witnora v0.2 verifier"
19
+ )
20
+ subparsers = parser.add_subparsers(dest="command", required=True)
21
+ for command in ("canonical", "digest"):
22
+ child = subparsers.add_parser(command)
23
+ child.add_argument("input", type=Path)
24
+ event = subparsers.add_parser("event-chain")
25
+ event.add_argument("input", type=Path)
26
+ conformance = subparsers.add_parser("conformance")
27
+ conformance.add_argument("directory", type=Path)
28
+ evidence = subparsers.add_parser("evidence")
29
+ evidence.add_argument("packet", type=Path)
30
+ evidence.add_argument("--root", required=True, type=Path)
31
+ evidence.add_argument("--revocation-journal", required=True, type=Path)
32
+ args = parser.parse_args(argv)
33
+
34
+ try:
35
+ if args.command in {"canonical", "digest"}:
36
+ value = loads_strict(args.input.read_bytes())
37
+ print(canonical_json(value) if args.command == "canonical" else sha256_canonical(value))
38
+ elif args.command == "event-chain":
39
+ events = loads_strict(args.input.read_bytes())
40
+ print(
41
+ json.dumps(
42
+ {"valid": True, "headDigest": verify_event_chain(events), "networkUsed": False},
43
+ separators=(",", ":"),
44
+ )
45
+ )
46
+ elif args.command == "conformance":
47
+ report = verify_conformance_directory(args.directory)
48
+ print(json.dumps(report.as_dict(), separators=(",", ":"), sort_keys=True))
49
+ return 0 if report.overall == "PASS" else 1
50
+ else:
51
+ packet = loads_strict(args.packet.read_bytes())
52
+ root = loads_strict(args.root.read_bytes())
53
+ journal = (
54
+ loads_strict(args.revocation_journal.read_bytes())
55
+ if args.revocation_journal
56
+ else None
57
+ )
58
+ report = verify_evidence_packet(packet, root, latest_revocation_journal=journal)
59
+ print(json.dumps(report, separators=(",", ":"), sort_keys=True))
60
+ return 0 if report["overall"] == "PASS" else 1
61
+ return 0
62
+ except (OSError, VerificationError, ValueError, TypeError) as error:
63
+ code = error.code if isinstance(error, VerificationError) else "VERIFICATION_FAILED"
64
+ print(
65
+ json.dumps(
66
+ {"valid": False, "code": code, "message": str(error), "networkUsed": False},
67
+ separators=(",", ":"),
68
+ ),
69
+ file=sys.stderr,
70
+ )
71
+ return 1
72
+
73
+
74
+ if __name__ == "__main__":
75
+ raise SystemExit(main())
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .canonical import canonical_bytes, loads_strict, sha256_canonical
10
+ from .errors import VerificationError
11
+ from .event_chain import verify_event_chain
12
+ from .probe import verify_probe_exchange
13
+ from .trust import PinnedTrustStore, verify_key_certificate, verify_revocation_journal
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ConformanceReport:
18
+ schema_version: str
19
+ overall: str
20
+ case_count: int
21
+ passed: int
22
+ failed: int
23
+ network_used: bool
24
+ failures: tuple[str, ...]
25
+
26
+ def as_dict(self) -> dict[str, Any]:
27
+ return {
28
+ "schemaVersion": self.schema_version,
29
+ "overall": self.overall,
30
+ "caseCount": self.case_count,
31
+ "passed": self.passed,
32
+ "failed": self.failed,
33
+ "networkUsed": self.network_used,
34
+ "failures": list(self.failures),
35
+ }
36
+
37
+
38
+ def verify_conformance_directory(directory: str | Path) -> ConformanceReport:
39
+ root = Path(directory)
40
+ failures: list[str] = []
41
+ passed = 0
42
+ case_count = 0
43
+
44
+ vectors = _read_json(root / "canonical-vectors.json")
45
+ for vector in vectors["vectors"]:
46
+ case_count += 1
47
+ try:
48
+ parsed = loads_strict(vector["rawJson"])
49
+ canonical = canonical_bytes(parsed)
50
+ if not vector["valid"]:
51
+ failures.append(f"{vector['id']}: expected rejection")
52
+ continue
53
+ if (
54
+ canonical.hex() != vector["canonicalUtf8Hex"]
55
+ or sha256_canonical(parsed) != vector["sha256"]
56
+ ):
57
+ failures.append(f"{vector['id']}: canonical bytes or digest mismatch")
58
+ continue
59
+ passed += 1
60
+ except VerificationError as error:
61
+ if not vector["valid"] and error.code == vector["errorCode"]:
62
+ passed += 1
63
+ else:
64
+ failures.append(
65
+ f"{vector['id']}: {error.code}, expected {vector.get('errorCode', 'PASS')}"
66
+ )
67
+
68
+ protocol = _read_json(root / "protocol-fixture.json")
69
+ store = PinnedTrustStore.from_roots(protocol["pinnedRoots"])
70
+ checks = (
71
+ (
72
+ "request-certificate",
73
+ lambda: verify_key_certificate(
74
+ protocol["requestCertificate"],
75
+ store,
76
+ expected_role="server_attestor",
77
+ tenant_ref=protocol["expected"]["tenantRef"],
78
+ project_ref=protocol["expected"]["projectRef"],
79
+ environment=protocol["expected"]["environment"],
80
+ at=protocol["request"]["requestedAt"],
81
+ revocation_journal=protocol["revocationJournal"],
82
+ ),
83
+ ),
84
+ (
85
+ "revocation-journal",
86
+ lambda: verify_revocation_journal(protocol["revocationJournal"], store),
87
+ ),
88
+ (
89
+ "probe-exchange",
90
+ lambda: verify_probe_exchange(
91
+ protocol["request"],
92
+ protocol["attestation"],
93
+ trust_store=store,
94
+ request_certificate=protocol["requestCertificate"],
95
+ probe_certificate=protocol["probeCertificate"],
96
+ revocation_journal=protocol["revocationJournal"],
97
+ expected=protocol["expected"],
98
+ now=datetime.fromisoformat(protocol["verificationTime"].replace("Z", "+00:00")),
99
+ max_freshness_ms=protocol["maxFreshnessMs"],
100
+ ),
101
+ ),
102
+ ("event-chain", lambda: verify_event_chain(protocol["events"])),
103
+ )
104
+ for name, check in checks:
105
+ case_count += 1
106
+ try:
107
+ check()
108
+ passed += 1
109
+ except (VerificationError, KeyError, TypeError, ValueError) as error:
110
+ failures.append(f"{name}: {error}")
111
+
112
+ return ConformanceReport(
113
+ schema_version="witnora.conformance_report.v0.2",
114
+ overall="PASS" if not failures else "FAIL",
115
+ case_count=case_count,
116
+ passed=passed,
117
+ failed=len(failures),
118
+ network_used=False,
119
+ failures=tuple(failures),
120
+ )
121
+
122
+
123
+ def _read_json(path: Path) -> Any:
124
+ return json.loads(path.read_text(encoding="utf-8"))
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from cryptography.exceptions import InvalidSignature
10
+ from cryptography.hazmat.primitives import serialization
11
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
12
+
13
+ from .canonical import CANONICAL_VERSION, canonical_bytes, sha256_canonical
14
+ from .errors import VerificationError, require
15
+
16
+
17
+ def b64url_encode(value: bytes) -> str:
18
+ return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
19
+
20
+
21
+ def b64url_decode(value: str, expected_length: int | None = None) -> bytes:
22
+ require(
23
+ isinstance(value, str) and value != "",
24
+ "INVALID_BASE64URL",
25
+ "A non-empty base64url value is required.",
26
+ )
27
+ require(
28
+ "=" not in value and "+" not in value and "/" not in value,
29
+ "INVALID_BASE64URL",
30
+ "Padded or standard base64 is forbidden.",
31
+ )
32
+ try:
33
+ raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
34
+ except (ValueError, TypeError) as exc:
35
+ raise VerificationError(
36
+ "INVALID_BASE64URL", "Value is not valid unpadded base64url."
37
+ ) from exc
38
+ if expected_length is not None:
39
+ require(
40
+ len(raw) == expected_length, "INVALID_KEY_LENGTH", f"Expected {expected_length} bytes."
41
+ )
42
+ return raw
43
+
44
+
45
+ def public_key_text(public_key: Ed25519PublicKey) -> str:
46
+ return public_key.public_bytes(
47
+ serialization.Encoding.PEM,
48
+ serialization.PublicFormat.SubjectPublicKeyInfo,
49
+ ).decode("ascii")
50
+
51
+
52
+ def private_key_text(private_key: Ed25519PrivateKey) -> str:
53
+ return private_key.private_bytes(
54
+ serialization.Encoding.PEM,
55
+ serialization.PrivateFormat.PKCS8,
56
+ serialization.NoEncryption(),
57
+ ).decode("ascii")
58
+
59
+
60
+ def private_key_from_seed(seed: bytes) -> Ed25519PrivateKey:
61
+ return Ed25519PrivateKey.from_private_bytes(hashlib.sha256(seed).digest())
62
+
63
+
64
+ def sign_value(value: Any, private_key: Ed25519PrivateKey, key_id: str) -> dict[str, str]:
65
+ payload = canonical_bytes(value)
66
+ return {
67
+ "algorithm": "Ed25519",
68
+ "canonicalization": CANONICAL_VERSION,
69
+ "keyId": key_id,
70
+ "valueDigest": hashlib.sha256(payload).hexdigest(),
71
+ "signature": b64url_encode(private_key.sign(payload)),
72
+ }
73
+
74
+
75
+ def sign_document(value: Any, private_key: Ed25519PrivateKey) -> str:
76
+ return base64.b64encode(private_key.sign(canonical_bytes(value))).decode("ascii")
77
+
78
+
79
+ def verify_document_signature(value: Any, signature: str, public_key_pem: str) -> None:
80
+ try:
81
+ public_key = serialization.load_pem_public_key(public_key_pem.encode("ascii"))
82
+ require(
83
+ isinstance(public_key, Ed25519PublicKey),
84
+ "INVALID_PUBLIC_KEY",
85
+ "Public key must be Ed25519.",
86
+ )
87
+ public_key.verify(base64.b64decode(signature, validate=True), canonical_bytes(value))
88
+ except VerificationError:
89
+ raise
90
+ except (InvalidSignature, ValueError, TypeError) as exc:
91
+ raise VerificationError("SIGNATURE_INVALID", "Ed25519 signature is invalid.") from exc
92
+
93
+
94
+ def verify_signed_value(
95
+ value: Any,
96
+ signature: Mapping[str, Any],
97
+ public_key_text_value: str,
98
+ expected_key_id: str | None = None,
99
+ ) -> None:
100
+ require(
101
+ signature.get("algorithm") == "Ed25519",
102
+ "SIGNATURE_ALGORITHM_MISMATCH",
103
+ "Ed25519 is required.",
104
+ )
105
+ require(
106
+ signature.get("canonicalization") == CANONICAL_VERSION,
107
+ "CANONICAL_VERSION_MISMATCH",
108
+ "Signature uses a different canonical profile.",
109
+ )
110
+ if expected_key_id is not None:
111
+ require(
112
+ signature.get("keyId") == expected_key_id,
113
+ "SIGNATURE_KEY_MISMATCH",
114
+ "Signature key ID does not match.",
115
+ )
116
+ payload = canonical_bytes(value)
117
+ require(
118
+ hmac.compare_digest(str(signature.get("valueDigest", "")), sha256_canonical(value)),
119
+ "SIGNED_DIGEST_MISMATCH",
120
+ "Signed value digest does not match canonical bytes.",
121
+ )
122
+ try:
123
+ public_key = serialization.load_pem_public_key(public_key_text_value.encode("ascii"))
124
+ except (ValueError, TypeError) as exc:
125
+ raise VerificationError("INVALID_PUBLIC_KEY", "Public key PEM is invalid.") from exc
126
+ require(
127
+ isinstance(public_key, Ed25519PublicKey),
128
+ "INVALID_PUBLIC_KEY",
129
+ "Public key must be Ed25519.",
130
+ )
131
+ try:
132
+ public_key.verify(b64url_decode(str(signature.get("signature", "")), 64), payload)
133
+ except InvalidSignature as exc:
134
+ raise VerificationError("SIGNATURE_INVALID", "Ed25519 signature is invalid.") from exc
135
+
136
+
137
+ def signed_body(document: Mapping[str, Any]) -> dict[str, Any]:
138
+ require("signature" in document, "SIGNATURE_MISSING", "Signed document has no signature.")
139
+ return {key: value for key, value in document.items() if key != "signature"}
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class VerificationError(ValueError):
5
+ """A deterministic, machine-actionable verification failure."""
6
+
7
+ def __init__(self, code: str, message: str) -> None:
8
+ super().__init__(f"{code}: {message}")
9
+ self.code = code
10
+ self.message = message
11
+
12
+
13
+ def require(condition: bool, code: str, message: str) -> None:
14
+ if not condition:
15
+ raise VerificationError(code, message)
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from .canonical import sha256_canonical
8
+ from .errors import require
9
+ from .trust import parse_timestamp
10
+
11
+ EVENT_CHAIN_VERSION = "witnora.event_hash_chain.v0.2"
12
+
13
+
14
+ def make_event(
15
+ *,
16
+ sequence: int,
17
+ event_type: str,
18
+ occurred_at: str,
19
+ previous_hash: str | None,
20
+ payload_digest: str,
21
+ ) -> dict[str, Any]:
22
+ body = {
23
+ "sequence": sequence,
24
+ "type": event_type,
25
+ "occurredAt": occurred_at,
26
+ "previousHash": previous_hash,
27
+ "payloadDigest": payload_digest,
28
+ }
29
+ return {**body, "eventHash": sha256_canonical(body)}
30
+
31
+
32
+ def verify_event_chain(events: Sequence[Mapping[str, Any]]) -> str:
33
+ require(bool(events), "EVENT_CHAIN_EMPTY", "Event chain must contain at least one event.")
34
+ previous_hash: str | None = None
35
+ previous_time: datetime | None = None
36
+ seen_hashes: set[str] = set()
37
+ for index, event in enumerate(events):
38
+ expected_sequence = index + 1
39
+ require(
40
+ event.get("sequence") == expected_sequence,
41
+ "EVENT_SEQUENCE_GAP",
42
+ f"Expected event sequence {expected_sequence}.",
43
+ )
44
+ require(
45
+ event.get("previousHash") == previous_hash,
46
+ "EVENT_PREVIOUS_HASH_MISMATCH",
47
+ f"Event {expected_sequence} does not reference its predecessor.",
48
+ )
49
+ payload_digest = str(event.get("payloadDigest", ""))
50
+ _require_sha256(payload_digest, "EVENT_PAYLOAD_DIGEST_INVALID")
51
+ occurred_at = parse_timestamp(event.get("occurredAt"), f"events[{index}].occurredAt")
52
+ require(
53
+ previous_time is None or occurred_at >= previous_time,
54
+ "EVENT_TIME_NON_MONOTONIC",
55
+ "Event time moved backwards.",
56
+ )
57
+ body = {
58
+ key: event.get(key)
59
+ for key in ("sequence", "type", "occurredAt", "previousHash", "payloadDigest")
60
+ }
61
+ event_hash = str(event.get("eventHash", ""))
62
+ _require_sha256(event_hash, "EVENT_HASH_INVALID")
63
+ require(
64
+ event_hash == sha256_canonical(body),
65
+ "EVENT_HASH_MISMATCH",
66
+ f"Event {expected_sequence} hash is invalid.",
67
+ )
68
+ require(event_hash not in seen_hashes, "EVENT_HASH_DUPLICATE", "Event hash is duplicated.")
69
+ seen_hashes.add(event_hash)
70
+ previous_hash = event_hash
71
+ previous_time = occurred_at
72
+ return previous_hash or ""
73
+
74
+
75
+ def _require_sha256(value: str, code: str) -> None:
76
+ require(
77
+ len(value) == 64 and all(character in "0123456789abcdef" for character in value),
78
+ code,
79
+ "A lowercase SHA-256 hex digest is required.",
80
+ )