zft 0.2.0a1__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.
- traceagent/__init__.py +7 -0
- traceagent/attest/__init__.py +1 -0
- traceagent/attest/dsse.py +378 -0
- traceagent/attest/errors.py +27 -0
- traceagent/attest/export.py +135 -0
- traceagent/attest/jcs.py +130 -0
- traceagent/attest/keys.py +151 -0
- traceagent/cli/main.py +566 -0
- traceagent/codegen/__init__.py +1 -0
- traceagent/codegen/gherkin_gen.py +49 -0
- traceagent/codegen/property_gen.py +122 -0
- traceagent/debug/__init__.py +1 -0
- traceagent/debug/ledger.py +152 -0
- traceagent/debug/repro.py +44 -0
- traceagent/dsl/__init__.py +1 -0
- traceagent/dsl/ears.py +112 -0
- traceagent/dsl/oracle.py +122 -0
- traceagent/dsl/predicate.py +317 -0
- traceagent/dsl/strategies.py +112 -0
- traceagent/gates/__init__.py +1 -0
- traceagent/gates/driver_gate.py +242 -0
- traceagent/gates/l0.py +91 -0
- traceagent/gates/l1.py +361 -0
- traceagent/gates/l2.py +235 -0
- traceagent/gates/l3.py +29 -0
- traceagent/gates/mutation_bar.py +371 -0
- traceagent/gates/runners/__init__.py +1 -0
- traceagent/gates/runners/mutmut_runner.py +263 -0
- traceagent/gates/runners/pytest_runner.py +62 -0
- traceagent/gates/sandbox.py +195 -0
- traceagent/lineage/__init__.py +4 -0
- traceagent/lineage/extract.py +281 -0
- traceagent/lineage/impact.py +79 -0
- traceagent/lineage/matrix.py +196 -0
- traceagent/negotiate/__init__.py +1 -0
- traceagent/negotiate/a2a_adapter.py +88 -0
- traceagent/negotiate/resume.py +162 -0
- traceagent/negotiate/sm.py +126 -0
- traceagent/negotiate/terms.py +96 -0
- traceagent/negotiate/transport.py +375 -0
- traceagent/registry/__init__.py +1 -0
- traceagent/registry/conflicts.py +21 -0
- traceagent/spec/__init__.py +1 -0
- traceagent/spec/canon.py +40 -0
- traceagent/spec/delta.py +33 -0
- traceagent/spec/findings.py +71 -0
- traceagent/spec/identity.py +12 -0
- traceagent/spec/lint.py +115 -0
- traceagent/spec/schema.py +67 -0
- traceagent/spec/store.py +44 -0
- traceagent/taskgate.py +161 -0
- zft-0.2.0a1.dist-info/METADATA +163 -0
- zft-0.2.0a1.dist-info/RECORD +57 -0
- zft-0.2.0a1.dist-info/WHEEL +5 -0
- zft-0.2.0a1.dist-info/entry_points.txt +3 -0
- zft-0.2.0a1.dist-info/licenses/LICENSE +202 -0
- zft-0.2.0a1.dist-info/top_level.txt +1 -0
traceagent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""attest: DSSE TraceManifest attestations over the contract store."""
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""DSSE attestation (plan C-28): TraceManifest predicate binding clause hashes.
|
|
2
|
+
|
|
3
|
+
Substrate: securesystemslib (in-toto 3.x's underlying DSSE library; in-toto 3.1
|
|
4
|
+
removed its legacy models API). Predicate URI versioned: .../TraceManifest/v1.
|
|
5
|
+
|
|
6
|
+
Payloads are canonicalized per RFC 8785 (JCS, see attest.jcs) before signing —
|
|
7
|
+
securesystemslib's encode_canonical is the older OLPC Canonical-JSON draft
|
|
8
|
+
(no float support, code-point key ordering), not JCS, so we do not sign over it.
|
|
9
|
+
|
|
10
|
+
Malformed envelopes raise the typed errors below instead of leaking
|
|
11
|
+
securesystemslib/binascii internals; parse_envelope collects every format
|
|
12
|
+
problem it finds before failing (parser-style error recovery).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import binascii
|
|
18
|
+
import copy
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
from securesystemslib.dsse import Envelope
|
|
27
|
+
from securesystemslib.exceptions import FormatError, VerificationError
|
|
28
|
+
from securesystemslib.signer import CryptoSigner, Key, Signer
|
|
29
|
+
|
|
30
|
+
from traceagent.attest.errors import (
|
|
31
|
+
AttestationError,
|
|
32
|
+
EnvelopeFormatError,
|
|
33
|
+
KeyValidationError,
|
|
34
|
+
SignatureVerificationError,
|
|
35
|
+
SubjectMismatchError,
|
|
36
|
+
)
|
|
37
|
+
from traceagent.attest.jcs import canonicalize
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from traceagent.attest.keys import KeyDocument
|
|
41
|
+
|
|
42
|
+
PREDICATE_TYPE = "https://traceagent.dev/attestations/TraceManifest/v1"
|
|
43
|
+
PAYLOAD_TYPE = "application/vnd.in-toto+json"
|
|
44
|
+
STATEMENT_TYPE = "https://in-toto.io/Statement/v1"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"AttestationError", "EnvelopeFormatError", "KeyValidationError",
|
|
48
|
+
"SignatureVerificationError", "SubjectMismatchError",
|
|
49
|
+
"attest_contract", "clause_subjects", "decode_payload", "parse_envelope",
|
|
50
|
+
"parse_payload", "verify_attestation",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _gate_source_root() -> Path:
|
|
55
|
+
return Path(__file__).resolve().parent.parent.parent.parent
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _gate_source_files() -> dict[str, str]:
|
|
59
|
+
"""Relative-path -> sha256 hex for the gate source files embedded in the manifest."""
|
|
60
|
+
source_root = _gate_source_root()
|
|
61
|
+
gate_files = [
|
|
62
|
+
source_root / "src" / "traceagent" / "gates" / "l1.py",
|
|
63
|
+
source_root / "src" / "traceagent" / "gates" / "l2.py",
|
|
64
|
+
source_root / "src" / "traceagent" / "codegen" / "property_gen.py",
|
|
65
|
+
]
|
|
66
|
+
gate_hashes: dict[str, str] = {}
|
|
67
|
+
for f in gate_files:
|
|
68
|
+
rel = f.relative_to(source_root)
|
|
69
|
+
gate_hashes[str(rel)] = hashlib.sha256(f.read_bytes()).hexdigest()
|
|
70
|
+
return gate_hashes
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_gate_manifest(payload: dict) -> None:
|
|
74
|
+
"""Fail if the gate manifest's own file hashes are tampered (zft lineage:
|
|
75
|
+
gate self-verification, unioned 2026-09-12)."""
|
|
76
|
+
predicate = payload.get("predicate")
|
|
77
|
+
if not isinstance(predicate, dict):
|
|
78
|
+
return
|
|
79
|
+
manifest = predicate.get("gate_manifest")
|
|
80
|
+
if not isinstance(manifest, dict):
|
|
81
|
+
return
|
|
82
|
+
files = manifest.get("files")
|
|
83
|
+
if not isinstance(files, dict):
|
|
84
|
+
return
|
|
85
|
+
for rel, recorded in files.items():
|
|
86
|
+
actual_path = _gate_source_root() / rel
|
|
87
|
+
try:
|
|
88
|
+
actual = hashlib.sha256(actual_path.read_bytes()).hexdigest()
|
|
89
|
+
except OSError:
|
|
90
|
+
raise AttestationError(
|
|
91
|
+
f"gate manifest file {rel!r} missing or unreadable"
|
|
92
|
+
) from None
|
|
93
|
+
if actual != recorded:
|
|
94
|
+
raise AttestationError(
|
|
95
|
+
f"gate manifest file {rel!r} hash mismatch: expected {recorded!r}, "
|
|
96
|
+
f"found {actual!r}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def clause_subjects(root: Path) -> list[dict]:
|
|
100
|
+
subjects = []
|
|
101
|
+
for path in sorted((root / ".zft" / "specs").rglob("*.json")):
|
|
102
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
103
|
+
subjects.append({"name": f"clause:{path.name}", "digest": {"sha256": digest}})
|
|
104
|
+
return subjects
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _deterministic_coverage(root: Path) -> str:
|
|
108
|
+
"""covered/due clause count for milestone v0 (bindings vs deferred targets)."""
|
|
109
|
+
from traceagent.lineage.extract import extract_bindings
|
|
110
|
+
from traceagent.spec.store import Store, load_contract
|
|
111
|
+
|
|
112
|
+
store = Store.load(root)
|
|
113
|
+
milestone = "v0"
|
|
114
|
+
try:
|
|
115
|
+
deferred = load_contract(root).get("meta", {}).get("target_milestone", {})
|
|
116
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
117
|
+
deferred = {}
|
|
118
|
+
due = {a for a, _n in store.nodes.items() if deferred.get(a, milestone) <= milestone}
|
|
119
|
+
bindings = extract_bindings(root)
|
|
120
|
+
bound = sorted({b["alias"] for b in bindings if b["alias"] in due})
|
|
121
|
+
return f"{len(bound)}/{len(due)}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def attest_contract(root: Path | str, producer_model: str | None = None,
|
|
125
|
+
gate_model: str | None = None, signer: Signer | None = None,
|
|
126
|
+
deterministic_coverage: str | None = None) -> dict:
|
|
127
|
+
"""Sign a TraceManifest statement over the current store; returns DSSE envelope dict.
|
|
128
|
+
|
|
129
|
+
Payload is RFC 8785-canonicalized before signing. Sign with the passed
|
|
130
|
+
signer; the caller MUST keep it for verification. deterministic_coverage:
|
|
131
|
+
if None, computed from bindings vs due clauses.
|
|
132
|
+
"""
|
|
133
|
+
root = Path(root)
|
|
134
|
+
signer = signer or CryptoSigner.generate_ed25519()
|
|
135
|
+
subjects = clause_subjects(root)
|
|
136
|
+
if not subjects:
|
|
137
|
+
raise AttestationError(
|
|
138
|
+
f"no clause specs found under {root / '.zft' / 'specs'} — nothing to attest")
|
|
139
|
+
|
|
140
|
+
from traceagent.spec.store import load_contract
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
contract = load_contract(root)
|
|
144
|
+
deferred = contract.get("meta", {}).get("target_milestone", {})
|
|
145
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
146
|
+
deferred = {}
|
|
147
|
+
if deterministic_coverage is None:
|
|
148
|
+
deterministic_coverage = _deterministic_coverage(root)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
payload = {
|
|
152
|
+
"_type": STATEMENT_TYPE,
|
|
153
|
+
"subject": subjects,
|
|
154
|
+
"predicateType": PREDICATE_TYPE,
|
|
155
|
+
"predicate": {
|
|
156
|
+
"coverage": {"clauses_covered": deterministic_coverage or "pending-L2"},
|
|
157
|
+
"judge_excluded": [],
|
|
158
|
+
"deferred": sorted(deferred),
|
|
159
|
+
"models": {"producer": producer_model, "gate": gate_model},
|
|
160
|
+
"model_dependent": producer_model == gate_model,
|
|
161
|
+
"gate_manifest": {
|
|
162
|
+
"files": _gate_source_files(),
|
|
163
|
+
"python": sys.version.split()[0],
|
|
164
|
+
"pytest": pytest.__version__,
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
# JCS is the last gate before signing: a payload we cannot canonicalize
|
|
169
|
+
# (lone surrogate from surrogateescaped argv or escaped contract JSON,
|
|
170
|
+
# an out-of-double-range integer, ...) must be rejected typed and unsigned.
|
|
171
|
+
try:
|
|
172
|
+
payload_bytes = canonicalize(payload)
|
|
173
|
+
except ValueError as exc:
|
|
174
|
+
raise AttestationError(
|
|
175
|
+
f"payload is not RFC 8785 canonicalizable: {exc}") from exc
|
|
176
|
+
envelope = Envelope(
|
|
177
|
+
payload=payload_bytes,
|
|
178
|
+
payload_type=PAYLOAD_TYPE,
|
|
179
|
+
signatures={},
|
|
180
|
+
)
|
|
181
|
+
envelope.sign(signer)
|
|
182
|
+
return envelope.to_dict()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _envelope_problems(envelope_dict: object) -> list[str]:
|
|
186
|
+
"""Collect ALL structural problems in a would-be DSSE envelope (error recovery)."""
|
|
187
|
+
if not isinstance(envelope_dict, dict):
|
|
188
|
+
return [f"envelope must be a JSON object, got {type(envelope_dict).__name__}"]
|
|
189
|
+
problems: list[str] = []
|
|
190
|
+
payload = envelope_dict.get("payload")
|
|
191
|
+
if not isinstance(payload, str) or not payload:
|
|
192
|
+
problems.append(f"'payload' must be a non-empty base64 string, got {payload!r}")
|
|
193
|
+
payload_type = envelope_dict.get("payloadType")
|
|
194
|
+
if not isinstance(payload_type, str) or not payload_type:
|
|
195
|
+
problems.append(f"'payloadType' must be a non-empty string, got {payload_type!r}")
|
|
196
|
+
signatures = envelope_dict.get("signatures")
|
|
197
|
+
# DSSE envelopes carry signatures as an array; securesystemslib's
|
|
198
|
+
# from_dict iterates it, so the legacy {keyid: sig} mapping shape is
|
|
199
|
+
# rejected here rather than by a stray TypeError deeper in.
|
|
200
|
+
if not isinstance(signatures, list) or not signatures:
|
|
201
|
+
problems.append(f"'signatures' must be a non-empty list, got {signatures!r}")
|
|
202
|
+
return problems
|
|
203
|
+
# each entry must at least be shaped for from_dict (a bare null/str/int
|
|
204
|
+
# entry used to escape as AttributeError from the substrate)
|
|
205
|
+
for i, entry in enumerate(signatures):
|
|
206
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("sig"), str):
|
|
207
|
+
problems.append(
|
|
208
|
+
f"'signatures[{i}]' must be an object with a string 'sig', got {entry!r}")
|
|
209
|
+
return problems
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _canonical_base64(text: str, what: str) -> None:
|
|
213
|
+
"""Accept only canonical RFC 4648 standard-alphabet base64 (zero pad bits).
|
|
214
|
+
|
|
215
|
+
Two malleabilities are closed here: Python's b64decode ignores non-zero
|
|
216
|
+
pad bits, so 'QR==' decodes like 'QQ==' — a flipped last character yields
|
|
217
|
+
a byte-different envelope that still verifies; and securesystemslib also
|
|
218
|
+
decodes the urlsafe alphabet, so re-rendering a field urlsafe would give
|
|
219
|
+
the same signed content a textually different envelope (G5: envelope
|
|
220
|
+
equality must be byte equality — matrix CANON-2/3/6).
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
data = base64.b64decode(text.encode("utf-8"), validate=True)
|
|
224
|
+
except (binascii.Error, ValueError) as exc: # binascii.Error ⊂ ValueError
|
|
225
|
+
raise EnvelopeFormatError(
|
|
226
|
+
f"{what} is not canonical base64: {exc}") from exc
|
|
227
|
+
if base64.b64encode(data).decode("ascii") != text:
|
|
228
|
+
raise EnvelopeFormatError(
|
|
229
|
+
f"{what} is not canonical base64 "
|
|
230
|
+
f"(non-zero pad bits or altered padding): got {text!r}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_envelope(envelope_dict: dict) -> Envelope:
|
|
234
|
+
"""Validate + parse an envelope dict, reporting every format problem at once."""
|
|
235
|
+
problems = _envelope_problems(envelope_dict)
|
|
236
|
+
if problems:
|
|
237
|
+
raise EnvelopeFormatError("malformed DSSE envelope: " + "; ".join(problems))
|
|
238
|
+
_canonical_base64(envelope_dict["payload"], "'payload'")
|
|
239
|
+
for entry in envelope_dict["signatures"]:
|
|
240
|
+
if isinstance(entry, dict) and isinstance(entry.get("sig"), str):
|
|
241
|
+
_canonical_base64(entry["sig"], "'signatures[].sig'")
|
|
242
|
+
try:
|
|
243
|
+
# from_dict consumes signature entries in place — parse a copy so the
|
|
244
|
+
# caller's dict (and raw-envelope exports) stay pristine.
|
|
245
|
+
return Envelope.from_dict(copy.deepcopy(envelope_dict))
|
|
246
|
+
except (KeyError, TypeError, ValueError, FormatError) as exc:
|
|
247
|
+
# bad base64 (binascii.Error ⊂ ValueError), missing fields, bad shapes
|
|
248
|
+
raise EnvelopeFormatError(f"malformed DSSE envelope: {exc}") from exc
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def decode_payload(envelope: Envelope) -> dict:
|
|
252
|
+
"""UTF-8 + JSON decode a parsed envelope's payload into a dict."""
|
|
253
|
+
try:
|
|
254
|
+
text = envelope.payload.decode("utf-8")
|
|
255
|
+
except (UnicodeDecodeError, AttributeError) as exc:
|
|
256
|
+
raise EnvelopeFormatError(f"payload is not valid UTF-8: {exc}") from exc
|
|
257
|
+
try:
|
|
258
|
+
payload = json.loads(text)
|
|
259
|
+
except json.JSONDecodeError as exc:
|
|
260
|
+
raise EnvelopeFormatError(f"payload is not valid JSON: {exc}") from exc
|
|
261
|
+
if not isinstance(payload, dict):
|
|
262
|
+
raise EnvelopeFormatError(
|
|
263
|
+
f"payload must be a JSON object, got {type(payload).__name__}")
|
|
264
|
+
return payload
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _statement_problems(payload: dict) -> list[str]:
|
|
268
|
+
problems = []
|
|
269
|
+
if payload.get("_type") != STATEMENT_TYPE:
|
|
270
|
+
problems.append(f"'_type' must be {STATEMENT_TYPE!r}, got {payload.get('_type')!r}")
|
|
271
|
+
subject = payload.get("subject")
|
|
272
|
+
if not isinstance(subject, list) or not subject:
|
|
273
|
+
problems.append(f"'subject' must be a non-empty list, got {subject!r}")
|
|
274
|
+
predicate_type = payload.get("predicateType")
|
|
275
|
+
if not isinstance(predicate_type, str) or not predicate_type:
|
|
276
|
+
problems.append(f"'predicateType' must be a non-empty string, got {predicate_type!r}")
|
|
277
|
+
if not isinstance(payload.get("predicate"), dict):
|
|
278
|
+
problems.append(f"'predicate' must be an object, got {payload.get('predicate')!r}")
|
|
279
|
+
return problems
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def parse_payload(envelope_dict: dict) -> dict:
|
|
283
|
+
"""Parse an envelope dict all the way to a validated in-toto Statement dict."""
|
|
284
|
+
payload = decode_payload(parse_envelope(envelope_dict))
|
|
285
|
+
if envelope_dict["payloadType"] != PAYLOAD_TYPE:
|
|
286
|
+
raise EnvelopeFormatError(
|
|
287
|
+
f"unsupported payloadType {envelope_dict['payloadType']!r} "
|
|
288
|
+
f"(expected {PAYLOAD_TYPE!r})")
|
|
289
|
+
problems = _statement_problems(payload)
|
|
290
|
+
if problems:
|
|
291
|
+
raise EnvelopeFormatError(
|
|
292
|
+
"payload is not a valid in-toto statement: " + "; ".join(problems))
|
|
293
|
+
return payload
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _check_subjects(payload: dict, expected_subjects: dict[str, str]) -> None:
|
|
297
|
+
subjects = payload.get("subject")
|
|
298
|
+
if not isinstance(subjects, list):
|
|
299
|
+
raise EnvelopeFormatError(
|
|
300
|
+
f"payload 'subject' must be a list, got {type(subjects).__name__}")
|
|
301
|
+
got: dict[str, str] = {}
|
|
302
|
+
for entry in subjects:
|
|
303
|
+
try:
|
|
304
|
+
got[entry["name"]] = entry["digest"]["sha256"]
|
|
305
|
+
except (TypeError, KeyError, IndexError) as exc:
|
|
306
|
+
raise EnvelopeFormatError(f"malformed subject entry {entry!r}: {exc}") from exc
|
|
307
|
+
if got != expected_subjects:
|
|
308
|
+
changed = sorted(
|
|
309
|
+
f"{n} (attested {got[n][:12]}…, store {expected_subjects[n][:12]}…)"
|
|
310
|
+
for n in got.keys() & expected_subjects.keys()
|
|
311
|
+
if got[n] != expected_subjects[n])
|
|
312
|
+
missing = sorted(got.keys() - expected_subjects.keys())
|
|
313
|
+
added = sorted(expected_subjects.keys() - got.keys())
|
|
314
|
+
detail = "; ".join(
|
|
315
|
+
part for part in (
|
|
316
|
+
f"changed: {', '.join(changed)}" if changed else "",
|
|
317
|
+
f"attested but not in store: {', '.join(missing)}" if missing else "",
|
|
318
|
+
f"in store but not attested (subject-count mismatch): "
|
|
319
|
+
f"{', '.join(added)}" if added else "",
|
|
320
|
+
) if part)
|
|
321
|
+
raise SubjectMismatchError(
|
|
322
|
+
f"subject digests do not match the contract store: {detail}")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def verify_attestation(envelope_dict: dict, expected_subjects: dict[str, str] | None = None,
|
|
326
|
+
public_key: Key | None = None, *,
|
|
327
|
+
key_document: "KeyDocument | None" = None,
|
|
328
|
+
now=None, expect_keyid: str | None = None) -> dict:
|
|
329
|
+
"""Verify signature + subject digests; returns the payload dict on success.
|
|
330
|
+
|
|
331
|
+
Guards raise EnvelopeFormatError for malformed envelopes (all format
|
|
332
|
+
problems reported at once), KeyValidationError for missing/unusable keys
|
|
333
|
+
and key-policy failures, SignatureVerificationError when the signature
|
|
334
|
+
check fails, and SubjectMismatchError when verified digests differ from
|
|
335
|
+
expected_subjects.
|
|
336
|
+
|
|
337
|
+
Key policy (ATTACK_MATRIX G7): pass key_document (attest.keys.KeyDocument,
|
|
338
|
+
parsed from the published key file) instead of a bare public_key to have
|
|
339
|
+
revocation, the validity window, and an optional expect_keyid pin enforced
|
|
340
|
+
BEFORE any cryptographic work. now overrides the clock for the window
|
|
341
|
+
check (deterministic tests); naive datetimes are taken as UTC.
|
|
342
|
+
"""
|
|
343
|
+
if key_document is not None and public_key is not None:
|
|
344
|
+
raise KeyValidationError(
|
|
345
|
+
"pass either public_key or key_document, not both — "
|
|
346
|
+
"which key governs verification must be unambiguous")
|
|
347
|
+
if key_document is not None:
|
|
348
|
+
public_key = key_document.check(now=now, expect_keyid=expect_keyid)
|
|
349
|
+
if public_key is None:
|
|
350
|
+
raise KeyValidationError(
|
|
351
|
+
"verify_attestation requires public_key (dev: signer.public_key)")
|
|
352
|
+
if not isinstance(public_key, Key):
|
|
353
|
+
raise KeyValidationError(
|
|
354
|
+
f"public_key must be a securesystemslib Key, got {type(public_key).__name__}")
|
|
355
|
+
if not getattr(public_key, "keyid", ""):
|
|
356
|
+
raise KeyValidationError("public_key carries no keyid")
|
|
357
|
+
envelope = parse_envelope(envelope_dict)
|
|
358
|
+
if envelope_dict["payloadType"] != PAYLOAD_TYPE:
|
|
359
|
+
raise EnvelopeFormatError(
|
|
360
|
+
f"unsupported payloadType {envelope_dict['payloadType']!r} "
|
|
361
|
+
f"(expected {PAYLOAD_TYPE!r})")
|
|
362
|
+
try:
|
|
363
|
+
envelope.verify([public_key], 1)
|
|
364
|
+
except VerificationError as exc:
|
|
365
|
+
raise SignatureVerificationError(f"signature verification failed: {exc}") from exc
|
|
366
|
+
except ValueError as exc: # threshold / keyid-matching rejections
|
|
367
|
+
raise KeyValidationError(f"key rejected by DSSE verifier: {exc}") from exc
|
|
368
|
+
except Exception as exc: # substrate escapes (review L-3): typed, never raw
|
|
369
|
+
if isinstance(exc, AttestationError):
|
|
370
|
+
raise
|
|
371
|
+
raise SignatureVerificationError(
|
|
372
|
+
f"signature verification layer failed: "
|
|
373
|
+
f"{type(exc).__name__}: {exc}") from exc
|
|
374
|
+
payload = decode_payload(envelope)
|
|
375
|
+
if expected_subjects is not None:
|
|
376
|
+
_check_subjects(payload, expected_subjects)
|
|
377
|
+
_check_gate_manifest(payload)
|
|
378
|
+
return payload
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Typed attestation errors, shared by dsse.py (crypto + envelope) and
|
|
2
|
+
keys.py (key documents + policy). Lives in its own module so the two can
|
|
3
|
+
import the taxonomy without a cycle; dsse re-exports everything, so
|
|
4
|
+
``from traceagent.attest.dsse import …`` keeps working.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AttestationError(ValueError):
|
|
10
|
+
"""Base class: malformed attestations, bad keys, failed verification."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EnvelopeFormatError(AttestationError):
|
|
14
|
+
"""DSSE envelope is structurally malformed (fields, base64, JSON payload)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class KeyValidationError(AttestationError):
|
|
18
|
+
"""Verification key is missing, unusable, or fails key policy
|
|
19
|
+
(revoked, outside its validity window, keyid pin mismatch)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SignatureVerificationError(AttestationError):
|
|
23
|
+
"""Envelope signature did not verify against the provided key."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SubjectMismatchError(AttestationError):
|
|
27
|
+
"""Verified payload's subject digests differ from the expected set."""
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Compliance export (plan C-30): derive formats from attested manifests ONLY.
|
|
2
|
+
|
|
3
|
+
Structured views over the attested envelope in ``.traceagent/attest.json``:
|
|
4
|
+
``export_envelope`` (raw DSSE envelope, validated), ``export_matrix`` (trace
|
|
5
|
+
matrix dict), and ``export_summary`` (human-readable attestation summary).
|
|
6
|
+
Envelopes are structure-validated with the dsse guards; export is refused
|
|
7
|
+
without an attestation file (ATT-EXPORTS-FROM-ATTESTATIONS).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from traceagent.attest.dsse import EnvelopeFormatError, parse_envelope, parse_payload
|
|
15
|
+
|
|
16
|
+
FORMATS = ("dsse", "matrix", "summary")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def export_attestation(root: Path | str, fmt: str = "matrix") -> dict | str:
|
|
20
|
+
"""Dispatch an export by format: 'dsse' (raw envelope), 'matrix', 'summary'."""
|
|
21
|
+
if fmt not in FORMATS:
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"unknown export format {fmt!r} — expected one of: {', '.join(FORMATS)}")
|
|
24
|
+
if fmt == "dsse":
|
|
25
|
+
return export_envelope(root)
|
|
26
|
+
if fmt == "summary":
|
|
27
|
+
return export_summary(root)
|
|
28
|
+
return export_matrix(root)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_envelope(root: Path | str) -> dict:
|
|
32
|
+
"""Load the attested envelope; refuse without one (ATT-EXPORTS-FROM-ATTESTATIONS)."""
|
|
33
|
+
att_path = Path(root) / ".traceagent" / "attest.json"
|
|
34
|
+
if not att_path.exists():
|
|
35
|
+
raise FileNotFoundError(
|
|
36
|
+
"no attestation found — export refused (ATT-EXPORTS-FROM-ATTESTATIONS)") # noqa: E501
|
|
37
|
+
try:
|
|
38
|
+
envelope = json.loads(att_path.read_text())
|
|
39
|
+
except json.JSONDecodeError as exc:
|
|
40
|
+
raise EnvelopeFormatError(
|
|
41
|
+
f"attestation file {att_path} is not valid JSON: {exc}") from exc
|
|
42
|
+
if not isinstance(envelope, dict):
|
|
43
|
+
raise EnvelopeFormatError(
|
|
44
|
+
f"attestation file {att_path} must contain a JSON object, "
|
|
45
|
+
f"got {type(envelope).__name__}")
|
|
46
|
+
return envelope
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def export_envelope(root: Path | str) -> dict:
|
|
50
|
+
"""Raw DSSE envelope export: the attested envelope exactly as stored, validated."""
|
|
51
|
+
envelope = load_envelope(root)
|
|
52
|
+
parse_envelope(envelope) # refuse to hand on a structurally malformed envelope
|
|
53
|
+
return envelope
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _predicate_problems(predicate: dict) -> list[str]:
|
|
57
|
+
"""Every way ``predicate`` violates the TraceManifest shape, at once."""
|
|
58
|
+
problems = [f"'predicate.{key}' missing" for key in ("coverage", "model_dependent")
|
|
59
|
+
if key not in predicate]
|
|
60
|
+
if "coverage" in predicate and not isinstance(predicate["coverage"], dict):
|
|
61
|
+
problems.append("'predicate.coverage' must be an object, got "
|
|
62
|
+
f"{type(predicate['coverage']).__name__}")
|
|
63
|
+
if ("model_dependent" in predicate
|
|
64
|
+
and not isinstance(predicate["model_dependent"], bool)):
|
|
65
|
+
problems.append("'predicate.model_dependent' must be a bool, got "
|
|
66
|
+
f"{type(predicate['model_dependent']).__name__}")
|
|
67
|
+
return problems
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def export_matrix(root: Path | str) -> dict:
|
|
71
|
+
"""Build a trace matrix from the attested envelope; refuse without one."""
|
|
72
|
+
payload = parse_payload(load_envelope(root))
|
|
73
|
+
predicate = payload["predicate"]
|
|
74
|
+
problems = _predicate_problems(predicate)
|
|
75
|
+
if problems:
|
|
76
|
+
raise EnvelopeFormatError("attested predicate is incomplete: " + "; ".join(problems))
|
|
77
|
+
clauses = []
|
|
78
|
+
for entry in payload["subject"]:
|
|
79
|
+
try:
|
|
80
|
+
clauses.append(entry["name"])
|
|
81
|
+
except (TypeError, KeyError) as exc:
|
|
82
|
+
raise EnvelopeFormatError(f"malformed subject entry {entry!r}: {exc}") from exc
|
|
83
|
+
return {
|
|
84
|
+
"predicateType": payload["predicateType"],
|
|
85
|
+
"clauses": clauses,
|
|
86
|
+
"coverage": predicate["coverage"],
|
|
87
|
+
"model_dependent": predicate["model_dependent"],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def export_summary(root: Path | str) -> str:
|
|
92
|
+
"""Human-readable attestation summary derived from the attested envelope."""
|
|
93
|
+
envelope = load_envelope(root)
|
|
94
|
+
payload = parse_payload(envelope)
|
|
95
|
+
predicate = payload["predicate"]
|
|
96
|
+
problems = _predicate_problems(predicate)
|
|
97
|
+
if problems:
|
|
98
|
+
raise EnvelopeFormatError("attested predicate is incomplete: " + "; ".join(problems))
|
|
99
|
+
coverage = predicate["coverage"]
|
|
100
|
+
covered = (str(coverage["clauses_covered"])
|
|
101
|
+
if coverage.get("clauses_covered") is not None
|
|
102
|
+
else "unknown")
|
|
103
|
+
models = predicate.get("models") or {}
|
|
104
|
+
deferred = predicate.get("deferred") or []
|
|
105
|
+
keyids = _signature_keyids(envelope)
|
|
106
|
+
lines = [
|
|
107
|
+
"TraceAgent attestation summary",
|
|
108
|
+
"==============================",
|
|
109
|
+
f"statement type: {payload['_type']}",
|
|
110
|
+
f"predicate type: {payload['predicateType']}",
|
|
111
|
+
f"payload type: {envelope.get('payloadType', '?')}",
|
|
112
|
+
f"signatures: {len(keyids)}"
|
|
113
|
+
+ (f" (keyid {keyids[0][:16]}…)" if keyids else ""),
|
|
114
|
+
f"coverage: clauses covered {covered}",
|
|
115
|
+
f"models: producer={models.get('producer') or 'unspecified'} "
|
|
116
|
+
f"gate={models.get('gate') or 'unspecified'} "
|
|
117
|
+
f"(model_dependent: {predicate.get('model_dependent', False)})",
|
|
118
|
+
f"deferred: {len(deferred)} milestone target(s)",
|
|
119
|
+
f"subjects: {len(payload['subject'])} clause digest(s)",
|
|
120
|
+
]
|
|
121
|
+
for entry in payload["subject"]:
|
|
122
|
+
try:
|
|
123
|
+
lines.append(f" - {entry['name']}")
|
|
124
|
+
lines.append(f" sha256:{entry['digest']['sha256']}")
|
|
125
|
+
except (TypeError, KeyError):
|
|
126
|
+
lines.append(f" - (malformed subject entry: {entry!r})")
|
|
127
|
+
return "\n".join(lines)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _signature_keyids(envelope: dict) -> list[str]:
|
|
131
|
+
signatures = envelope.get("signatures", [])
|
|
132
|
+
if isinstance(signatures, dict): # legacy {keyid: sig} form
|
|
133
|
+
return [str(keyid) for keyid in signatures]
|
|
134
|
+
return [str(sig.get("keyid", "?")) for sig in signatures if isinstance(sig, dict)]
|
|
135
|
+
|
traceagent/attest/jcs.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""RFC 8785 JSON Canonicalization Scheme (JCS) — the serialization we sign over.
|
|
2
|
+
|
|
3
|
+
securesystemslib's ``encode_canonical`` implements the older OLPC "Canonical
|
|
4
|
+
JSON" draft, which diverges from RFC 8785: it cannot serialize floats at all,
|
|
5
|
+
and orders object keys by Unicode code point instead of UTF-16 code units.
|
|
6
|
+
DSSE payloads here are canonicalized with this module instead, so any
|
|
7
|
+
independent JCS implementation re-derives byte-identical bytes for a signature.
|
|
8
|
+
|
|
9
|
+
Implements RFC 8785 §3.2: ECMAScript ``JSON.stringify`` string escaping and
|
|
10
|
+
``Number::toString`` number serialization, object keys sorted by UTF-16 code
|
|
11
|
+
units, output encoded as UTF-8.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
# Largest integer exactly representable as an IEEE 754 double (RFC 8785 §3.1:
|
|
20
|
+
# values outside double precision are not interoperable JCS input).
|
|
21
|
+
_MAX_SAFE_INTEGER = (1 << 53) - 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def canonical_text(value: Any) -> str:
|
|
25
|
+
"""Serialize ``value`` to canonical JSON text per RFC 8785."""
|
|
26
|
+
fragments: list[str] = []
|
|
27
|
+
_emit(value, fragments)
|
|
28
|
+
return "".join(fragments)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def canonicalize(value: Any) -> bytes:
|
|
32
|
+
"""Serialize ``value`` to canonical JSON bytes per RFC 8785 (UTF-8)."""
|
|
33
|
+
return canonical_text(value).encode("utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _emit(value: Any, out: list[str]) -> None:
|
|
37
|
+
if value is None:
|
|
38
|
+
out.append("null")
|
|
39
|
+
elif value is True:
|
|
40
|
+
out.append("true")
|
|
41
|
+
elif value is False:
|
|
42
|
+
out.append("false")
|
|
43
|
+
elif isinstance(value, int):
|
|
44
|
+
# bools were handled above; Python ints are unbounded but JCS input
|
|
45
|
+
# must stay within IEEE 754 double precision.
|
|
46
|
+
if abs(value) > _MAX_SAFE_INTEGER:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"JCS: integer {value} exceeds the IEEE 754 double-safe range")
|
|
49
|
+
out.append(str(value))
|
|
50
|
+
elif isinstance(value, float):
|
|
51
|
+
out.append(_number_token(value))
|
|
52
|
+
elif isinstance(value, str):
|
|
53
|
+
out.append(_string_token(value))
|
|
54
|
+
elif isinstance(value, (list, tuple)):
|
|
55
|
+
out.append("[")
|
|
56
|
+
for i, item in enumerate(value):
|
|
57
|
+
if i:
|
|
58
|
+
out.append(",")
|
|
59
|
+
_emit(item, out)
|
|
60
|
+
out.append("]")
|
|
61
|
+
elif isinstance(value, dict):
|
|
62
|
+
out.append("{")
|
|
63
|
+
for i, key in enumerate(_sorted_keys(value)):
|
|
64
|
+
if i:
|
|
65
|
+
out.append(",")
|
|
66
|
+
out.append(_string_token(key))
|
|
67
|
+
out.append(":")
|
|
68
|
+
_emit(value[key], out)
|
|
69
|
+
out.append("}")
|
|
70
|
+
else:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"JCS: cannot canonicalize {type(value).__name__} value: {value!r}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _sorted_keys(obj: dict) -> list[str]:
|
|
76
|
+
keys = list(obj)
|
|
77
|
+
for key in keys:
|
|
78
|
+
if not isinstance(key, str):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"JCS: object keys must be strings, got {type(key).__name__}")
|
|
81
|
+
# UTF-16 code-unit order (RFC 8785 §3.2.3): big-endian UTF-16 bytes compare
|
|
82
|
+
# exactly like the uint16 sequences the spec sorts by. This differs from
|
|
83
|
+
# code-point order whenever non-BMP characters meet U+E000..U+FFFF.
|
|
84
|
+
try:
|
|
85
|
+
keys.sort(key=lambda k: k.encode("utf-16-be"))
|
|
86
|
+
except UnicodeEncodeError as exc:
|
|
87
|
+
raise ValueError(f"JCS: invalid object key: {exc}") from exc
|
|
88
|
+
return keys
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _string_token(text: str) -> str:
|
|
92
|
+
# RFC 8785 input is parsed JSON; unpaired surrogates cannot round-trip
|
|
93
|
+
# through UTF-8 and would silently corrupt signatures.
|
|
94
|
+
try:
|
|
95
|
+
text.encode("utf-8")
|
|
96
|
+
except UnicodeEncodeError as exc:
|
|
97
|
+
raise ValueError(f"JCS: string is not valid Unicode: {exc}") from exc
|
|
98
|
+
# json.dumps(ensure_ascii=False) escapes exactly like JSON.stringify:
|
|
99
|
+
# \b \t \n \f \r, \" and \\, other C0 controls as lowercase \u00xx,
|
|
100
|
+
# everything non-ASCII literal.
|
|
101
|
+
return json.dumps(text, ensure_ascii=False)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _number_token(value: float) -> str:
|
|
105
|
+
if math.isnan(value) or math.isinf(value):
|
|
106
|
+
raise ValueError(f"JCS: cannot canonicalize non-JSON number {value!r}")
|
|
107
|
+
if value == 0: # 0.0 and -0.0 both serialize as "0" (RFC 8785 §3.1.1)
|
|
108
|
+
return "0"
|
|
109
|
+
sign = "-" if value < 0 else ""
|
|
110
|
+
# repr() yields the same shortest round-trip digits as ECMAScript's
|
|
111
|
+
# Number::toString; re-render those digits in ECMA notation, which
|
|
112
|
+
# switches to exponent form only outside 1e-6 .. 1e21.
|
|
113
|
+
mantissa, _, exp_text = repr(abs(value)).partition("e")
|
|
114
|
+
exponent = int(exp_text) if exp_text else 0
|
|
115
|
+
int_part, _, frac_part = mantissa.partition(".")
|
|
116
|
+
digits = int_part + frac_part
|
|
117
|
+
point = len(int_part) + exponent # digits before the decimal point
|
|
118
|
+
stripped = digits.lstrip("0")
|
|
119
|
+
point -= len(digits) - len(stripped)
|
|
120
|
+
s = stripped.rstrip("0") or "0"
|
|
121
|
+
k, n = len(s), point
|
|
122
|
+
if k <= n <= 21:
|
|
123
|
+
return sign + s + "0" * (n - k)
|
|
124
|
+
if 0 < n <= 21:
|
|
125
|
+
return sign + s[:n] + "." + s[n:]
|
|
126
|
+
if -6 < n <= 0:
|
|
127
|
+
return sign + "0." + "0" * -n + s
|
|
128
|
+
e = n - 1
|
|
129
|
+
return (sign + s[:1] + ("." + s[1:] if k > 1 else "")
|
|
130
|
+
+ "e" + ("+" if e >= 0 else "-") + str(abs(e)))
|