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,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .canonical import sha256_canonical
|
|
8
|
+
from .crypto import verify_document_signature
|
|
9
|
+
from .errors import VerificationError, require
|
|
10
|
+
from .probe import verify_probe_exchange
|
|
11
|
+
from .trust import (
|
|
12
|
+
PinnedTrustStore,
|
|
13
|
+
assert_revocation_journal_extends,
|
|
14
|
+
parse_timestamp,
|
|
15
|
+
verify_key_certificate,
|
|
16
|
+
verify_revocation_journal,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
EVIDENCE_VERSION = "witnora.verifiable_evidence_packet.v0.2"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify_evidence_packet(
|
|
23
|
+
packet: Mapping[str, Any],
|
|
24
|
+
pinned_root: Mapping[str, Any],
|
|
25
|
+
*,
|
|
26
|
+
at: datetime | None = None,
|
|
27
|
+
latest_revocation_journal: Mapping[str, Any] | None = None,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
failures: list[str] = []
|
|
30
|
+
required = (
|
|
31
|
+
"approval",
|
|
32
|
+
"grant",
|
|
33
|
+
"executionAttempt",
|
|
34
|
+
"reconciliation",
|
|
35
|
+
"probeRequest",
|
|
36
|
+
"probeAttestation",
|
|
37
|
+
"trust",
|
|
38
|
+
"separation",
|
|
39
|
+
"privacy",
|
|
40
|
+
"evidenceStrength",
|
|
41
|
+
"gatewaySignature",
|
|
42
|
+
)
|
|
43
|
+
if packet.get("protocolVersion") != EVIDENCE_VERSION or any(
|
|
44
|
+
key not in packet for key in required
|
|
45
|
+
):
|
|
46
|
+
return _failed("Evidence packet v0.2 is structurally incomplete.")
|
|
47
|
+
|
|
48
|
+
cryptographically_valid = True
|
|
49
|
+
trusted_by_pinned_root = True
|
|
50
|
+
try:
|
|
51
|
+
store = PinnedTrustStore.from_roots([pinned_root])
|
|
52
|
+
trust = packet["trust"]
|
|
53
|
+
require(
|
|
54
|
+
trust.get("rootId") == pinned_root.get("rootId"),
|
|
55
|
+
"UNPINNED_ROOT",
|
|
56
|
+
"Packet does not reference the caller-pinned root.",
|
|
57
|
+
)
|
|
58
|
+
require(
|
|
59
|
+
latest_revocation_journal is not None,
|
|
60
|
+
"LATEST_REVOCATION_REQUIRED",
|
|
61
|
+
"Caller must supply the latest trusted revocation journal.",
|
|
62
|
+
)
|
|
63
|
+
journal = latest_revocation_journal
|
|
64
|
+
verify_revocation_journal(journal, store)
|
|
65
|
+
assert_revocation_journal_extends(trust["revocationJournal"], journal)
|
|
66
|
+
request_signer_certificate = trust["requestSignerCertificate"]
|
|
67
|
+
verify_key_certificate(
|
|
68
|
+
request_signer_certificate,
|
|
69
|
+
store,
|
|
70
|
+
expected_role="server_attestor",
|
|
71
|
+
tenant_ref=str(packet["tenantRef"]),
|
|
72
|
+
project_ref=str(packet["projectRef"]),
|
|
73
|
+
environment=str(packet["environment"]),
|
|
74
|
+
at=str(packet["probeRequest"]["requestedAt"]),
|
|
75
|
+
revocation_journal=journal,
|
|
76
|
+
)
|
|
77
|
+
gateway_certificate = trust["gatewayCertificate"]
|
|
78
|
+
verify_key_certificate(
|
|
79
|
+
gateway_certificate,
|
|
80
|
+
store,
|
|
81
|
+
expected_role="gateway_runtime",
|
|
82
|
+
tenant_ref=str(packet["tenantRef"]),
|
|
83
|
+
project_ref=str(packet["projectRef"]),
|
|
84
|
+
environment=str(packet["environment"]),
|
|
85
|
+
at=str(packet["issuedAt"]),
|
|
86
|
+
revocation_journal=journal,
|
|
87
|
+
)
|
|
88
|
+
signature = packet["gatewaySignature"]
|
|
89
|
+
require(
|
|
90
|
+
signature.get("keyId") == gateway_certificate.get("subjectKeyId"),
|
|
91
|
+
"SIGNATURE_KEY_MISMATCH",
|
|
92
|
+
"Gateway signature key ID does not match its certificate.",
|
|
93
|
+
)
|
|
94
|
+
body = {key: value for key, value in packet.items() if key != "gatewaySignature"}
|
|
95
|
+
verify_document_signature(
|
|
96
|
+
body,
|
|
97
|
+
str(signature.get("signature", "")),
|
|
98
|
+
str(gateway_certificate.get("publicKeyPem", "")),
|
|
99
|
+
)
|
|
100
|
+
expected = {
|
|
101
|
+
field: str(packet[field])
|
|
102
|
+
for field in (
|
|
103
|
+
"tenantRef",
|
|
104
|
+
"projectRef",
|
|
105
|
+
"environment",
|
|
106
|
+
"actionDigest",
|
|
107
|
+
"contractDigest",
|
|
108
|
+
"resourceCommitment",
|
|
109
|
+
"predicateDigest",
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
expected["probeId"] = str(packet["probeAttestation"]["probeId"])
|
|
113
|
+
approval_certificate = trust["approvalCertificate"]
|
|
114
|
+
grant_certificate = trust["grantCertificate"]
|
|
115
|
+
approval = packet["approval"]
|
|
116
|
+
grant = packet["grant"]
|
|
117
|
+
require(
|
|
118
|
+
approval.get("protocolVersion") == "witnora.signed_action_approval.v0.2"
|
|
119
|
+
and grant.get("protocolVersion") == "witnora.signed_execution_grant.v0.2",
|
|
120
|
+
"AUTHORIZATION_VERSION_UNSUPPORTED",
|
|
121
|
+
"Signed authorization objects must use the v0.2 protocols.",
|
|
122
|
+
)
|
|
123
|
+
for authorization_name, authorization in (("approval", approval), ("grant", grant)):
|
|
124
|
+
for field in ("tenantRef", "projectRef", "environment", "actionDigest"):
|
|
125
|
+
require(
|
|
126
|
+
authorization.get(field) == packet.get(field),
|
|
127
|
+
"AUTHORIZATION_SCOPE_MISMATCH",
|
|
128
|
+
f"{authorization_name} {field} is not bound to the Evidence packet.",
|
|
129
|
+
)
|
|
130
|
+
verify_key_certificate(
|
|
131
|
+
approval_certificate,
|
|
132
|
+
store,
|
|
133
|
+
expected_role="reviewer",
|
|
134
|
+
tenant_ref=str(packet["tenantRef"]),
|
|
135
|
+
project_ref=str(packet["projectRef"]),
|
|
136
|
+
environment=str(packet["environment"]),
|
|
137
|
+
at=str(approval["approvedAt"]),
|
|
138
|
+
revocation_journal=journal,
|
|
139
|
+
)
|
|
140
|
+
verify_key_certificate(
|
|
141
|
+
grant_certificate,
|
|
142
|
+
store,
|
|
143
|
+
expected_role="resource_scope_evaluator",
|
|
144
|
+
tenant_ref=str(packet["tenantRef"]),
|
|
145
|
+
project_ref=str(packet["projectRef"]),
|
|
146
|
+
environment=str(packet["environment"]),
|
|
147
|
+
at=str(grant["issuedAt"]),
|
|
148
|
+
revocation_journal=journal,
|
|
149
|
+
)
|
|
150
|
+
require(
|
|
151
|
+
approval.get("signerKeyId") == approval_certificate.get("subjectKeyId"),
|
|
152
|
+
"APPROVAL_KEY_MISMATCH",
|
|
153
|
+
"Approval key does not match its certificate.",
|
|
154
|
+
)
|
|
155
|
+
require(
|
|
156
|
+
grant.get("issuerKeyId") == grant_certificate.get("subjectKeyId"),
|
|
157
|
+
"GRANT_KEY_MISMATCH",
|
|
158
|
+
"Grant key does not match its certificate.",
|
|
159
|
+
)
|
|
160
|
+
verify_document_signature(
|
|
161
|
+
{key: value for key, value in approval.items() if key != "signature"},
|
|
162
|
+
str(approval.get("signature", "")),
|
|
163
|
+
str(approval_certificate.get("publicKeyPem", "")),
|
|
164
|
+
)
|
|
165
|
+
verify_document_signature(
|
|
166
|
+
{key: value for key, value in grant.items() if key != "signature"},
|
|
167
|
+
str(grant.get("signature", "")),
|
|
168
|
+
str(grant_certificate.get("publicKeyPem", "")),
|
|
169
|
+
)
|
|
170
|
+
require(
|
|
171
|
+
grant.get("approvalDigest") == sha256_canonical(approval),
|
|
172
|
+
"GRANT_APPROVAL_MISMATCH",
|
|
173
|
+
"Grant is not bound to the signed approval.",
|
|
174
|
+
)
|
|
175
|
+
verification_time = at or datetime.now(UTC)
|
|
176
|
+
require(
|
|
177
|
+
parse_timestamp(grant["notBefore"], "grant.notBefore")
|
|
178
|
+
<= verification_time
|
|
179
|
+
<= parse_timestamp(grant["expiresAt"], "grant.expiresAt"),
|
|
180
|
+
"GRANT_INACTIVE",
|
|
181
|
+
"Grant is not valid at verification time.",
|
|
182
|
+
)
|
|
183
|
+
require(
|
|
184
|
+
verification_time <= parse_timestamp(approval["expiresAt"], "approval.expiresAt"),
|
|
185
|
+
"APPROVAL_EXPIRED",
|
|
186
|
+
"Approval is expired at verification time.",
|
|
187
|
+
)
|
|
188
|
+
verify_probe_exchange(
|
|
189
|
+
packet["probeRequest"],
|
|
190
|
+
packet["probeAttestation"],
|
|
191
|
+
trust_store=store,
|
|
192
|
+
request_certificate=request_signer_certificate,
|
|
193
|
+
probe_certificate=trust["probeCertificate"],
|
|
194
|
+
revocation_journal=journal,
|
|
195
|
+
expected=expected,
|
|
196
|
+
now=verification_time,
|
|
197
|
+
max_freshness_ms=300_000,
|
|
198
|
+
)
|
|
199
|
+
require(
|
|
200
|
+
packet["probeAttestation"].get("probeId")
|
|
201
|
+
== trust["probeCertificate"].get("operatorIdentity"),
|
|
202
|
+
"PROBE_IDENTITY_MISMATCH",
|
|
203
|
+
"Probe identity is not bound to the certified operator identity.",
|
|
204
|
+
)
|
|
205
|
+
except (VerificationError, KeyError, TypeError, ValueError) as error:
|
|
206
|
+
cryptographically_valid = False
|
|
207
|
+
trusted_by_pinned_root = False
|
|
208
|
+
failures.append(str(error))
|
|
209
|
+
|
|
210
|
+
approval = packet["approval"]
|
|
211
|
+
grant = packet["grant"]
|
|
212
|
+
recovery_history = packet["executionAttempt"].get("recoveryHistory", [])
|
|
213
|
+
dispatch_reconciled = (
|
|
214
|
+
packet["executionAttempt"].get("dispatchCount") == 1
|
|
215
|
+
and packet["executionAttempt"].get("state") == "RECONCILED_SUCCESS"
|
|
216
|
+
and packet["reconciliation"].get("status") == "TARGET_STATE_RECONCILED"
|
|
217
|
+
and bool(recovery_history)
|
|
218
|
+
and recovery_history[-1].get("state") == "RECONCILED_SUCCESS"
|
|
219
|
+
and sha256_canonical(recovery_history) == packet.get("eventChainDigest")
|
|
220
|
+
)
|
|
221
|
+
binding_source = packet["reconciliation"].get("authorizationBindingSource")
|
|
222
|
+
grant_use_bound = (
|
|
223
|
+
packet["probeAttestation"].get("normalizedObservation", {}).get("consumedGrantId")
|
|
224
|
+
== grant.get("grantId")
|
|
225
|
+
if binding_source == "TARGET_GRANT_ECHO"
|
|
226
|
+
else binding_source == "GATEWAY_DISPATCH_RECORD" and dispatch_reconciled
|
|
227
|
+
)
|
|
228
|
+
authorization_bound = (
|
|
229
|
+
cryptographically_valid
|
|
230
|
+
and approval.get("actionDigest") == packet.get("actionDigest")
|
|
231
|
+
and grant.get("actionDigest") == packet.get("actionDigest")
|
|
232
|
+
and grant.get("maxUses") == 1
|
|
233
|
+
and grant_use_bound
|
|
234
|
+
)
|
|
235
|
+
outcome_observed = (
|
|
236
|
+
packet["probeAttestation"].get("outcomeStatus") == "VERIFIED" and dispatch_reconciled
|
|
237
|
+
)
|
|
238
|
+
deployment_mode = packet["separation"].get("probeDeploymentMode")
|
|
239
|
+
customer_operated = (
|
|
240
|
+
deployment_mode in {"separate_process", "separate_container", "customer_network"}
|
|
241
|
+
and pinned_root.get("ownerType") == "CUSTOMER_ASSURANCE_ROOT"
|
|
242
|
+
and packet["evidenceStrength"].get("outcomeObservation") == "CUSTOMER_OPERATED_READ_PATH"
|
|
243
|
+
)
|
|
244
|
+
expected_execution = (
|
|
245
|
+
"TARGET_STATE_RECONCILED"
|
|
246
|
+
if dispatch_reconciled
|
|
247
|
+
else (
|
|
248
|
+
"DISPATCH_UNKNOWN"
|
|
249
|
+
if packet["executionAttempt"].get("state") == "DISPATCH_UNKNOWN"
|
|
250
|
+
else "NOT_ESTABLISHED"
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
expected_separation = (
|
|
254
|
+
"CUSTOMER_NETWORK_SEPARATED"
|
|
255
|
+
if deployment_mode == "customer_network"
|
|
256
|
+
else (
|
|
257
|
+
"CONTAINER_SEPARATED"
|
|
258
|
+
if deployment_mode == "separate_container"
|
|
259
|
+
else "PROCESS_SEPARATED"
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
expected_authorization = (
|
|
263
|
+
(
|
|
264
|
+
"EXACT_ACTION_GRANT_BOUND"
|
|
265
|
+
if binding_source == "TARGET_GRANT_ECHO"
|
|
266
|
+
else "GATEWAY_DISPATCH_BOUND"
|
|
267
|
+
)
|
|
268
|
+
if authorization_bound
|
|
269
|
+
else "NOT_ESTABLISHED"
|
|
270
|
+
)
|
|
271
|
+
strength_consistent = (
|
|
272
|
+
packet["evidenceStrength"].get("authorizationBinding") == expected_authorization
|
|
273
|
+
and packet["evidenceStrength"].get("executionBinding") == expected_execution
|
|
274
|
+
and packet["evidenceStrength"].get("sourceIntegrity")
|
|
275
|
+
== ("CUSTOMER_ROOT_CERTIFIED" if cryptographically_valid else "SELF_ASSERTED")
|
|
276
|
+
and packet["evidenceStrength"].get("outcomeObservation")
|
|
277
|
+
== ("CUSTOMER_OPERATED_READ_PATH" if outcome_observed else "NOT_OBSERVED")
|
|
278
|
+
and packet["evidenceStrength"].get("operatorSeparation") == expected_separation
|
|
279
|
+
and packet["evidenceStrength"].get("review") != "EXTERNAL"
|
|
280
|
+
and packet["privacy"].get("rawCanaryMatchesOutsideLocal") == 0
|
|
281
|
+
)
|
|
282
|
+
if not authorization_bound:
|
|
283
|
+
failures.append("Authorization is not bound to the exact action.")
|
|
284
|
+
if not dispatch_reconciled:
|
|
285
|
+
failures.append("Target dispatch is not reconciled.")
|
|
286
|
+
if not outcome_observed:
|
|
287
|
+
failures.append("Outcome was not independently observed.")
|
|
288
|
+
if not strength_consistent:
|
|
289
|
+
failures.append("Evidence-strength vector overclaims the packet evidence.")
|
|
290
|
+
if sha256_canonical(packet["probeRequest"]) != packet["probeAttestation"].get("requestDigest"):
|
|
291
|
+
failures.append("Probe request digest mismatch.")
|
|
292
|
+
overall = (
|
|
293
|
+
cryptographically_valid
|
|
294
|
+
and authorization_bound
|
|
295
|
+
and dispatch_reconciled
|
|
296
|
+
and outcome_observed
|
|
297
|
+
and customer_operated
|
|
298
|
+
and strength_consistent
|
|
299
|
+
)
|
|
300
|
+
return {
|
|
301
|
+
"protocolVersion": "witnora.offline_verification_report.v0.2",
|
|
302
|
+
"overall": "PASS" if overall else "FAIL",
|
|
303
|
+
"networkUsed": False,
|
|
304
|
+
"dimensions": {
|
|
305
|
+
"cryptographicallyValid": cryptographically_valid,
|
|
306
|
+
"trustedByPinnedRoot": trusted_by_pinned_root,
|
|
307
|
+
"authorizationBound": authorization_bound,
|
|
308
|
+
"dispatchReconciled": dispatch_reconciled,
|
|
309
|
+
"outcomeObserved": outcome_observed,
|
|
310
|
+
"customerOperated": customer_operated,
|
|
311
|
+
# v0.2 has no independent reviewer signature object.
|
|
312
|
+
"thirdPartyReviewed": False,
|
|
313
|
+
},
|
|
314
|
+
"failures": failures,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _failed(message: str) -> dict[str, Any]:
|
|
319
|
+
return {
|
|
320
|
+
"protocolVersion": "witnora.offline_verification_report.v0.2",
|
|
321
|
+
"overall": "FAIL",
|
|
322
|
+
"networkUsed": False,
|
|
323
|
+
"dimensions": dict.fromkeys(
|
|
324
|
+
(
|
|
325
|
+
"cryptographicallyValid",
|
|
326
|
+
"trustedByPinnedRoot",
|
|
327
|
+
"authorizationBound",
|
|
328
|
+
"dispatchReconciled",
|
|
329
|
+
"outcomeObserved",
|
|
330
|
+
"customerOperated",
|
|
331
|
+
"thirdPartyReviewed",
|
|
332
|
+
),
|
|
333
|
+
False,
|
|
334
|
+
),
|
|
335
|
+
"failures": [message],
|
|
336
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, MutableSet
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .canonical import sha256_canonical
|
|
9
|
+
from .crypto import signed_body, verify_document_signature
|
|
10
|
+
from .errors import require
|
|
11
|
+
from .trust import (
|
|
12
|
+
PinnedTrustStore,
|
|
13
|
+
parse_timestamp,
|
|
14
|
+
verify_key_certificate,
|
|
15
|
+
verify_revocation_journal,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
PROBE_REQUEST_VERSION = "witnora.probe_observation_request.v0.2"
|
|
19
|
+
PROBE_ATTESTATION_VERSION = "witnora.probe_observation_attestation.v0.2"
|
|
20
|
+
_BINDING_FIELDS = (
|
|
21
|
+
"tenantRef",
|
|
22
|
+
"projectRef",
|
|
23
|
+
"environment",
|
|
24
|
+
"actionDigest",
|
|
25
|
+
"contractDigest",
|
|
26
|
+
"resourceCommitment",
|
|
27
|
+
"predicateDigest",
|
|
28
|
+
"probeId",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ReplayLedger:
|
|
34
|
+
request_nonces: MutableSet[str] = field(default_factory=set)
|
|
35
|
+
attestation_ids: MutableSet[str] = field(default_factory=set)
|
|
36
|
+
|
|
37
|
+
def reserve(self, request: Mapping[str, Any], attestation: Mapping[str, Any]) -> None:
|
|
38
|
+
nonce = str(request.get("nonce", ""))
|
|
39
|
+
attestation_id = str(attestation.get("attestationId", ""))
|
|
40
|
+
require(
|
|
41
|
+
nonce not in self.request_nonces,
|
|
42
|
+
"PROBE_REQUEST_REPLAY",
|
|
43
|
+
"Probe request nonce was already accepted.",
|
|
44
|
+
)
|
|
45
|
+
require(
|
|
46
|
+
attestation_id not in self.attestation_ids,
|
|
47
|
+
"PROBE_ATTESTATION_REPLAY",
|
|
48
|
+
"Probe attestation ID was already accepted.",
|
|
49
|
+
)
|
|
50
|
+
self.request_nonces.add(nonce)
|
|
51
|
+
self.attestation_ids.add(attestation_id)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def verify_probe_exchange(
|
|
55
|
+
request: Mapping[str, Any],
|
|
56
|
+
attestation: Mapping[str, Any],
|
|
57
|
+
*,
|
|
58
|
+
trust_store: PinnedTrustStore,
|
|
59
|
+
request_certificate: Mapping[str, Any],
|
|
60
|
+
probe_certificate: Mapping[str, Any],
|
|
61
|
+
revocation_journal: Mapping[str, Any],
|
|
62
|
+
expected: Mapping[str, str],
|
|
63
|
+
now: datetime,
|
|
64
|
+
max_freshness_ms: int = 300_000,
|
|
65
|
+
replay_ledger: ReplayLedger | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
require(
|
|
68
|
+
request.get("protocolVersion") == PROBE_REQUEST_VERSION,
|
|
69
|
+
"PROBE_REQUEST_VERSION_UNSUPPORTED",
|
|
70
|
+
"Probe request is not v0.2.",
|
|
71
|
+
)
|
|
72
|
+
require(
|
|
73
|
+
attestation.get("protocolVersion") == PROBE_ATTESTATION_VERSION,
|
|
74
|
+
"PROBE_ATTESTATION_VERSION_UNSUPPORTED",
|
|
75
|
+
"Probe attestation is not v0.2.",
|
|
76
|
+
)
|
|
77
|
+
verify_revocation_journal(revocation_journal, trust_store)
|
|
78
|
+
|
|
79
|
+
requested_at = parse_timestamp(request.get("requestedAt"), "request.requestedAt")
|
|
80
|
+
not_before = parse_timestamp(request.get("notBefore"), "request.notBefore")
|
|
81
|
+
expires_at = parse_timestamp(request.get("expiresAt"), "request.expiresAt")
|
|
82
|
+
require(
|
|
83
|
+
not_before <= requested_at <= expires_at,
|
|
84
|
+
"PROBE_REQUEST_WINDOW_INVALID",
|
|
85
|
+
"Request was not issued inside its declared window.",
|
|
86
|
+
)
|
|
87
|
+
require(
|
|
88
|
+
request.get("requestSignerKeyId") == request_certificate.get("subjectKeyId"),
|
|
89
|
+
"PROBE_REQUEST_KEY_MISMATCH",
|
|
90
|
+
"Request signer does not match its certificate.",
|
|
91
|
+
)
|
|
92
|
+
verify_key_certificate(
|
|
93
|
+
request_certificate,
|
|
94
|
+
trust_store,
|
|
95
|
+
expected_role="server_attestor",
|
|
96
|
+
tenant_ref=expected["tenantRef"],
|
|
97
|
+
project_ref=expected["projectRef"],
|
|
98
|
+
environment=expected["environment"],
|
|
99
|
+
at=requested_at,
|
|
100
|
+
revocation_journal=revocation_journal,
|
|
101
|
+
)
|
|
102
|
+
verify_document_signature(
|
|
103
|
+
signed_body(request),
|
|
104
|
+
str(request.get("signature", "")),
|
|
105
|
+
str(request_certificate.get("publicKeyPem", "")),
|
|
106
|
+
)
|
|
107
|
+
_assert_bindings(request, expected, "PROBE_REQUEST_BINDING_MISMATCH")
|
|
108
|
+
|
|
109
|
+
observed_at = parse_timestamp(attestation.get("observedAt"), "attestation.observedAt")
|
|
110
|
+
require(
|
|
111
|
+
not_before <= observed_at <= expires_at,
|
|
112
|
+
"PROBE_OBSERVATION_OUTSIDE_REQUEST_WINDOW",
|
|
113
|
+
"Observation falls outside the request window.",
|
|
114
|
+
)
|
|
115
|
+
require(
|
|
116
|
+
observed_at <= now + timedelta(seconds=30),
|
|
117
|
+
"PROBE_OBSERVATION_FUTURE",
|
|
118
|
+
"Observation is future-dated beyond clock skew.",
|
|
119
|
+
)
|
|
120
|
+
freshness = int(attestation.get("dataFreshnessMs", -1))
|
|
121
|
+
require(
|
|
122
|
+
0 <= freshness <= max_freshness_ms,
|
|
123
|
+
"PROBE_OBSERVATION_STALE",
|
|
124
|
+
"Declared observation freshness exceeds the limit.",
|
|
125
|
+
)
|
|
126
|
+
require(
|
|
127
|
+
(now - observed_at).total_seconds() * 1000 <= max_freshness_ms,
|
|
128
|
+
"PROBE_OBSERVATION_STALE",
|
|
129
|
+
"Observation is stale at verification time.",
|
|
130
|
+
)
|
|
131
|
+
require(
|
|
132
|
+
attestation.get("keyCertificateId") == probe_certificate.get("certificateId"),
|
|
133
|
+
"PROBE_CERTIFICATE_MISMATCH",
|
|
134
|
+
"Attestation certificate ID does not match.",
|
|
135
|
+
)
|
|
136
|
+
verify_key_certificate(
|
|
137
|
+
probe_certificate,
|
|
138
|
+
trust_store,
|
|
139
|
+
expected_role="outcome_probe",
|
|
140
|
+
tenant_ref=expected["tenantRef"],
|
|
141
|
+
project_ref=expected["projectRef"],
|
|
142
|
+
environment=expected["environment"],
|
|
143
|
+
at=observed_at,
|
|
144
|
+
revocation_journal=revocation_journal,
|
|
145
|
+
)
|
|
146
|
+
verify_document_signature(
|
|
147
|
+
signed_body(attestation),
|
|
148
|
+
str(attestation.get("signature", "")),
|
|
149
|
+
str(probe_certificate.get("publicKeyPem", "")),
|
|
150
|
+
)
|
|
151
|
+
require(
|
|
152
|
+
attestation.get("requestId") == request.get("requestId"),
|
|
153
|
+
"PROBE_REQUEST_ID_MISMATCH",
|
|
154
|
+
"Attestation references another request.",
|
|
155
|
+
)
|
|
156
|
+
require(
|
|
157
|
+
attestation.get("requestDigest") == sha256_canonical(request),
|
|
158
|
+
"PROBE_REQUEST_DIGEST_MISMATCH",
|
|
159
|
+
"Attestation request digest is invalid.",
|
|
160
|
+
)
|
|
161
|
+
require(
|
|
162
|
+
attestation.get("nonce") == request.get("nonce"),
|
|
163
|
+
"PROBE_NONCE_MISMATCH",
|
|
164
|
+
"Attestation nonce differs from the request.",
|
|
165
|
+
)
|
|
166
|
+
_assert_bindings(attestation, expected, "PROBE_ATTESTATION_BINDING_MISMATCH")
|
|
167
|
+
require(
|
|
168
|
+
isinstance(attestation.get("limitations"), list),
|
|
169
|
+
"PROBE_LIMITATIONS_INVALID",
|
|
170
|
+
"Attestation limitations must be an array.",
|
|
171
|
+
)
|
|
172
|
+
require(
|
|
173
|
+
attestation.get("outcomeStatus") in {"VERIFIED", "NOT_VERIFIED", "INDETERMINATE"},
|
|
174
|
+
"PROBE_OUTCOME_STATUS_INVALID",
|
|
175
|
+
"Probe outcome status is invalid.",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if replay_ledger is not None:
|
|
179
|
+
replay_ledger.reserve(request, attestation)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _assert_bindings(value: Mapping[str, Any], expected: Mapping[str, str], code: str) -> None:
|
|
183
|
+
for field_name in _BINDING_FIELDS:
|
|
184
|
+
require(
|
|
185
|
+
value.get(field_name) == expected.get(field_name),
|
|
186
|
+
code,
|
|
187
|
+
f"Probe {field_name} binding mismatch.",
|
|
188
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
7
|
+
|
|
8
|
+
from .canonical import sha256_canonical
|
|
9
|
+
from .crypto import public_key_text, sign_document
|
|
10
|
+
from .trust import root_fingerprint
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_trust_root(
|
|
14
|
+
*,
|
|
15
|
+
root_id: str,
|
|
16
|
+
private_key: Ed25519PrivateKey,
|
|
17
|
+
allowed_roles: list[str],
|
|
18
|
+
organization_ref: str,
|
|
19
|
+
created_at: str,
|
|
20
|
+
not_before: str,
|
|
21
|
+
not_after: str,
|
|
22
|
+
owner_type: str = "CUSTOMER_ASSURANCE_ROOT",
|
|
23
|
+
policy_version: str = "v0.2",
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
public_key_pem = public_key_text(private_key.public_key())
|
|
26
|
+
return {
|
|
27
|
+
"protocolVersion": "witnora.trust_root.v0.2",
|
|
28
|
+
"rootId": root_id,
|
|
29
|
+
"publicKeyPem": public_key_pem,
|
|
30
|
+
"algorithm": "Ed25519",
|
|
31
|
+
"ownerType": owner_type,
|
|
32
|
+
"allowedRoles": allowed_roles,
|
|
33
|
+
"organizationRef": organization_ref,
|
|
34
|
+
"createdAt": created_at,
|
|
35
|
+
"notBefore": not_before,
|
|
36
|
+
"notAfter": not_after,
|
|
37
|
+
"policyVersion": policy_version,
|
|
38
|
+
"rootFingerprint": root_fingerprint(public_key_pem),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def issue_key_certificate(
|
|
43
|
+
*,
|
|
44
|
+
root: Mapping[str, Any],
|
|
45
|
+
root_private_key: Ed25519PrivateKey,
|
|
46
|
+
subject_private_key: Ed25519PrivateKey,
|
|
47
|
+
certificate_id: str,
|
|
48
|
+
serial_number: int,
|
|
49
|
+
subject_key_id: str,
|
|
50
|
+
role: str,
|
|
51
|
+
tenant_ref: str,
|
|
52
|
+
project_ref: str,
|
|
53
|
+
environment: str,
|
|
54
|
+
operator_identity: str,
|
|
55
|
+
not_before: str,
|
|
56
|
+
not_after: str,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
body = {
|
|
59
|
+
"protocolVersion": "witnora.key_certificate.v0.2",
|
|
60
|
+
"certificateId": certificate_id,
|
|
61
|
+
"serialNumber": serial_number,
|
|
62
|
+
"subjectKeyId": subject_key_id,
|
|
63
|
+
"publicKeyPem": public_key_text(subject_private_key.public_key()),
|
|
64
|
+
"issuerRootId": root["rootId"],
|
|
65
|
+
"role": role,
|
|
66
|
+
"tenantRef": tenant_ref,
|
|
67
|
+
"projectRef": project_ref,
|
|
68
|
+
"environment": environment,
|
|
69
|
+
"operatorIdentity": operator_identity,
|
|
70
|
+
"notBefore": not_before,
|
|
71
|
+
"notAfter": not_after,
|
|
72
|
+
}
|
|
73
|
+
certificate_digest = sha256_canonical(body)
|
|
74
|
+
signed = {**body, "certificateDigest": certificate_digest}
|
|
75
|
+
return {**signed, "signature": sign_document(signed, root_private_key)}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def empty_revocation_journal(root_id: str) -> dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"protocolVersion": "witnora.revocation_journal.v0.2",
|
|
81
|
+
"rootId": root_id,
|
|
82
|
+
"statements": [],
|
|
83
|
+
"checkpointDigest": sha256_canonical([]),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def append_revocation(
|
|
88
|
+
journal: Mapping[str, Any],
|
|
89
|
+
*,
|
|
90
|
+
root_private_key: Ed25519PrivateKey,
|
|
91
|
+
revoked_id: str,
|
|
92
|
+
effective_at: str,
|
|
93
|
+
retroactive: bool,
|
|
94
|
+
reason: str,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
statements = list(journal["statements"])
|
|
97
|
+
body = {
|
|
98
|
+
"protocolVersion": "witnora.key_revocation_statement.v0.2",
|
|
99
|
+
"sequence": len(statements) + 1,
|
|
100
|
+
"previousJournalDigest": journal["checkpointDigest"] if statements else None,
|
|
101
|
+
"revokedId": revoked_id,
|
|
102
|
+
"effectiveAt": effective_at,
|
|
103
|
+
"retroactive": retroactive,
|
|
104
|
+
"reason": reason,
|
|
105
|
+
"issuerRootId": journal["rootId"],
|
|
106
|
+
}
|
|
107
|
+
statement_digest = sha256_canonical(body)
|
|
108
|
+
signed = {**body, "statementDigest": statement_digest}
|
|
109
|
+
statements.append({**signed, "signature": sign_document(signed, root_private_key)})
|
|
110
|
+
return {
|
|
111
|
+
"protocolVersion": "witnora.revocation_journal.v0.2",
|
|
112
|
+
"rootId": journal["rootId"],
|
|
113
|
+
"statements": statements,
|
|
114
|
+
"checkpointDigest": sha256_canonical(statements),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def sign_probe_request(body: Mapping[str, Any], private_key: Ed25519PrivateKey) -> dict[str, Any]:
|
|
119
|
+
value = {"protocolVersion": "witnora.probe_observation_request.v0.2", **body}
|
|
120
|
+
return {**value, "signature": sign_document(value, private_key)}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def sign_probe_attestation(
|
|
124
|
+
body: Mapping[str, Any], private_key: Ed25519PrivateKey
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
value = {"protocolVersion": "witnora.probe_observation_attestation.v0.2", **body}
|
|
127
|
+
return {**value, "signature": sign_document(value, private_key)}
|