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.
- package/README.md +76 -9
- package/dist/assurance-contract.js +312 -0
- package/dist/assurance-loop-demo.js +245 -0
- package/dist/authorization-state-machine.js +35 -0
- package/dist/authorization-v02.js +60 -0
- package/dist/bundle.js +22 -0
- package/dist/canonical-v02.js +197 -0
- package/dist/canonical.js +31 -0
- package/dist/cli.js +132 -0
- package/dist/command-help.js +81 -0
- package/dist/control-plane.js +12 -3
- package/dist/credentials.js +1 -1
- package/dist/design-partner-pilot.js +402 -0
- package/dist/design-partner-v02.js +449 -0
- package/dist/evidence-v02.js +118 -0
- package/dist/generic-eval.js +160 -0
- package/dist/github-design-partner-v02.js +399 -0
- package/dist/github-live-v02.js +45 -0
- package/dist/guided-setup.js +1 -1
- package/dist/index.js +1 -0
- package/dist/offline-verifier.js +375 -0
- package/dist/onboard.js +132 -0
- package/dist/onboarding-templates.js +49 -6
- package/dist/outcome-evaluator.js +51 -0
- package/dist/privacy-manifest.js +273 -0
- package/dist/probe-protocol-v02.js +101 -0
- package/dist/runtime-context.js +75 -0
- package/dist/sandbox.js +1 -1
- package/dist/schema-validator.js +141 -3
- package/dist/trust-v02.js +111 -0
- package/dist/try.js +1 -1
- package/dist/vendor/design-partner/failure-exercises.mjs +353 -0
- package/dist/vendor/onegent-runtime/browser-enforcement-runtime.d.ts +8 -0
- package/dist/vendor/onegent-runtime/browser-enforcement-runtime.d.ts.map +1 -1
- package/dist/vendor/onegent-runtime/browser-enforcement-runtime.js +43 -1
- package/dist/vendor/witnora-probe/canonical.d.ts +3 -0
- package/dist/vendor/witnora-probe/canonical.js +39 -0
- package/dist/vendor/witnora-probe/cli.d.ts +2 -0
- package/dist/vendor/witnora-probe/cli.js +36 -0
- package/dist/vendor/witnora-probe/config.d.ts +2 -0
- package/dist/vendor/witnora-probe/config.js +63 -0
- package/dist/vendor/witnora-probe/credential-resolver.d.ts +6 -0
- package/dist/vendor/witnora-probe/credential-resolver.js +17 -0
- package/dist/vendor/witnora-probe/crypto.d.ts +2 -0
- package/dist/vendor/witnora-probe/crypto.js +14 -0
- package/dist/vendor/witnora-probe/emulator.d.ts +51 -0
- package/dist/vendor/witnora-probe/emulator.js +164 -0
- package/dist/vendor/witnora-probe/index.d.ts +11 -0
- package/dist/vendor/witnora-probe/index.js +11 -0
- package/dist/vendor/witnora-probe/logger.d.ts +3 -0
- package/dist/vendor/witnora-probe/logger.js +26 -0
- package/dist/vendor/witnora-probe/metrics.d.ts +12 -0
- package/dist/vendor/witnora-probe/metrics.js +31 -0
- package/dist/vendor/witnora-probe/probe.d.ts +22 -0
- package/dist/vendor/witnora-probe/probe.js +208 -0
- package/dist/vendor/witnora-probe/request-verifier.d.ts +9 -0
- package/dist/vendor/witnora-probe/request-verifier.js +84 -0
- package/dist/vendor/witnora-probe/server.d.ts +9 -0
- package/dist/vendor/witnora-probe/server.js +85 -0
- package/dist/vendor/witnora-probe/storage.d.ts +16 -0
- package/dist/vendor/witnora-probe/storage.js +114 -0
- package/dist/vendor/witnora-probe/types.d.ts +126 -0
- package/dist/vendor/witnora-probe/types.js +2 -0
- package/dist/vendor/witnora-verifier-python/README.md +43 -0
- package/dist/vendor/witnora-verifier-python/pyproject.toml +23 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/__init__.py +33 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/canonical.py +218 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/cli.py +75 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/conformance.py +124 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/crypto.py +139 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/errors.py +15 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/event_chain.py +80 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/evidence.py +336 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/probe.py +188 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/producer.py +127 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/trust.py +337 -0
- 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
|
+
)
|