codeimprint 0.5.0a11__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.
@@ -0,0 +1,679 @@
1
+ """Verify a seal receipt: digest, attestation, developer signatures, log inclusion, time.
2
+
3
+ A receipt is what `POST /api/v1/seal` returns next to the Imprint (see
4
+ docs/how-sealing-works.md). Every check here is offline and uses only keys the caller already
5
+ trusts; ``verify_online`` adds the checks that need the server's current log.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from dataclasses import asdict, dataclass
12
+ from datetime import datetime, timedelta, timezone
13
+
14
+ from . import imprint_file, records, timestamp, transparency, trust
15
+ from .imprint_file import b64d, canonical
16
+
17
+ # The development log's origin. Servers can run under another origin (CODEIMPRINT_LOG_ORIGIN),
18
+ # so a checkpoint must carry its receipt's origin, and the live log must keep it.
19
+ ORIGIN = "codeimprint.mudrika.io/log/v1"
20
+ CLOCK_SKEW = timedelta(minutes=5) # allowed between the server's and a TSA's clock
21
+ CONFIRM_WITHIN = timedelta(hours=1) # a TSA time further after the seal is reported
22
+ AGREE_WITHIN = timedelta(hours=1) # valid tokens of one subject further apart: FAIL
23
+
24
+ PASS, FAIL, WARN = "PASS", "FAIL", "WARN"
25
+
26
+
27
+ @dataclass
28
+ class Check:
29
+ name: str
30
+ status: str
31
+ detail: str
32
+
33
+ def as_dict(self) -> dict:
34
+ return asdict(self)
35
+
36
+
37
+ def _time(text: str) -> datetime:
38
+ return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
39
+
40
+
41
+ def checkpoint_digest(checkpoint: dict) -> bytes:
42
+ """What a TSA timestamps for a checkpoint: SHA-256 of its canonical statement."""
43
+ return hashlib.sha256(canonical(checkpoint["statement"])).digest()
44
+
45
+
46
+ RELEASE = "codeimprint.release.v1"
47
+
48
+
49
+ def _record(receipt: dict) -> Check:
50
+ """Receipt format 4: an ownership record. Its digest, typed ID, attestation fields and
51
+ owner signatures must all follow from the record document."""
52
+ from cryptography.exceptions import InvalidSignature
53
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
54
+
55
+ rec = receipt.get("record") or {}
56
+ doc, rtype_name = rec.get("document"), rec.get("type")
57
+ rtype = records.TYPES.get(rtype_name)
58
+ if rtype is None or not isinstance(doc, dict) or doc.get("type") != rtype_name:
59
+ return Check("record", FAIL, f"unknown or malformed record type {rtype_name!r}")
60
+ digest = hashlib.sha256(canonical(doc)).hexdigest()
61
+ if digest != receipt.get("digest"):
62
+ return Check("record", FAIL, "the record document does not give the receipt's digest")
63
+ if imprint_file.record_id(rtype.prefix, digest) != receipt.get("imprint_id"):
64
+ return Check("record", FAIL, "the record's ID is not the one its type and digest give")
65
+ st = (receipt.get("attestation") or {}).get("statement") or {}
66
+ if (st.get("kind"), st.get("project"), st.get("parents")) != (
67
+ rtype.kind, doc.get("project"), records.parents(rtype, doc)):
68
+ return Check("record", FAIL, "the attestation names another kind, project or parents")
69
+ message = canonical(records.sign_statement(rtype_name, digest))
70
+ sigs = receipt.get("signatures") or []
71
+ for sig in sigs:
72
+ try:
73
+ raw = b64d(sig["public_key"])
74
+ Ed25519PublicKey.from_public_bytes(raw).verify(b64d(sig["signature"]), message)
75
+ except (InvalidSignature, KeyError, TypeError, ValueError):
76
+ return Check("record", FAIL, f"the {sig.get('role')} signature does not verify")
77
+ if trust.fingerprint(raw) != sig.get("fingerprint"):
78
+ return Check("record", FAIL, "a signature's fingerprint does not match its key")
79
+ if [{"role": s["role"], "fingerprint": s["fingerprint"]} for s in sigs] != st.get("signers"):
80
+ return Check("record", FAIL, "the signatures are not the ones the attestation names")
81
+ missing = [r for r in rtype.roles if r not in {s["role"] for s in sigs}]
82
+ if missing:
83
+ return Check("record", FAIL, f"missing signature(s) for: {', '.join(missing)}")
84
+ who = ", ".join(f"{s['role']} {s['fingerprint']}" for s in sigs) or "no owner signatures"
85
+ return Check("record", PASS, f"{rtype.kind} record; {who}")
86
+
87
+
88
+ def _release(receipt: dict) -> Check:
89
+ """A release record: its digest is SHA-256 of the release document, which names the
90
+ labelled seal; the chain must say the same."""
91
+ rel, digest, iid = receipt["release"], receipt.get("digest", ""), receipt.get("imprint_id")
92
+ if hashlib.sha256(canonical(rel)).hexdigest() != digest:
93
+ return Check("release", FAIL, "the release document does not give the receipt's digest")
94
+ if imprint_file.release_id(digest) != iid:
95
+ return Check("release", FAIL, "the receipt's digest does not give its release ID")
96
+ chain = receipt.get("chain") or {}
97
+ if (rel.get("type") != RELEASE or chain.get("kind") != "release"
98
+ or chain.get("parents") != [rel.get("seal")]
99
+ or chain.get("project") != rel.get("project")):
100
+ return Check("release", FAIL, "the release document and its chain disagree")
101
+ tag = (f"tag {rel['tag']}" if "tag" in rel else
102
+ "tag kept private (a salted commitment is public)" if "tag_commitment" in rel
103
+ else "no tag")
104
+ return Check("release", PASS, f"labels seal {rel['seal']}; {tag}")
105
+
106
+
107
+ def _identity(receipt: dict, now: datetime) -> Check | None:
108
+ """Type checks for identity and revocation records (ADR 0002 steps 1.5-1.7)."""
109
+ from cryptography.exceptions import InvalidSignature
110
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
111
+
112
+ rec = receipt.get("record") or {}
113
+ doc = rec.get("document") or {}
114
+ if rec.get("type") == "codeimprint.identity.v1":
115
+ req = doc.get("request") or {}
116
+ st = req.get("statement") or {}
117
+ try:
118
+ raw = b64d(doc["public_key"])
119
+ Ed25519PublicKey.from_public_bytes(raw).verify(b64d(req["signature"]),
120
+ canonical(st))
121
+ except (InvalidSignature, KeyError, TypeError, ValueError):
122
+ return Check("identity", FAIL, "the key did not sign this identity request")
123
+ if (st.get("type") != "codeimprint.identity-request.v1"
124
+ or st.get("public_key") != doc.get("public_key")
125
+ or trust.fingerprint(raw) != doc.get("key")
126
+ or (st.get("level"), st.get("provider"), st.get("subject")) != (
127
+ doc.get("level"), doc.get("provider"), doc.get("subject"))):
128
+ return Check("identity", FAIL, "the identity record does not match the request "
129
+ "the key signed")
130
+ try:
131
+ until = _time(doc["valid_until"])
132
+ except (KeyError, ValueError):
133
+ return Check("identity", FAIL, "the identity record has no validity window")
134
+ if until <= now:
135
+ return Check("identity", FAIL, f"expired at {doc['valid_until']}")
136
+ subject = f" as {doc['subject']}" if doc.get("subject") else ""
137
+ return Check("identity", PASS, f"key {doc['key']} verified at level {doc['level']} "
138
+ f"({doc['provider']}{subject}) until {doc['valid_until']}")
139
+ if rec.get("type") == "codeimprint.org-member.v1":
140
+ roles = {s.get("role"): s for s in receipt.get("signatures") or []}
141
+ member = roles.get("member")
142
+ if not member or member.get("fingerprint") != doc.get("member_key"):
143
+ return Check("membership", FAIL, "the member key did not sign its membership")
144
+ if doc.get("basis") == "admin-approval" and "admin" not in roles:
145
+ return Check("membership", FAIL, "an admin-approved membership without the admin's "
146
+ "signature")
147
+ if doc.get("basis") != "admin-approval" and "admin" in roles:
148
+ return Check("membership", FAIL, "a domain-proven membership with an admin signature")
149
+ return Check("membership", PASS, f"key {doc.get('member_key')} in organisation "
150
+ f"{doc.get('org')} ({doc.get('basis')}) from "
151
+ f"{doc.get('valid_from')}")
152
+ if rec.get("type") == "codeimprint.disclosure.v1":
153
+ return Check("disclosure", PASS, f"organisation {doc.get('org')} discloses "
154
+ f"{doc.get('value')} (identity {doc.get('record')}); "
155
+ "check it against that record's commitment online")
156
+ if rec.get("type") == "codeimprint.revocation.v1":
157
+ holders = [s for s in receipt.get("signatures") or [] if s.get("role") == "holder"]
158
+ if holders and holders[0].get("fingerprint") != doc.get("key"):
159
+ return Check("revocation", FAIL, "signed by a key other than the revoked one")
160
+ since = f", compromised since {doc['compromised_since']}" \
161
+ if doc.get("compromised_since") else ""
162
+ what = doc.get("revokes") or f"key {doc.get('key')}"
163
+ return Check("revocation", PASS, f"{what}: {doc.get('reason')} from "
164
+ f"{doc.get('effective_at')}{since}")
165
+ return None
166
+
167
+
168
+ def key_checks(receipt: dict, client) -> list[Check]:
169
+ """Online: the identity status of a record, and whether a seal's signer keys were reported
170
+ compromised before the seal (a seal signed from then on is flagged)."""
171
+ if not hasattr(client, "key_identity"):
172
+ return []
173
+ from .api import ApiError
174
+
175
+ def status(fp):
176
+ try:
177
+ return client.key_identity(fp)
178
+ except ApiError:
179
+ return None # a server without identity records
180
+
181
+ rec = receipt.get("record") or {}
182
+ if rec.get("type") == "codeimprint.identity.v1":
183
+ doc = rec["document"]
184
+ s = status(doc["key"])
185
+ if s is None:
186
+ return []
187
+ mine = next((i for i in s.get("identities", []) if i["id"] == receipt["imprint_id"]),
188
+ None)
189
+ if mine is None:
190
+ return [Check("identity status", FAIL, "the server does not list this identity")]
191
+ if mine["status"] != "valid":
192
+ why = {"revoked": f"revoked by {mine.get('revoked_by')}",
193
+ "expired": f"expired at {mine['valid_until']}",
194
+ "suspect": f"bound after the key was reported compromised "
195
+ f"({s.get('compromised_since')})"}.get(mine["status"],
196
+ mine["status"])
197
+ return [Check("identity status", FAIL, why)]
198
+ return [Check("identity status", PASS, "valid, not revoked")]
199
+ if rec:
200
+ return []
201
+ attested = ((receipt.get("attestation") or {}).get("statement") or {}).get("attested_at")
202
+ fps = {s.get("fingerprint") for s in receipt.get("developer_signatures") or []}
203
+ signer = chain_signer(receipt)
204
+ if signer:
205
+ fps.add(signer)
206
+ out = []
207
+ for fp in sorted(f for f in fps if f):
208
+ s = status(fp)
209
+ if s is None:
210
+ continue
211
+ since = s.get("compromised_since")
212
+ levels = sorted({f"{i['level']} ({i['subject'] or i['provider']})"
213
+ for i in s.get("identities", []) if i["status"] == "valid"})
214
+ who = f"; verified: {', '.join(levels)}" if levels else ""
215
+ if since and attested and attested >= since:
216
+ out.append(Check("key status", FAIL, f"signed by {fp} after the key was reported "
217
+ f"compromised (since {since})"))
218
+ elif since:
219
+ out.append(Check("key status", PASS, f"{fp} was reported compromised since {since}, "
220
+ f"after this seal{who}"))
221
+ else:
222
+ out.append(Check("key status", PASS, f"{fp}: no compromise reported{who}"))
223
+ return out
224
+
225
+
226
+ def _digest(receipt: dict, doc: dict | None) -> Check:
227
+ if "release" in receipt:
228
+ return _release(receipt)
229
+ digest, iid = receipt.get("digest", ""), receipt.get("imprint_id")
230
+ if imprint_file.imprint_id(digest) != iid:
231
+ return Check("digest", FAIL, "the receipt's digest does not give its Imprint ID")
232
+ if doc is None:
233
+ return Check("digest", PASS, f"{digest[:16]}… gives {iid} (no .imprint file checked)")
234
+ status = imprint_file.verify_document(doc)
235
+ if not status["digest_ok"]:
236
+ return Check("digest", FAIL, "the .imprint file was changed after sealing")
237
+ if (doc.get("integrity") or {}).get("sha256") != digest:
238
+ return Check("digest", FAIL, "the .imprint file is not the one this receipt seals")
239
+ if status["signature_ok"] is False:
240
+ return Check("digest", FAIL, "the .imprint file's own signature does not verify")
241
+ return Check("digest", PASS, f"{digest[:16]}… matches the .imprint file ({iid})")
242
+
243
+
244
+ def _attestation(receipt: dict, keys: list[dict]) -> Check:
245
+ att = receipt.get("attestation") or {}
246
+ ok, why = trust.verify_signed(att, keys)
247
+ if not ok:
248
+ return Check("attestation", FAIL, why)
249
+ st = att["statement"]
250
+ if st.get("type") not in ("codeimprint.attestation.v1", "codeimprint.attestation.v2"):
251
+ return Check("attestation", FAIL, "not an attestation statement")
252
+ chain = receipt.get("chain")
253
+ if (st["type"].endswith(".v2") or chain) and "record" not in receipt:
254
+ fields = ("project", "kind", "parents")
255
+ if not chain or any(st.get(k) != chain.get(k) for k in fields):
256
+ return Check("attestation", FAIL, "the attestation and the receipt's chain disagree")
257
+ if st.get("digest") != receipt.get("digest") or st.get("imprint_id") != receipt.get(
258
+ "imprint_id"):
259
+ return Check("attestation", FAIL, "the attestation is for another Imprint")
260
+ params = (receipt.get("header") or {}).get("params")
261
+ if params and st.get("params") != params:
262
+ return Check("attestation", FAIL, "the attestation names another parameter set")
263
+ kind = f", kind {st['kind']}" if "kind" in st else ""
264
+ return Check("attestation", PASS, f"signed by {why} at {st['attested_at']}{kind}")
265
+
266
+
267
+ CHAIN_STATEMENT = "codeimprint.chain-seal.v1"
268
+
269
+
270
+ def chain_signer(receipt: dict) -> str | None:
271
+ """The fingerprint of the project key that signed this seal into its chain, if valid."""
272
+ from cryptography.exceptions import InvalidSignature
273
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
274
+
275
+ chain = receipt.get("chain") or {}
276
+ sig = chain.get("signature")
277
+ if not sig:
278
+ return None
279
+ statement = {"type": CHAIN_STATEMENT, "project": chain.get("project"),
280
+ "kind": chain.get("kind"), "parents": chain.get("parents") or [],
281
+ "digest": receipt.get("digest")}
282
+ try:
283
+ raw = b64d(sig["public_key"])
284
+ Ed25519PublicKey.from_public_bytes(raw).verify(b64d(sig["signature"]),
285
+ canonical(statement))
286
+ except (InvalidSignature, ValueError, KeyError, TypeError):
287
+ return None
288
+ fp = trust.fingerprint(raw)
289
+ return fp if fp == sig.get("fingerprint") else None
290
+
291
+
292
+ def _chain(receipt: dict) -> Check | None:
293
+ chain = receipt.get("chain")
294
+ if not chain:
295
+ return None
296
+ parents = ", ".join(chain.get("parents") or []) or "none"
297
+ if not chain.get("project"):
298
+ return Check("chain", PASS, f"kind {chain.get('kind')}, in no project")
299
+ if not chain.get("signature"):
300
+ return Check("chain", FAIL, "a project seal without its developer signature")
301
+ fp = chain_signer(receipt)
302
+ if fp is None:
303
+ return Check("chain", FAIL, "the developer signature over the chain does not verify")
304
+ return Check("chain", PASS, f"project {chain['project']}, kind {chain['kind']}, parent "
305
+ f"{parents}; signed by project key {fp}")
306
+
307
+
308
+ def _manifest(receipt: dict, doc: dict | None) -> Check | None:
309
+ """A Git seal's .imprint holds the manifest root the server attested."""
310
+ root = ((receipt.get("attestation") or {}).get("statement") or {}).get("manifest_root")
311
+ if root is None and not (doc or {}).get("manifest"):
312
+ return None
313
+ if doc is None:
314
+ return Check("manifest", PASS, f"root {root[:16]}… (no .imprint file checked)")
315
+ if (doc.get("manifest") or {}).get("root") != root:
316
+ return Check("manifest", FAIL, "the .imprint file's manifest root is not the sealed one")
317
+ return Check("manifest", PASS, f"root {root[:16]}… over {doc['manifest'].get('units')} "
318
+ "units matches the attestation")
319
+
320
+
321
+ def _release_tag(receipt: dict, doc: dict | None) -> Check | None:
322
+ """A release seal names its tag as a release record does (a salted commitment, or the tag
323
+ when published): the Imprint's public header and the signed attestation must agree."""
324
+ signed = ((receipt.get("attestation") or {}).get("statement") or {}).get("release")
325
+ shown = ((doc if doc is not None else receipt.get("header")) or {}).get("release")
326
+ if signed is None and shown is None:
327
+ return None
328
+ if signed != shown:
329
+ return Check("release", FAIL, "the release tag in the Imprint is not the attested one")
330
+ tag = (f"tag {signed['tag']}" if "tag" in signed else
331
+ "tag kept private (a salted commitment is public)")
332
+ return Check("release", PASS, f"release seal; {tag}")
333
+
334
+
335
+ def _developer(receipt: dict, expected: str | None) -> Check:
336
+ from cryptography.exceptions import InvalidSignature
337
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
338
+
339
+ good, bad = [], []
340
+ for sig in receipt.get("developer_signatures") or []:
341
+ try:
342
+ raw = b64d(sig["public_key"])
343
+ Ed25519PublicKey.from_public_bytes(raw).verify(
344
+ b64d(sig["signature"]), trust.developer_message(receipt["digest"]))
345
+ if trust.fingerprint(raw) != sig.get("fingerprint"):
346
+ raise ValueError("fingerprint does not match the key")
347
+ good.append(sig["fingerprint"])
348
+ except (InvalidSignature, ValueError, KeyError, TypeError):
349
+ bad.append(str(sig.get("fingerprint")))
350
+ signer = chain_signer(receipt)
351
+ if signer and signer not in good:
352
+ good.append(signer) # the chain signature is the developer's
353
+ if bad:
354
+ return Check("developer signature", FAIL, f"does not verify: {', '.join(bad)}")
355
+ if expected and expected not in good:
356
+ return Check("developer signature", FAIL,
357
+ f"no signature by the project's developer key {expected}")
358
+ if not good:
359
+ return Check("developer signature", WARN, "none (sealed without a developer key)")
360
+ mark = " (the project's key)" if expected else ""
361
+ return Check("developer signature", PASS, f"by {', '.join(good)}{mark}")
362
+
363
+
364
+ def _log(receipt: dict, keys: list[dict]) -> Check:
365
+ log = receipt.get("log") or {}
366
+ cp, leaf, att = log.get("checkpoint") or {}, log.get("leaf") or {}, receipt.get(
367
+ "attestation") or {}
368
+ ok, why = trust.verify_signed(cp, keys)
369
+ if not ok:
370
+ return Check("log inclusion", FAIL, f"checkpoint: {why}")
371
+ st = cp["statement"]
372
+ if st.get("type") != "codeimprint.checkpoint.v1" or not st.get("origin"):
373
+ return Check("log inclusion", FAIL, "not a CodeImprint log checkpoint")
374
+ if st["origin"] != receipt.get("origin", st["origin"]):
375
+ return Check("log inclusion", FAIL, f"the checkpoint is from log {st['origin']}, not "
376
+ f"from this receipt's log {receipt.get('origin')}")
377
+ expected = {"type": "codeimprint.log-entry.v1", "index": log.get("index"),
378
+ "imprint_id": receipt.get("imprint_id"), "digest": receipt.get("digest"),
379
+ "attested_at": (att.get("statement") or {}).get("attested_at"),
380
+ "attestation_key_id": att.get("key_id"),
381
+ "attestation_signature": att.get("signature")}
382
+ ast = att.get("statement") or {}
383
+ if ast.get("type") == "codeimprint.attestation.v2":
384
+ expected.update(type="codeimprint.log-entry.v2", project=ast.get("project"),
385
+ kind=ast.get("kind"), parents=ast.get("parents"))
386
+ if leaf != expected:
387
+ return Check("log inclusion", FAIL, "the log entry does not match this seal")
388
+ size = st.get("tree_size")
389
+ if log.get("tree_size") != size:
390
+ return Check("log inclusion", FAIL, "the proof is for another tree size")
391
+ try:
392
+ proof = [bytes.fromhex(h) for h in log.get("inclusion_proof", [])]
393
+ root = bytes.fromhex(st["root_hash"])
394
+ except (ValueError, TypeError, KeyError):
395
+ return Check("log inclusion", FAIL, "malformed proof")
396
+ if not transparency.verify_inclusion(log["index"], size,
397
+ transparency.leaf_hash(canonical(leaf)), proof, root):
398
+ return Check("log inclusion", FAIL, "the inclusion proof does not reach the checkpoint")
399
+ return Check("log inclusion", PASS,
400
+ f"entry #{log['index']} of {size}, checkpoint signed by {why}")
401
+
402
+
403
+ # -- timestamps ----------------------------------------------------------------------------
404
+ @dataclass
405
+ class _Group:
406
+ """The timestamps of one thing the TSAs were asked to stamp: the seal or the checkpoint."""
407
+
408
+ label: str # seal | checkpoint
409
+ subject: str # as the server's signed record names it
410
+ digest: bytes # the SHA-256 the TSAs timestamped
411
+ committed: list | None # TSAs the signed statement committed to (None: v1)
412
+ record: dict | None # the server's signed codeimprint.timestamps.v1 record
413
+ tokens: list[dict] # one entry per TSA: ok (with token) | unavailable | invalid
414
+
415
+
416
+ def _view(view) -> tuple[dict | None, list[dict]]:
417
+ """(record, token entries) from a receipt v2 view, or from a v1 single-token field."""
418
+ if isinstance(view, dict) and "tokens" in view:
419
+ return view.get("record"), [t for t in view.get("tokens") or [] if isinstance(t, dict)]
420
+ return None, [view] if isinstance(view, dict) else []
421
+
422
+
423
+ def _groups(receipt: dict) -> list[_Group]:
424
+ cp = (receipt.get("log") or {}).get("checkpoint") or {}
425
+ cp_st = cp.get("statement") or {}
426
+ att_st = (receipt.get("attestation") or {}).get("statement") or {}
427
+ try:
428
+ seal_digest = bytes.fromhex(receipt.get("digest") or "")
429
+ except ValueError:
430
+ seal_digest = b""
431
+ groups = []
432
+ for label, view, committed, subject, digest in (
433
+ ("seal", (receipt.get("timestamps") or {}).get("seal"), att_st.get("tsas"),
434
+ f"seal:{receipt.get('imprint_id')}", seal_digest),
435
+ ("checkpoint", cp.get("timestamps", cp.get("timestamp")), cp_st.get("tsas"),
436
+ f"checkpoint:{cp_st.get('tree_size')}", checkpoint_digest(cp) if cp_st else b"")):
437
+ record, tokens = _view(view)
438
+ groups.append(_Group(label, subject, digest, committed, record, tokens))
439
+ return groups
440
+
441
+
442
+ def _record_problem(g: _Group, keys: list[dict]) -> str | None:
443
+ """With a commitment, the signed record must name every committed TSA and match each
444
+ token entry exactly: a token cannot be dropped, swapped or relabelled unnoticed."""
445
+ if not g.committed:
446
+ return None
447
+ if g.record is None:
448
+ return "the server's signed record of these timestamps is missing (removed?)"
449
+ ok, why = trust.verify_signed(g.record, keys)
450
+ if not ok:
451
+ return f"timestamps record: {why}"
452
+ st = g.record["statement"]
453
+ if (st.get("type") != "codeimprint.timestamps.v1" or st.get("subject") != g.subject
454
+ or st.get("digest") != g.digest.hex()):
455
+ return "the timestamps record is for something else"
456
+ results = {r.get("url"): r for r in st.get("results") or []}
457
+ if set(results) != set(g.committed):
458
+ return "the timestamps record does not cover the TSAs the seal committed to"
459
+ entries = {t.get("url"): t for t in g.tokens}
460
+ if set(entries) != set(results):
461
+ return "a timestamp was removed from (or added to) the receipt"
462
+ for url, result in results.items():
463
+ entry = entries[url]
464
+ if entry.get("status") != result.get("status"):
465
+ return f"{url}: recorded as {result.get('status')}, shown as {entry.get('status')}"
466
+ if result.get("status") == "ok":
467
+ try:
468
+ sha = hashlib.sha256(b64d(entry["token"])).hexdigest()
469
+ except (KeyError, ValueError, TypeError):
470
+ sha = None
471
+ if sha != result.get("token_sha256"):
472
+ return f"{url}: the token is not the one the server recorded"
473
+ return None
474
+
475
+
476
+ def _cn(subject: str) -> str:
477
+ part = next((p for p in subject.split(",") if p.startswith("CN=")), subject)
478
+ return part.removeprefix("CN=")
479
+
480
+
481
+ def _timestamps(receipt: dict, keys: list[dict],
482
+ anchors: list) -> tuple[list[Check], list[timestamp.TokenInfo]]:
483
+ """One line per TSA and subject; every token present must verify (else FAIL)."""
484
+ checks, infos = [], []
485
+ groups = _groups(receipt)
486
+ for g in groups:
487
+ name = f"timestamp {g.label}"
488
+ problem = _record_problem(g, keys)
489
+ if problem:
490
+ checks.append(Check(name, FAIL, problem))
491
+ good = []
492
+ for entry in g.tokens:
493
+ where, status = entry.get("url") or "TSA", entry.get("status")
494
+ if status == "ok":
495
+ try:
496
+ info = timestamp.verify_token(b64d(entry["token"]), g.digest, anchors)
497
+ except (timestamp.TimestampError, KeyError, ValueError, TypeError) as exc:
498
+ checks.append(Check(name, FAIL, f"{where}: {exc}"))
499
+ continue
500
+ if entry.get("gen_time") not in (None, info.gen_time):
501
+ checks.append(Check(name, FAIL, f"{where}: the receipt misstates genTime"))
502
+ continue
503
+ good.append(info)
504
+ checks.append(Check(name, PASS, f"{_cn(info.tsa)} at {info.gen_time} "
505
+ f"(root {info.root})"))
506
+ elif status == "unavailable":
507
+ checks.append(Check(name, WARN, f"{where}: unavailable at seal time"))
508
+ elif status == "invalid":
509
+ checks.append(Check(name, WARN, f"{where}: invalid response at seal time "
510
+ "(not kept)"))
511
+ elif status == "pending": # receipt v1
512
+ checks.append(Check(name, WARN, "pending at the TSA; fetch the receipt again"))
513
+ else:
514
+ checks.append(Check(name, FAIL, f"{where}: unknown status {status!r}"))
515
+ if len(good) > 1:
516
+ times = sorted(_time(i.gen_time) for i in good)
517
+ if times[-1] - times[0] > AGREE_WITHIN:
518
+ checks.append(Check(name, FAIL, f"the TSAs disagree on the time by "
519
+ f"{times[-1] - times[0]}"))
520
+ infos += good
521
+ if not any(g.tokens for g in groups) and not checks:
522
+ checks.append(Check("timestamp", WARN, "no RFC 3161 timestamp on this seal"))
523
+ return checks, infos
524
+
525
+
526
+ def _time_order(receipt: dict, infos: list[timestamp.TokenInfo], now: datetime) -> Check:
527
+ try:
528
+ attested = _time(receipt["attestation"]["statement"]["attested_at"])
529
+ issued = _time(receipt["log"]["checkpoint"]["statement"]["issued_at"])
530
+ except (KeyError, TypeError, ValueError):
531
+ return Check("time", FAIL, "missing or malformed times")
532
+ if attested > now + CLOCK_SKEW:
533
+ return Check("time", FAIL, f"attested in the future ({attested:%Y-%m-%d %H:%M} UTC)")
534
+ if issued < attested:
535
+ return Check("time", FAIL, "the checkpoint is older than the seal it includes")
536
+ if not infos:
537
+ return Check("time", WARN, f"sealed {attested:%Y-%m-%d %H:%M:%S} UTC, attested by the "
538
+ "server only (no independent timestamp)")
539
+ for info in infos: # timestamps are requested after sealing
540
+ if _time(info.gen_time) < attested - CLOCK_SKEW:
541
+ return Check("time", FAIL, f"{_cn(info.tsa)} dates the seal {info.gen_time}, "
542
+ "before its attested time: the seal time was forged")
543
+ earliest = min(_time(i.gen_time) for i in infos)
544
+ if earliest - attested > CONFIRM_WITHIN:
545
+ return Check("time", WARN, f"attested {attested:%Y-%m-%d %H:%M} UTC but independently "
546
+ f"confirmed only from {earliest:%Y-%m-%d %H:%M} UTC; rely on the later time")
547
+ count = len({i.tsa for i in infos})
548
+ return Check("time", PASS, f"sealed {attested:%Y-%m-%d %H:%M:%S} UTC, confirmed by "
549
+ f"{count} TSA{'s' if count > 1 else ''} from {earliest:%Y-%m-%d %H:%M:%S} UTC")
550
+
551
+
552
+ def verify(receipt: dict, keys: list[dict], doc: dict | None = None,
553
+ developer: str | None = None, now: datetime | None = None,
554
+ anchors: list | None = None) -> list[Check]:
555
+ """Every offline check. ``developer`` is the fingerprint the project expects to sign;
556
+ ``anchors`` the trusted TSA roots (default: the pinned ones)."""
557
+ now = now or datetime.now(timezone.utc)
558
+ anchors = timestamp.pinned_anchors() if anchors is None else anchors
559
+ if not keys:
560
+ return [_digest(receipt, doc),
561
+ Check("attestation", FAIL, "no trusted server keys: run `imprint verify "
562
+ "--online` once, or pass --keys FILE")]
563
+ if "record" in receipt: # receipt format 4
564
+ checks = [_record(receipt), _attestation(receipt, keys), _log(receipt, keys)]
565
+ typed = _identity(receipt, now)
566
+ if typed:
567
+ checks.insert(1, typed)
568
+ tokens, infos = _timestamps(receipt, keys, anchors)
569
+ return checks + tokens + [_time_order(receipt, infos, now)]
570
+ checks = [_digest(receipt, doc), _attestation(receipt, keys),
571
+ *[c for c in (_chain(receipt), _manifest(receipt, doc),
572
+ _release_tag(receipt, doc)) if c],
573
+ _developer(receipt, developer), _log(receipt, keys)]
574
+ tokens, infos = _timestamps(receipt, keys, anchors)
575
+ checks += tokens
576
+ checks.append(_time_order(receipt, infos, now))
577
+ return checks
578
+
579
+
580
+ # -- online --------------------------------------------------------------------------------
581
+ def _consistent(a: dict, b: dict, proof_hex: list[str]) -> bool:
582
+ try:
583
+ return transparency.verify_consistency(
584
+ a["statement"]["tree_size"], b["statement"]["tree_size"],
585
+ bytes.fromhex(a["statement"]["root_hash"]), bytes.fromhex(b["statement"]["root_hash"]),
586
+ [bytes.fromhex(h) for h in proof_hex])
587
+ except (KeyError, ValueError, TypeError):
588
+ return False
589
+
590
+
591
+ def verify_online(receipt: dict, client, keys: list[dict],
592
+ anchors: list | None = None) -> list[Check]:
593
+ """Check the receipt against the server's current log (``client`` is an api.Client)."""
594
+ anchors = timestamp.pinned_anchors() if anchors is None else anchors
595
+ checks = []
596
+ cp = receipt["log"]["checkpoint"]
597
+ size, index = cp["statement"]["tree_size"], receipt["log"]["index"]
598
+
599
+ latest = client.log_checkpoint()
600
+ ok, why = trust.verify_signed(latest, keys)
601
+ if not ok:
602
+ return [Check("log consistency", FAIL, f"latest checkpoint: {why}")]
603
+ if latest["statement"].get("origin") != cp["statement"].get("origin"):
604
+ return [Check("log consistency", FAIL, f"the server now serves log "
605
+ f"{latest['statement'].get('origin')}, not "
606
+ f"{cp['statement'].get('origin')}")]
607
+ new_size = latest["statement"]["tree_size"]
608
+ if new_size < size:
609
+ return [Check("log consistency", FAIL, f"the log shrank from {size} to {new_size}")]
610
+ proof = client.log_consistency(size, new_size)
611
+ if not _consistent(cp, latest, proof["proof"]):
612
+ return [Check("log consistency", FAIL, "the log was rewritten since this receipt: an "
613
+ "entry was removed, reordered or changed")]
614
+ checks.append(Check("log consistency", PASS,
615
+ f"log now has {new_size} entries and extends this receipt's checkpoint"))
616
+
617
+ if index > 0: # the log just before this entry
618
+ before = client.log_consistency(index, size)
619
+ prior = before["from_checkpoint"]
620
+ ok, why = trust.verify_signed(prior, keys)
621
+ if not ok or not _consistent(prior, cp, before["proof"]):
622
+ checks.append(Check("log order", FAIL, "cannot prove the log before this entry"))
623
+ else:
624
+ attested = receipt["attestation"]["statement"]["attested_at"]
625
+ floor = prior["statement"]["issued_at"]
626
+ _record, entries = _view(prior.get("timestamps", prior.get("timestamp")))
627
+ for entry in entries:
628
+ if entry.get("status") != "ok":
629
+ continue
630
+ try:
631
+ info = timestamp.verify_token(b64d(entry["token"]),
632
+ checkpoint_digest(prior), anchors)
633
+ floor = max(floor, info.gen_time)
634
+ except (timestamp.TimestampError, KeyError, ValueError):
635
+ pass
636
+ if _time(attested) < _time(floor) - CLOCK_SKEW:
637
+ checks.append(Check("log order", FAIL, f"attested {attested}, but the log "
638
+ f"before this entry already existed at {floor}: backdated"))
639
+ else:
640
+ checks.append(Check("log order", PASS,
641
+ f"entry #{index} was added after entry #{index - 1}"))
642
+
643
+ chain = receipt.get("chain") or {}
644
+ signer = chain_signer(receipt)
645
+ if chain.get("project") and signer and hasattr(client, "public_project"):
646
+ attested = receipt["attestation"]["statement"]["attested_at"]
647
+ keys_now = {k["fingerprint"]: k
648
+ for k in client.public_project(chain["project"]).get("keys", [])}
649
+ k = keys_now.get(signer)
650
+ if k and k["valid_from"] <= attested and (not k["valid_until"]
651
+ or attested <= k["valid_until"]):
652
+ checks.append(Check("project key", PASS, f"{signer} was a key of project "
653
+ f"{chain['project']} at {attested}"))
654
+ else:
655
+ checks.append(Check("project key", FAIL, f"{signer} was not a key of project "
656
+ f"{chain['project']} at {attested}"))
657
+ rel = receipt.get("release")
658
+ if rel:
659
+ try:
660
+ sealed = client.public_imprint(rel["seal"])
661
+ except Exception as exc: # noqa: BLE001 — reported as a failed check
662
+ sealed = {"error": str(exc)}
663
+ ok = (sealed.get("digest") == rel.get("seal_digest")
664
+ and ((sealed.get("chain") or {}).get("project") == rel.get("project"))
665
+ and (sealed.get("log") or {}).get("index", index) < index)
666
+ checks.append(Check("released seal", PASS if ok else FAIL,
667
+ f"{rel['seal']} is an earlier seal of the project with digest "
668
+ f"{str(rel.get('seal_digest'))[:16]}…" if ok else
669
+ f"{rel['seal']} is not the sealed tree this release names"))
670
+ checks += key_checks(receipt, client)
671
+ record = (client.public_record(receipt["imprint_id"]) if "record" in receipt
672
+ else client.public_imprint(receipt["imprint_id"]))
673
+ same = (record.get("digest") == receipt["digest"]
674
+ and record.get("attestation") == receipt["attestation"]
675
+ and (record.get("log") or {}).get("index") == index)
676
+ checks.append(Check("registry", PASS if same else FAIL,
677
+ "the public record matches this receipt" if same else
678
+ "the public record differs from this receipt"))
679
+ return checks