sengol-verify 2.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.
- sengol_verify/__init__.py +7 -0
- sengol_verify/bundle.py +144 -0
- sengol_verify/canonical.py +390 -0
- sengol_verify/cli.py +223 -0
- sengol_verify/merkle.py +31 -0
- sengol_verify/verify.py +695 -0
- sengol_verify-2.1.0.dist-info/METADATA +218 -0
- sengol_verify-2.1.0.dist-info/RECORD +11 -0
- sengol_verify-2.1.0.dist-info/WHEEL +4 -0
- sengol_verify-2.1.0.dist-info/entry_points.txt +2 -0
- sengol_verify-2.1.0.dist-info/licenses/LICENSE +201 -0
sengol_verify/bundle.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Loads a sengol evidence bundle from a JSON file, a directory, or a zip.
|
|
2
|
+
|
|
3
|
+
``sengol audit export`` writes a single self-contained JSON file:
|
|
4
|
+
``{"records": [...], "countersignatures": [...], "anchor_receipts": [...],
|
|
5
|
+
"public_keys": {...}}`` (optionally with "pending_countersignatures" and,
|
|
6
|
+
for a JudgeEvidencePack, a "judge_evidence_pack" envelope key). This module
|
|
7
|
+
accepts that file directly, a zip containing exactly one such JSON file, or
|
|
8
|
+
a directory containing one.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import zipfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from sengol_verify.canonical import Record, UnknownRecordType, family_for
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Countersignature",
|
|
22
|
+
"AnchorReceipt",
|
|
23
|
+
"load_bundle",
|
|
24
|
+
"reconstruct_records",
|
|
25
|
+
"reconstruct_countersigs",
|
|
26
|
+
"reconstruct_anchors",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Countersignature:
|
|
32
|
+
countersig_id: str
|
|
33
|
+
record_id: str
|
|
34
|
+
tenant_id: str
|
|
35
|
+
agent_id: str
|
|
36
|
+
payload_hash: str
|
|
37
|
+
algorithm: str
|
|
38
|
+
key_id: str
|
|
39
|
+
signature: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class AnchorReceipt:
|
|
44
|
+
anchor_id: str
|
|
45
|
+
tenant_id: str
|
|
46
|
+
agent_id: str
|
|
47
|
+
merkle_root: str
|
|
48
|
+
leaf_count: int
|
|
49
|
+
seq_low: int
|
|
50
|
+
seq_high: int
|
|
51
|
+
leaf_hashes: list
|
|
52
|
+
tsa_receipt: str | None = None
|
|
53
|
+
tsa_url: str | None = None
|
|
54
|
+
rekor_log_id: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BundleFormatError(ValueError):
|
|
58
|
+
"""The given path does not contain a single, readable bundle JSON."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_json_bundle(path: Path) -> dict:
|
|
62
|
+
try:
|
|
63
|
+
return json.loads(path.read_text())
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
raise BundleFormatError(f"{path}: not valid JSON ({exc})") from exc
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_bundle(path: Path) -> dict:
|
|
69
|
+
"""Return the bundle dict for a .json file, a directory, or a zip."""
|
|
70
|
+
if path.is_dir():
|
|
71
|
+
candidates = sorted(path.glob("*.json"))
|
|
72
|
+
if len(candidates) != 1:
|
|
73
|
+
raise BundleFormatError(
|
|
74
|
+
f"{path}: expected exactly one .json file, found {len(candidates)}"
|
|
75
|
+
)
|
|
76
|
+
return _read_json_bundle(candidates[0])
|
|
77
|
+
|
|
78
|
+
if zipfile.is_zipfile(path):
|
|
79
|
+
with zipfile.ZipFile(path) as zf:
|
|
80
|
+
names = [n for n in zf.namelist() if n.endswith(".json")]
|
|
81
|
+
if len(names) != 1:
|
|
82
|
+
raise BundleFormatError(
|
|
83
|
+
f"{path}: expected exactly one .json member, found {len(names)}"
|
|
84
|
+
)
|
|
85
|
+
with zf.open(names[0]) as fh:
|
|
86
|
+
try:
|
|
87
|
+
return json.loads(fh.read())
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
raise BundleFormatError(f"{path}: not valid JSON ({exc})") from exc
|
|
90
|
+
|
|
91
|
+
return _read_json_bundle(path)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _record(raw: dict) -> Record:
|
|
95
|
+
try:
|
|
96
|
+
return Record(raw, family_for(raw))
|
|
97
|
+
except UnknownRecordType:
|
|
98
|
+
return Record(raw, None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def reconstruct_records(bundle: dict) -> list:
|
|
102
|
+
"""One ``Record`` per bundle record. A record whose ``record_type`` is not
|
|
103
|
+
registered gets ``family=None``: it is kept, so verification reports it,
|
|
104
|
+
but it has no canonical payload."""
|
|
105
|
+
return [_record(raw) for raw in bundle.get("records", [])]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def reconstruct_countersigs(bundle: dict) -> list:
|
|
109
|
+
out = []
|
|
110
|
+
for raw in bundle.get("countersignatures", []):
|
|
111
|
+
out.append(
|
|
112
|
+
Countersignature(
|
|
113
|
+
countersig_id=str(raw.get("countersig_id", "")),
|
|
114
|
+
record_id=str(raw.get("record_id", "")),
|
|
115
|
+
tenant_id=raw.get("tenant_id", ""),
|
|
116
|
+
agent_id=raw.get("agent_id", ""),
|
|
117
|
+
payload_hash=raw.get("payload_hash", ""),
|
|
118
|
+
algorithm=raw.get("algorithm", "ed25519"),
|
|
119
|
+
key_id=raw.get("key_id", ""),
|
|
120
|
+
signature=raw.get("signature", ""),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def reconstruct_anchors(bundle: dict) -> list:
|
|
127
|
+
out = []
|
|
128
|
+
for raw in bundle.get("anchor_receipts", []):
|
|
129
|
+
out.append(
|
|
130
|
+
AnchorReceipt(
|
|
131
|
+
anchor_id=str(raw.get("anchor_id", "")),
|
|
132
|
+
tenant_id=raw.get("tenant_id", ""),
|
|
133
|
+
agent_id=raw.get("agent_id", ""),
|
|
134
|
+
merkle_root=raw.get("merkle_root", ""),
|
|
135
|
+
leaf_count=raw.get("leaf_count", 0),
|
|
136
|
+
seq_low=raw.get("seq_low", 0),
|
|
137
|
+
seq_high=raw.get("seq_high", 0),
|
|
138
|
+
leaf_hashes=list(raw.get("leaf_hashes", [])),
|
|
139
|
+
tsa_receipt=raw.get("tsa_receipt"),
|
|
140
|
+
tsa_url=raw.get("tsa_url"),
|
|
141
|
+
rekor_log_id=raw.get("rekor_log_id"),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return out
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Canonical signed-payload rule, vendored from sengol's
|
|
2
|
+
``sengol/core/payload_registry.py`` (ADR-0019).
|
|
3
|
+
|
|
4
|
+
One rule signs every record: RFC 8785 (JSON Canonicalization Scheme) over
|
|
5
|
+
``{"record": <fields>, "type": "<family>"}``, where ``<fields>`` is the
|
|
6
|
+
record's JSON-mode values minus the signature fields and the family's
|
|
7
|
+
``UNSIGNED_FIELDS``. The per-family/per-version tables below (``_VERSIONS``,
|
|
8
|
+
``_UNSIGNED_FIELDS``, ``_NESTED_UNSIGNED_FIELDS``, ``_UNORDERED_FIELDS``)
|
|
9
|
+
mirror ``sengol/core/types.py`` and ``payload_registry.py`` —
|
|
10
|
+
changing one here would make this tool disagree with sengol about what a
|
|
11
|
+
genuine signature covers.
|
|
12
|
+
|
|
13
|
+
This module has no import on the ``sengol`` package. ``Record`` duck-types
|
|
14
|
+
attribute access over a plain dict reconstructed from stored/exported JSON,
|
|
15
|
+
which is what ``canonical_payload`` actually reads (via ``record._raw``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import rfc8785
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Record",
|
|
27
|
+
"canonical_payload",
|
|
28
|
+
"UnknownPayloadVersion",
|
|
29
|
+
"UnknownRecordType",
|
|
30
|
+
"unsigned_field_paths",
|
|
31
|
+
"RECORD_TYPE_TO_FAMILY",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UnknownPayloadVersion(Exception):
|
|
36
|
+
"""No canonical function is registered for a record's (type, version)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UnknownRecordType(Exception):
|
|
40
|
+
"""A record's ``record_type`` names no family this verifier registers.
|
|
41
|
+
|
|
42
|
+
Such a record has no canonical payload here: reading it as a generic
|
|
43
|
+
``AuditRecord`` would verify bytes sengol never signed under that name.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, record_type: Any) -> None:
|
|
47
|
+
super().__init__(f"record_type {record_type!r} is not registered in this verifier")
|
|
48
|
+
self.record_type = record_type
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Field names that are datetimes in every canonical function that reads them.
|
|
52
|
+
# Any other field is passed through as the raw JSON scalar/list/dict.
|
|
53
|
+
_DATETIME_FIELDS = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"timestamp",
|
|
56
|
+
"period_from",
|
|
57
|
+
"period_to",
|
|
58
|
+
"certified_at",
|
|
59
|
+
"valid_until",
|
|
60
|
+
"purged_at",
|
|
61
|
+
"retired_at",
|
|
62
|
+
"decided_at",
|
|
63
|
+
"authorized_until",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _JsonPassthrough:
|
|
69
|
+
"""Wraps an already-JSON-shaped dict so ``.model_dump(mode="json")`` is a no-op.
|
|
70
|
+
|
|
71
|
+
``eval_result`` is stored as the exact dict a real ``EvalResult.model_dump``
|
|
72
|
+
call produced, so returning it unchanged reproduces the same bytes.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, data: dict) -> None:
|
|
76
|
+
self._data = data
|
|
77
|
+
|
|
78
|
+
def model_dump(self, mode: str = "json") -> dict:
|
|
79
|
+
return self._data
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Record:
|
|
83
|
+
"""Attribute view over one reconstructed record's raw JSON dict.
|
|
84
|
+
|
|
85
|
+
``family`` names which canonical-function family signs this record's
|
|
86
|
+
bytes (e.g. "AuditRecord", "AgentCallRecord") — resolved once, at
|
|
87
|
+
construction, from the record's ``record_type`` string field. It is
|
|
88
|
+
``None`` for a record whose ``record_type`` is not registered; such a
|
|
89
|
+
record has no canonical payload and ``canonical_payload`` raises
|
|
90
|
+
:exc:`UnknownRecordType`.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, raw: dict, family: str | None) -> None:
|
|
94
|
+
self._raw = raw
|
|
95
|
+
self.family = family
|
|
96
|
+
|
|
97
|
+
def __getattr__(self, name: str) -> Any:
|
|
98
|
+
if name not in self._raw:
|
|
99
|
+
return None
|
|
100
|
+
value = self._raw[name]
|
|
101
|
+
if value is not None and name in _DATETIME_FIELDS and isinstance(value, str):
|
|
102
|
+
return datetime.fromisoformat(value)
|
|
103
|
+
if name == "eval_result" and value is not None:
|
|
104
|
+
return _JsonPassthrough(value)
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def payload_version(self) -> int:
|
|
109
|
+
return self._raw.get("payload_version") or 1
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def record_id(self) -> str:
|
|
113
|
+
return str(self._raw.get("record_id", ""))
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def hmac_signature(self) -> str:
|
|
117
|
+
return self._raw.get("hmac_signature", "")
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def key_id(self) -> str:
|
|
121
|
+
return self._raw.get("key_id", "")
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def tenant_id(self) -> str:
|
|
125
|
+
return self._raw.get("tenant_id", "")
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def agent_id(self) -> str:
|
|
129
|
+
return self._raw.get("agent_id", "")
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def sequence_number(self) -> int:
|
|
133
|
+
return self._raw.get("sequence_number", 0)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def prev_hash(self) -> str:
|
|
137
|
+
return self._raw.get("prev_hash", "")
|
|
138
|
+
|
|
139
|
+
def canonical_payload(self) -> str:
|
|
140
|
+
if self.family is None:
|
|
141
|
+
raise UnknownRecordType(self._raw.get("record_type"))
|
|
142
|
+
return canonical_payload(self.family, self.payload_version, self)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# record_type string -> canonical-function family name (sengol AUDIT_RECORD_TYPES).
|
|
147
|
+
# A record with no record_type field is base AuditRecord; one with a
|
|
148
|
+
# record_type missing from this map is refused by ``family_for``.
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
RECORD_TYPE_TO_FAMILY: dict[str, str] = {
|
|
152
|
+
"sengol.agent.call": "AgentCallRecord",
|
|
153
|
+
"sengol.agent.tier_change": "AgentTierChangeRecord",
|
|
154
|
+
"sengol.audit.certification_anchor": "CertificationAnchorRecord",
|
|
155
|
+
"sengol.audit.dataset_rows_reveal": "DatasetRowsRevealRecord",
|
|
156
|
+
"sengol.audit.tombstone": "TombstoneRecord",
|
|
157
|
+
"sengol.audit.trace_reveal": "TraceRevealRecord",
|
|
158
|
+
"sengol.authorization.decision": "AuthorizationDecisionRecord",
|
|
159
|
+
"sengol.behaviour.deviation": "BehaviourDeviationRecord",
|
|
160
|
+
"sengol.behaviour.lease": "AcceptedSetLease",
|
|
161
|
+
"sengol.call.started": "CallStartedRecord",
|
|
162
|
+
"sengol.certification.revocation": "CertificationRevocationRecord",
|
|
163
|
+
"sengol.certification.supersession": "CertificationSupersessionRecord",
|
|
164
|
+
"sengol.gateway.usage": "GatewayUsageRecord",
|
|
165
|
+
"sengol.guard.decision": "GuardDecisionRecord",
|
|
166
|
+
"sengol.judge.model_card": "JudgeModelCard",
|
|
167
|
+
"sengol.judge.promotion_decision": "PromotionDecisionRecord",
|
|
168
|
+
"sengol.lifecycle.deployment_authorization": "DeploymentAuthorizationRecord",
|
|
169
|
+
"sengol.quarantine.event": "AgentQuarantineRecord",
|
|
170
|
+
"sengol.sod.approval": "SoDApprovalRecord",
|
|
171
|
+
"sengol.sod.decision": "SoDDecisionRecord",
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def family_for(raw: dict) -> str:
|
|
176
|
+
"""The canonical-function family for a raw record dict.
|
|
177
|
+
|
|
178
|
+
A record with no ``record_type`` (or a null one) is the base
|
|
179
|
+
``AuditRecord``. Any other value must be a key of
|
|
180
|
+
``RECORD_TYPE_TO_FAMILY``; otherwise this raises :exc:`UnknownRecordType`
|
|
181
|
+
rather than falling back to ``AuditRecord``.
|
|
182
|
+
|
|
183
|
+
``EvaluationRunRecord``, ``CertificationRecord``, ``GoldScoreRecord``,
|
|
184
|
+
``SaturationEvent`` and the two signed regression records
|
|
185
|
+
(``ProductionFailureRecord``, ``RegressionCaseRecord``) carry no
|
|
186
|
+
``record_type`` field at all — each lives outside the audit chain, in its
|
|
187
|
+
own table — so none belongs in ``RECORD_TYPE_TO_FAMILY``. A caller
|
|
188
|
+
reconstructing one of these passes its family to ``Record(raw, family)``
|
|
189
|
+
directly, as ``scripts/crosscheck_canonical.py`` does.
|
|
190
|
+
"""
|
|
191
|
+
record_type = raw.get("record_type")
|
|
192
|
+
if record_type is None:
|
|
193
|
+
return "AuditRecord"
|
|
194
|
+
if isinstance(record_type, str) and record_type in RECORD_TYPE_TO_FAMILY:
|
|
195
|
+
return RECORD_TYPE_TO_FAMILY[record_type]
|
|
196
|
+
raise UnknownRecordType(record_type)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# ADR-0019: one canonical rule signs every record. The payload is RFC 8785
|
|
201
|
+
# (JSON Canonicalization Scheme) over {"record": <fields>, "type": <family>},
|
|
202
|
+
# where <fields> is the record's JSON-mode field values minus the family's
|
|
203
|
+
# UNSIGNED_FIELDS (mirrors sengol/core/payload_registry.py verbatim — do not
|
|
204
|
+
# reintroduce a per-(family, version) function here).
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
#: Every payload_version each family accepts. Copied verbatim from
|
|
208
|
+
#: sengol/core/payload_registry.py::_VERSIONS. This only gates "is this
|
|
209
|
+
#: shape known" — every registered version canonicalizes by the one rule.
|
|
210
|
+
_VERSIONS: dict[str, tuple[int, ...]] = {
|
|
211
|
+
"AcceptedSetLease": (1,),
|
|
212
|
+
"AgentCallRecord": (1, 3, 4),
|
|
213
|
+
"AgentQuarantineRecord": (1,),
|
|
214
|
+
"AgentTierChangeRecord": (1,),
|
|
215
|
+
"AuditRecord": (1, 2, 3),
|
|
216
|
+
"AuthorizationDecisionRecord": (1, 2),
|
|
217
|
+
"BehaviourDeviationRecord": (1,),
|
|
218
|
+
"BehaviourManifestRecord": (1,),
|
|
219
|
+
"CallSetManifestRecord": (1,),
|
|
220
|
+
"CallStartedRecord": (1,),
|
|
221
|
+
"CertificationAnchorRecord": (1,),
|
|
222
|
+
"CertificationRecord": (2, 3, 4),
|
|
223
|
+
"CertificationRevocationRecord": (1,),
|
|
224
|
+
"CertificationSupersessionRecord": (1,),
|
|
225
|
+
"DatasetRowsRevealRecord": (1,),
|
|
226
|
+
"DeploymentAuthorizationRecord": (1,),
|
|
227
|
+
"EvaluationCaseRecord": (1,),
|
|
228
|
+
"EvaluationRunRecord": (2, 3, 4, 5, 6),
|
|
229
|
+
"GatewayUsageRecord": (1,),
|
|
230
|
+
"GoldScoreRecord": (1, 2),
|
|
231
|
+
"GuardDecisionRecord": (1,),
|
|
232
|
+
"JudgeModelCard": (1, 2),
|
|
233
|
+
"ProductionFailureRecord": (1,),
|
|
234
|
+
"PromotionDecisionRecord": (1, 2),
|
|
235
|
+
"RegressionCaseRecord": (1,),
|
|
236
|
+
"SaturationEvent": (1, 2),
|
|
237
|
+
"SoDApprovalRecord": (1,),
|
|
238
|
+
"SoDDecisionRecord": (1, 2, 3),
|
|
239
|
+
"TombstoneRecord": (1, 2),
|
|
240
|
+
"TraceRevealRecord": (1,),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#: The signature itself and the name of the backend that produced it. Every
|
|
244
|
+
#: family leaves these out; a family adds its own via `_UNSIGNED_FIELDS`.
|
|
245
|
+
ALWAYS_UNSIGNED_FIELDS = frozenset({"hmac_signature", "signing_backend"})
|
|
246
|
+
|
|
247
|
+
#: Unsigned on the base ``AuditRecord`` and so on every subtype: the
|
|
248
|
+
#: transport-only manifest sidecar, stripped before a record is stored.
|
|
249
|
+
_AUDIT_UNSIGNED_FIELDS = frozenset({"call_signature_manifests"})
|
|
250
|
+
#: Top-level UNSIGNED_FIELDS per family, mirroring sengol/core/types.py's
|
|
251
|
+
#: class attributes of the same name (ADR-0019). An ``AuditRecord`` family
|
|
252
|
+
#: also leaves out ``_AUDIT_UNSIGNED_FIELDS``; see ``_unsigned_fields``.
|
|
253
|
+
_UNSIGNED_FIELDS: dict[str, frozenset[str]] = {
|
|
254
|
+
"AuthorizationDecisionRecord": frozenset(
|
|
255
|
+
{"eval_result", "mcp_server_version", "model", "input_tokens", "output_tokens", "cost_usd"}
|
|
256
|
+
),
|
|
257
|
+
"GuardDecisionRecord": frozenset({"eval_result"}),
|
|
258
|
+
"SaturationEvent": frozenset({"remediated_at"}),
|
|
259
|
+
"CertificationRecord": frozenset({"anchor_record_id", "evidence_pack_id"}),
|
|
260
|
+
}
|
|
261
|
+
#: UNSIGNED_FIELDS of the models nested inside a record, keyed by the path of
|
|
262
|
+
#: field names from the record root (list positions do not count). A nested
|
|
263
|
+
#: model's own exclusions apply only where the path names it, and only in the
|
|
264
|
+
#: families whose class holds that model at that path:
|
|
265
|
+
#: ``EvalScore`` (``reason`` is erasable text bound by the signed
|
|
266
|
+
#: ``reason_digest``; ``reason_status`` describes a read) sits at
|
|
267
|
+
#: ``eval_result.scores`` in every ``AuditRecord`` subtype, and ``CaseBinding``
|
|
268
|
+
#: (raw content bound by its signed ``config_hash`` and ``reference_hash``)
|
|
269
|
+
#: sits at ``bindings`` in ``RegressionCaseRecord`` alone.
|
|
270
|
+
_AUDIT_NESTED_UNSIGNED_FIELDS: dict[tuple[str, ...], frozenset[str]] = {
|
|
271
|
+
("eval_result", "scores"): frozenset({"reason", "reason_status"}),
|
|
272
|
+
}
|
|
273
|
+
_NESTED_UNSIGNED_FIELDS: dict[str, dict[tuple[str, ...], frozenset[str]]] = {
|
|
274
|
+
"RegressionCaseRecord": {("bindings",): frozenset({"config", "reference"})},
|
|
275
|
+
}
|
|
276
|
+
#: Lists whose order carries no meaning; sorted by their own RFC 8785 bytes
|
|
277
|
+
#: before signing (ADR-0019), mirroring each family's UNORDERED_FIELDS.
|
|
278
|
+
_UNORDERED_FIELDS: dict[str, frozenset[str]] = {
|
|
279
|
+
"AcceptedSetLease": frozenset({"accepted_cert_ids"}),
|
|
280
|
+
"CertificationRecord": frozenset(
|
|
281
|
+
{"acknowledged_unversioned", "endpoint_equivalents", "environment_mounts", "supersedes"}
|
|
282
|
+
),
|
|
283
|
+
"CertificationSupersessionRecord": frozenset({"supersedes"}),
|
|
284
|
+
"DatasetRowsRevealRecord": frozenset({"row_indexes"}),
|
|
285
|
+
"EvaluationCaseRecord": frozenset({"scores", "skipped"}),
|
|
286
|
+
"EvaluationRunRecord": frozenset({"evaluator_manifest"}),
|
|
287
|
+
"GuardDecisionRecord": frozenset({"policies", "policy_outcomes"}),
|
|
288
|
+
"SoDDecisionRecord": frozenset({"approval_record_ids"}),
|
|
289
|
+
"TombstoneRecord": frozenset({"purged_dataset_ids", "purged_trace_ids"}),
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _unsigned_fields(family: str) -> frozenset[str]:
|
|
294
|
+
unsigned = ALWAYS_UNSIGNED_FIELDS | _UNSIGNED_FIELDS.get(family, frozenset())
|
|
295
|
+
if _is_audit_family(family):
|
|
296
|
+
unsigned |= _AUDIT_UNSIGNED_FIELDS
|
|
297
|
+
return unsigned
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _is_audit_family(family: str) -> bool:
|
|
301
|
+
return family == "AuditRecord" or family in RECORD_TYPE_TO_FAMILY.values()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def nested_unsigned_fields(family: str) -> dict[tuple[str, ...], frozenset[str]]:
|
|
305
|
+
"""The nested-model exclusions that apply to *family*, by path."""
|
|
306
|
+
if _is_audit_family(family):
|
|
307
|
+
return _AUDIT_NESTED_UNSIGNED_FIELDS
|
|
308
|
+
return _NESTED_UNSIGNED_FIELDS.get(family, {})
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _strip(
|
|
312
|
+
value: Any,
|
|
313
|
+
unsigned: frozenset[str],
|
|
314
|
+
nested: dict[tuple[str, ...], frozenset[str]],
|
|
315
|
+
path: tuple[str, ...] = (),
|
|
316
|
+
) -> Any:
|
|
317
|
+
"""Drop *unsigned* keys and every ``None`` value, recursing into dicts
|
|
318
|
+
and lists so a nested model's own exclusions apply where its path
|
|
319
|
+
from the record root names it."""
|
|
320
|
+
if isinstance(value, dict):
|
|
321
|
+
out = {}
|
|
322
|
+
for k, v in value.items():
|
|
323
|
+
if k in unsigned or v is None:
|
|
324
|
+
continue
|
|
325
|
+
sub = path + (k,)
|
|
326
|
+
out[k] = _strip(v, nested.get(sub, frozenset()), nested, sub)
|
|
327
|
+
return out
|
|
328
|
+
if isinstance(value, list):
|
|
329
|
+
return [_strip(v, unsigned, nested, path) for v in value]
|
|
330
|
+
return value
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _element_key(value: Any) -> bytes:
|
|
334
|
+
return rfc8785.dumps(value)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def canonical_payload(family: str, version: int, record: Any) -> str:
|
|
338
|
+
"""Return the RFC 8785 canonical JSON string for *record* (ADR-0019).
|
|
339
|
+
|
|
340
|
+
Raises :exc:`UnknownPayloadVersion` if *version* is not registered for
|
|
341
|
+
*family*, and lets ``rfc8785.CanonicalizationError`` propagate for a
|
|
342
|
+
signed value with no RFC 8785 form (NaN, Infinity, an integer beyond
|
|
343
|
+
2**53) — the same refusal real sengol makes.
|
|
344
|
+
"""
|
|
345
|
+
if version not in _VERSIONS.get(family, ()):
|
|
346
|
+
raise UnknownPayloadVersion(
|
|
347
|
+
f"No canonical payload registered for {family} v{version}. "
|
|
348
|
+
f"Known versions: {_VERSIONS.get(family, ())}"
|
|
349
|
+
)
|
|
350
|
+
fields = _strip(record._raw, _unsigned_fields(family), nested_unsigned_fields(family))
|
|
351
|
+
for name in _UNORDERED_FIELDS.get(family, frozenset()):
|
|
352
|
+
if isinstance(fields.get(name), list):
|
|
353
|
+
fields[name] = sorted(fields[name], key=_element_key)
|
|
354
|
+
return rfc8785.dumps({"record": fields, "type": family}).decode("utf-8")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def unsigned_field_paths(record: Record) -> list[str]:
|
|
358
|
+
"""The set fields of *record* that sit outside its signature, as sorted
|
|
359
|
+
dotted paths from the record root (list positions do not count).
|
|
360
|
+
|
|
361
|
+
A field is listed when its value is not ``None`` and either the family
|
|
362
|
+
leaves it out (the signature fields, the family's ``UNSIGNED_FIELDS``) or
|
|
363
|
+
the family's nested table names its path (``eval_result.scores.reason``).
|
|
364
|
+
A field inside an unsigned parent is not listed again. This walks the
|
|
365
|
+
record exactly as ``_strip`` does, so every path returned is absent from
|
|
366
|
+
``canonical_payload``. A record with no family has no signature rule and
|
|
367
|
+
returns ``[]``.
|
|
368
|
+
"""
|
|
369
|
+
if record.family is None:
|
|
370
|
+
return []
|
|
371
|
+
found: set[str] = set()
|
|
372
|
+
|
|
373
|
+
nested = nested_unsigned_fields(record.family)
|
|
374
|
+
|
|
375
|
+
def walk(value: Any, unsigned: frozenset[str], path: tuple[str, ...]) -> None:
|
|
376
|
+
if isinstance(value, dict):
|
|
377
|
+
for k, v in value.items():
|
|
378
|
+
if v is None:
|
|
379
|
+
continue
|
|
380
|
+
sub = path + (k,)
|
|
381
|
+
if k in unsigned:
|
|
382
|
+
found.add(".".join(sub))
|
|
383
|
+
else:
|
|
384
|
+
walk(v, nested.get(sub, frozenset()), sub)
|
|
385
|
+
elif isinstance(value, list):
|
|
386
|
+
for v in value:
|
|
387
|
+
walk(v, unsigned, path)
|
|
388
|
+
|
|
389
|
+
walk(record._raw, _unsigned_fields(record.family), ())
|
|
390
|
+
return sorted(found)
|