aics-cli 0.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.
aics_cli/card.py ADDED
@@ -0,0 +1,933 @@
1
+ """The signed assurance card (docs/spec/AICS-1.md §13, §14.5; DESIGN G10).
2
+
3
+ ``/.well-known/aics-card.json`` is signed with the A2A card construction
4
+ (A2A v1.0.0 §8.4) as profiled by AICS-1 §13.2: remove only ``signatures``,
5
+ JCS-canonicalise the rest, and sign ``ASCII(protected || "." ||
6
+ U64(J(unsigned_card)))`` with Ed25519, where ``protected`` is
7
+ ``U64(J({"alg":"EdDSA","kid":policy_kid,"typ":"JOSE"}))``. No unprotected
8
+ header, ``jku``, embedded key, ``b64=false`` or other algorithm is allowed,
9
+ and no default-value elision happens: every §13.1 member is required.
10
+
11
+ The policy key comes only from the pinned trust bundle. The first card is
12
+ identified by ``hexH(J(card)) == bootstrap_card_hash``; for later cards the
13
+ policy leaf, its inclusion and activation (§14.5 steps 3-4) need the log's
14
+ policy entries, which the server does not ship yet - such a card is reported
15
+ as signature-valid but ``incomplete``. Even for the bootstrap card, later
16
+ policy events (supersession) and the evidence behind ``proven`` assurances
17
+ are not checked; the output says so.
18
+
19
+ Everything in the card is the operator's own statement. Statuses must stay
20
+ inside the AICS-1 §13.3 boundaries (:func:`assurance_problems`), and free
21
+ text is escaped before it is printed (it can never add lines or terminal
22
+ escapes to the verdict).
23
+
24
+ Profiles (§13.1, §19, §20.9): the base card (``spec`` ``AICS-1``) and the
25
+ contributions and owner-service profile (``AICS-1+SUP-1``: the ``sup``
26
+ member and the twelve §20.8 refusal codes) are verified. A card of a swarms
27
+ profile (``AICS-1+SWARMS-1``, ``AICS-1+SWARMS-1+SUP-1``) is reported as an
28
+ unsupported profile (:func:`unsupported_profile`): §19 requires a client
29
+ without the profile to reject it, never to read it as a base card.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import json
36
+ import re
37
+ import time
38
+ import unicodedata
39
+ import urllib.parse
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+ from typing import Any
43
+
44
+ from aics_identity.canonical import canonicalize
45
+ from cryptography.exceptions import InvalidSignature
46
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
47
+
48
+ from . import home as _home
49
+ from .encoding import b64u_d, b64u_e, escape_untrusted
50
+ from .errors import AicsError, FetchError, PolicyError
51
+ from .policy import Policy, PolicyKey, parse_log_vkey, parse_policy_key, parse_witness_vkey
52
+
53
+ WELL_KNOWN = "/.well-known/aics-card.json"
54
+ CARD_MAX_BYTES = 1024 * 1024
55
+ FUTURE_SKEW = 30 # AICS-1 §14.5 step 2: issued_at <= now + 30
56
+ MAX_JSON_DEPTH = 32 # AICS-1 §15: implementations MUST bound JSON depth
57
+ STATUSES = ("proven", "promised", "not-offered")
58
+ #: Unicode categories never allowed in a card string: control, format, line and paragraph separators.
59
+ FORBIDDEN_CATEGORIES = ("Cc", "Cf", "Zl", "Zp")
60
+ REQUIRED_ASSURANCES = tuple(f"G{i}" for i in range(1, 11)) + tuple(f"N{i}" for i in range(1, 6))
61
+ UINT_MAX = 9007199254740991
62
+ _HEX64 = re.compile(r"[0-9a-f]{64}")
63
+ _PATTERN_ID = re.compile(r"[a-z0-9_]{1,32}")
64
+
65
+ CARD_MEMBERS = (
66
+ "spec", "version", "origin", "issued_at", "expires", "policy_parent_index", "log_vkey", "policy_kid",
67
+ "owner_kid", "tile_prefixes", "api_base", "gate", "proxy", "witness_policy", "max_checkpoint_age",
68
+ "mmd_seconds", "policy_notice_seconds", "limits", "retention", "schemes", "tiers", "reason_codes",
69
+ "secret_patterns", "policies", "assurances", "spec_versions", "signatures",
70
+ )
71
+ #: AICS-1 (0.2-draft) §13.1: the card's ``version`` and the C2SP versions it pins (tlog-checkpoint, signed-note,
72
+ #: tlog-cosignature and tlog-witness v1.1.0). A card of any other version is refused, 0.1-draft included.
73
+ CARD_VERSION = "0.2-draft"
74
+ SPEC_VERSIONS = {"tlog-tiles": "1.0.0", "tlog-checkpoint": "1.1.0", "signed-note": "1.1.0",
75
+ "tlog-cosignature": "1.1.0", "tlog-witness": "1.1.0", "a2a-card": "1.0.0"}
76
+ FIXED_LIMITS = {"public_body_bytes": 32768, "ciphertext_bytes": 65536, "envelope_window_seconds": 300,
77
+ "future_skew_seconds": 30, "session_idle_seconds": 900}
78
+ TIERS = ("verified", "third-party-generated", "unverified")
79
+ MANDATORY_SCHEME = "x25519-xsalsa20-poly1305"
80
+ #: AICS-1 §13.1 and §20.9: the card profiles this release verifies, and (§19, SWARMS R47) those it reports as
81
+ #: unsupported instead of reading them as base cards.
82
+ SPEC_BASE = "AICS-1"
83
+ SPEC_SUP = "AICS-1+SUP-1"
84
+ SUPPORTED_PROFILES = (SPEC_BASE, SPEC_SUP)
85
+ UNSUPPORTED_PROFILES = ("AICS-1+SWARMS-1", "AICS-1+SWARMS-1+SUP-1")
86
+ #: AICS-1 §20.8: the refusal codes an activated SUP-1 card adds to the refusal registry (no other card enables them).
87
+ SUP_REFUSALS = ("insufficient_balance", "not_requester", "owner_action_closed", "stale_quote", "task_closed",
88
+ "already_awarded", "already_credited", "payment_not_verified", "intent_expired", "amount_mismatch",
89
+ "bad_reference", "not_offered")
90
+ #: AICS-1 §20.9: the ``sup`` member and its parts.
91
+ SUP_MEMBERS = ("asset_hash", "contributions", "intent_seconds", "claim_seconds", "record_seconds", "storage")
92
+ SUP_CONTRIBUTION_MEMBERS = ("network", "asset", "decimals", "address", "units_per_sup")
93
+ SUP_STORAGE_MEMBERS = ("free_quota_bytes", "unit_bytes", "unit_amount")
94
+ SUP_RECORD_SECONDS_MAX = 31536000 # decision 17's 12 months
95
+ SUP_FREE_QUOTA_MINIMUM = 65536 # one maximal ciphertext body is always free
96
+ _SUP_NETWORK = re.compile(r"[a-z0-9-]{1,32}")
97
+ #: AICS-1 (0.2-draft) §17.2 registries of the base profile. §13.1: a card's reasons equal them, the refusal
98
+ #: registry plus exactly one ``secret_format:<id>`` per pattern the card enumerates (a closed expansion); a
99
+ #: base-only verifier refuses anything else, the §19 swarms codes included.
100
+ REASON_REGISTRIES = {
101
+ "refusal": (
102
+ "malformed", "bad_signature", "expired", "future", "replay", "unknown_key", "revoked", "tls_required",
103
+ "unsupported_scheme", "unsupported_version",
104
+ "sequence", "predecessor", "name_taken", "key_reuse", "key_fork", "stale_key", "stale_grant",
105
+ "stale_capsule", "already_voted", "poll_closed", "poll_constraint",
106
+ "too_large", "digest_mismatch", "rate_limit", "quota", "no_consent", "grant_exhausted", "suspended",
107
+ "frozen", "tier_required",
108
+ "secret_format", "rate", "seq", "fork", "sig", "no_grant", "size", "format", "name", "key_state",
109
+ "poll_rule",
110
+ ),
111
+ "tombstone": ("order", "erasure", "secret_format", "illegal_on_notice"),
112
+ "suspension": ("order", "flood"),
113
+ "freeze": ("maintenance", "incident", "order", "resource_exhaustion", "resume"),
114
+ "revocation": ("retired", "compromised"),
115
+ "outcome": ("acted", "declined", "private_unassessable", "invalid_notice", "duplicate", "published",
116
+ "resolved"),
117
+ }
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # strict JSON
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def _no_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
126
+ out: dict[str, Any] = {}
127
+ for k, v in pairs:
128
+ if k in out:
129
+ raise ValueError(f"duplicate JSON member {k!r}")
130
+ out[k] = v
131
+ return out
132
+
133
+
134
+ def _no_constants(name: str) -> Any:
135
+ raise ValueError(f"{name} is not valid JSON")
136
+
137
+
138
+ _JSON_SPECIAL = re.compile(r'[\[\]{}"\\]')
139
+
140
+
141
+ def check_json_depth(text: str, limit: int = MAX_JSON_DEPTH) -> None:
142
+ """Raise ValueError if arrays/objects in ``text`` nest deeper than
143
+ ``limit`` (scanned without recursion, before any parser recurses)."""
144
+ depth = 0
145
+ in_string = False
146
+ skip = -1
147
+ for m in _JSON_SPECIAL.finditer(text):
148
+ pos = m.start()
149
+ if pos <= skip:
150
+ continue
151
+ c = text[pos]
152
+ if in_string:
153
+ if c == "\\":
154
+ skip = pos + 1 # the escaped character
155
+ elif c == '"':
156
+ in_string = False
157
+ continue
158
+ if c == '"':
159
+ in_string = True
160
+ elif c in "[{":
161
+ depth += 1
162
+ if depth > limit:
163
+ raise ValueError(f"JSON nested deeper than {limit} levels")
164
+ elif c in "]}":
165
+ depth -= 1
166
+
167
+
168
+ def loads_strict(data: bytes | str, *, max_depth: int = MAX_JSON_DEPTH) -> Any:
169
+ """JSON with duplicate members and NaN/Infinity rejected (two parsers
170
+ must never see two different cards in one document) and nesting bounded
171
+ (a deeply nested document is a ValueError, never a RecursionError)."""
172
+ text = data.decode("utf-8") if isinstance(data, bytes) else data
173
+ check_json_depth(text, max_depth)
174
+ try:
175
+ return json.loads(text, object_pairs_hook=_no_duplicates, parse_constant=_no_constants)
176
+ except RecursionError:
177
+ raise ValueError("JSON nested too deeply") from None
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # JWS over JCS
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ def unsigned_payload(card: dict[str, Any]) -> bytes:
186
+ """``J(card without signatures)``: the JWS payload."""
187
+ return canonicalize({k: v for k, v in card.items() if k != "signatures"})
188
+
189
+
190
+ def card_hash(card: dict[str, Any]) -> str:
191
+ """``hexH(J(card))`` over the full signed card: the policy-leaf document
192
+ hash and, for the first card, the bundle's ``bootstrap_card_hash``."""
193
+ return hashlib.sha256(canonicalize(card)).hexdigest()
194
+
195
+
196
+ def sign_card(card: dict[str, Any], key: Ed25519PrivateKey, kid: str, *, alg: str = "EdDSA",
197
+ typ: str = "JOSE", extra_header: dict[str, Any] | None = None) -> dict[str, Any]:
198
+ """Return ``card`` with ``signatures`` set to one AICS-1 §13.2 signature."""
199
+ header = {"alg": alg, "kid": kid, "typ": typ, **(extra_header or {})}
200
+ protected = b64u_e(canonicalize(header))
201
+ signing_input = f"{protected}.{b64u_e(unsigned_payload(card))}".encode("ascii")
202
+ out = {k: v for k, v in card.items() if k != "signatures"}
203
+ out["signatures"] = [{"protected": protected, "signature": b64u_e(key.sign(signing_input))}]
204
+ return out
205
+
206
+
207
+ @dataclass
208
+ class SigCheck:
209
+ index: int
210
+ status: str # valid | invalid | unknown-kid | unsupported | malformed
211
+ kid: str | None = None
212
+ detail: str = ""
213
+
214
+ def report(self) -> dict[str, Any]:
215
+ d: dict[str, Any] = {"index": self.index, "status": self.status, "kid": self.kid}
216
+ if self.detail:
217
+ d["detail"] = self.detail
218
+ return d
219
+
220
+
221
+ def _canonical_u64(value: str) -> bytes:
222
+ raw = b64u_d(value)
223
+ if b64u_e(raw) != value:
224
+ raise ValueError("non-canonical base64url")
225
+ return raw
226
+
227
+
228
+ def check_signatures(card: dict[str, Any], keys: list[PolicyKey]) -> list[SigCheck]:
229
+ sigs = card.get("signatures")
230
+ if not isinstance(sigs, list) or not sigs:
231
+ return [SigCheck(0, "malformed", detail="card has no 'signatures' array")]
232
+ try:
233
+ payload_b64 = b64u_e(unsigned_payload(card))
234
+ except ValueError as exc:
235
+ return [SigCheck(0, "malformed", detail=f"card cannot be canonicalised (RFC 8785): {exc}")]
236
+ out: list[SigCheck] = []
237
+ for i, s in enumerate(sigs):
238
+ if not isinstance(s, dict) or set(s) != {"protected", "signature"} \
239
+ or not all(isinstance(s[k], str) for k in s):
240
+ out.append(SigCheck(i, "unsupported", detail="a signature is exactly {protected, signature}: "
241
+ "no unprotected header (AICS-1 §13.2)"))
242
+ continue
243
+ try:
244
+ raw_header = _canonical_u64(s["protected"])
245
+ header = loads_strict(raw_header)
246
+ except (ValueError, UnicodeDecodeError) as exc:
247
+ out.append(SigCheck(i, "malformed", detail=f"protected header: {exc}"))
248
+ continue
249
+ if not isinstance(header, dict):
250
+ out.append(SigCheck(i, "malformed", detail="protected header is not a JSON object"))
251
+ continue
252
+ kid = header.get("kid")
253
+ chk = SigCheck(i, "unsupported", kid=kid if isinstance(kid, str) else None)
254
+ if set(header) != {"alg", "kid", "typ"} or header.get("alg") != "EdDSA" or header.get("typ") != "JOSE":
255
+ chk.detail = ('the protected header must be exactly {"alg":"EdDSA","kid":<policy_kid>,"typ":"JOSE"} '
256
+ f"(got members {sorted(header)})")
257
+ out.append(chk)
258
+ continue
259
+ if canonicalize(header) != raw_header:
260
+ chk.status, chk.detail = "malformed", "protected header is not JCS-serialized"
261
+ out.append(chk)
262
+ continue
263
+ matching = [k for k in keys if isinstance(kid, str) and kid in k.kids]
264
+ if not matching:
265
+ chk.status, chk.detail = "unknown-kid", "kid is not the pinned policy key"
266
+ out.append(chk)
267
+ continue
268
+ try:
269
+ raw_sig = _canonical_u64(s["signature"])
270
+ except ValueError as exc:
271
+ chk.status, chk.detail = "malformed", f"signature: {exc}"
272
+ out.append(chk)
273
+ continue
274
+ signing_input = f"{s['protected']}.{payload_b64}".encode("ascii")
275
+ chk.status = "invalid"
276
+ for k in matching:
277
+ try:
278
+ Ed25519PublicKey.from_public_bytes(k.public_key).verify(raw_sig, signing_input)
279
+ chk.status = "valid"
280
+ break
281
+ except InvalidSignature:
282
+ continue
283
+ if chk.status == "invalid":
284
+ chk.detail = "signature does not verify over the JCS payload (card altered after signing?)"
285
+ out.append(chk)
286
+ return out
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # §13.1 schema
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ class _Schema:
295
+ def __init__(self) -> None:
296
+ self.problems: list[str] = []
297
+
298
+ def bad(self, path: str, msg: str) -> None:
299
+ self.problems.append(f"{path}: {msg}")
300
+
301
+ def obj(self, v: Any, path: str, members: tuple[str, ...]) -> bool:
302
+ if not isinstance(v, dict):
303
+ self.bad(path, "must be an object")
304
+ return False
305
+ missing = [m for m in members if m not in v]
306
+ extra = sorted(set(v) - set(members))
307
+ if missing:
308
+ self.bad(path, f"missing {', '.join(missing)}")
309
+ if extra:
310
+ self.bad(path, f"unknown members {', '.join(escape_untrusted(e) for e in extra)}")
311
+ return not missing
312
+
313
+ def uint(self, v: Any, path: str, *, positive: bool = False, equals: int | None = None,
314
+ minimum: int | None = None) -> None:
315
+ if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= UINT_MAX:
316
+ self.bad(path, "must be an integer from 0 to 2^53-1")
317
+ return
318
+ if positive and v <= 0:
319
+ self.bad(path, "must be positive")
320
+ if equals is not None and v != equals:
321
+ self.bad(path, f"must be {equals}")
322
+ if minimum is not None and v < minimum:
323
+ self.bad(path, f"must be at least {minimum}")
324
+
325
+ def string(self, v: Any, path: str, *, equals: str | None = None) -> bool:
326
+ if not isinstance(v, str) or not v:
327
+ self.bad(path, "must be a non-empty string")
328
+ return False
329
+ if equals is not None and v != equals:
330
+ self.bad(path, f"must be {equals!r}")
331
+ return False
332
+ return True
333
+
334
+ def https(self, v: Any, path: str) -> None:
335
+ if self.string(v, path):
336
+ parts = urllib.parse.urlsplit(v)
337
+ if parts.scheme != "https" or not parts.hostname:
338
+ self.bad(path, "must be an https:// URL")
339
+
340
+ def hash(self, v: Any, path: str) -> None:
341
+ if not isinstance(v, str) or not _HEX64.fullmatch(v):
342
+ self.bad(path, "must be 64 lowercase hex characters")
343
+
344
+ def str_list(self, v: Any, path: str, *, unique: bool = True) -> list[str]:
345
+ if not isinstance(v, list) or not all(isinstance(x, str) and x for x in v):
346
+ self.bad(path, "must be an array of non-empty strings")
347
+ return []
348
+ if unique and len(set(v)) != len(v):
349
+ self.bad(path, "entries must be unique")
350
+ return v
351
+
352
+ def count_limit(self, v: Any, path: str) -> None:
353
+ if self.obj(v, path, ("count", "interval_seconds")):
354
+ self.uint(v["count"], f"{path}.count", positive=True)
355
+ self.uint(v["interval_seconds"], f"{path}.interval_seconds", positive=True)
356
+
357
+ def characters(self, v: Any, path: str) -> None:
358
+ """Every string (and member name) is free of control, format, line
359
+ and paragraph separator characters: card text is printed to agents
360
+ and must not smuggle lines or terminal escapes into a verdict."""
361
+ if isinstance(v, str):
362
+ bad = _forbidden_chars(v)
363
+ if bad:
364
+ self.bad(path, f"contains control or format characters ({', '.join(bad)})")
365
+ elif isinstance(v, dict):
366
+ for k, x in v.items():
367
+ bad = _forbidden_chars(k)
368
+ if bad:
369
+ self.bad(path, f"a member name contains control or format characters ({', '.join(bad)})")
370
+ self.characters(x, f"{path}.{escape_untrusted(k)}")
371
+ elif isinstance(v, list):
372
+ for i, x in enumerate(v):
373
+ self.characters(x, f"{path}[{i}]")
374
+
375
+
376
+ def _forbidden_chars(s: str) -> list[str]:
377
+ found = sorted({ord(c) for c in s if unicodedata.category(c) in FORBIDDEN_CATEGORIES})
378
+ return [f"U+{o:04X}" for o in found[:8]] + (["..."] if len(found) > 8 else [])
379
+
380
+
381
+ def sup_asset_hash(origin: str) -> str:
382
+ """AICS-1 §20.2: ``hexH`` of the SUP asset descriptor of this log's origin, every ``asset_hash`` of the profile."""
383
+ return hashlib.sha256(canonicalize({"asset": "SUP", "buys": ["owner_action", "hosting", "storage"],
384
+ "decimals": 0, "origin": origin, "refundable": False,
385
+ "transferable": False})).hexdigest()
386
+
387
+
388
+ def unsupported_profile(card: Any) -> str | None:
389
+ """The card's ``spec`` when it names a profile this release does not implement (§19: reject, never reinterpret
390
+ as a base card), else None."""
391
+ spec = card.get("spec") if isinstance(card, dict) else None
392
+ return spec if isinstance(spec, str) and spec in UNSUPPORTED_PROFILES else None
393
+
394
+
395
+ def _sup_problems(s: "_Schema", card: dict[str, Any]) -> None:
396
+ """AICS-1 §20.9: the ``sup`` member of an activated SUP-1 card."""
397
+ sup = card["sup"]
398
+ if not s.obj(sup, "$.sup", SUP_MEMBERS):
399
+ return
400
+ if isinstance(card["origin"], str) and sup["asset_hash"] != sup_asset_hash(card["origin"]):
401
+ s.bad("$.sup.asset_hash", "must be hexH of the §20.2 SUP asset descriptor of this card's origin")
402
+ contributions = sup["contributions"]
403
+ if not isinstance(contributions, list):
404
+ s.bad("$.sup.contributions", "must be an array (empty when no network is offered)")
405
+ else:
406
+ networks: list[str] = []
407
+ for i, c in enumerate(contributions):
408
+ path = f"$.sup.contributions[{i}]"
409
+ if not s.obj(c, path, SUP_CONTRIBUTION_MEMBERS):
410
+ continue
411
+ if not isinstance(c["network"], str) or not _SUP_NETWORK.fullmatch(c["network"]):
412
+ s.bad(f"{path}.network", "must match [a-z0-9-]{1,32}")
413
+ else:
414
+ networks.append(c["network"])
415
+ s.string(c["asset"], f"{path}.asset")
416
+ s.string(c["address"], f"{path}.address")
417
+ s.uint(c["decimals"], f"{path}.decimals")
418
+ s.uint(c["units_per_sup"], f"{path}.units_per_sup", minimum=2)
419
+ if len(set(networks)) != len(networks):
420
+ s.bad("$.sup.contributions", "must list each credited network once")
421
+ s.uint(sup["intent_seconds"], "$.sup.intent_seconds", positive=True)
422
+ s.uint(sup["claim_seconds"], "$.sup.claim_seconds", positive=True)
423
+ if all(isinstance(sup[k], int) and not isinstance(sup[k], bool) for k in ("intent_seconds", "claim_seconds")) \
424
+ and sup["intent_seconds"] > sup["claim_seconds"]:
425
+ s.bad("$.sup", "needs 0 < intent_seconds <= claim_seconds")
426
+ s.uint(sup["record_seconds"], "$.sup.record_seconds")
427
+ if isinstance(sup["record_seconds"], int) and not isinstance(sup["record_seconds"], bool) \
428
+ and sup["record_seconds"] > SUP_RECORD_SECONDS_MAX:
429
+ s.bad("$.sup.record_seconds", f"must be at most {SUP_RECORD_SECONDS_MAX} (decision 17's 12 months)")
430
+ storage = sup["storage"]
431
+ if storage is not None and s.obj(storage, "$.sup.storage", SUP_STORAGE_MEMBERS):
432
+ s.uint(storage["free_quota_bytes"], "$.sup.storage.free_quota_bytes", minimum=SUP_FREE_QUOTA_MINIMUM)
433
+ unit_bytes, unit_amount = storage["unit_bytes"], storage["unit_amount"]
434
+ if (unit_bytes is None) != (unit_amount is None):
435
+ s.bad("$.sup.storage", "unit_bytes and unit_amount are both null (no storage offered) or both positive")
436
+ elif unit_bytes is not None:
437
+ s.uint(unit_bytes, "$.sup.storage.unit_bytes", positive=True)
438
+ s.uint(unit_amount, "$.sup.storage.unit_amount", positive=True)
439
+
440
+
441
+ def _service_keys(card: dict[str, Any]) -> dict[bytes, str]:
442
+ """Public keys of the card's log, policy and owner keys (those that parse)."""
443
+ out: dict[bytes, str] = {}
444
+ try:
445
+ out[parse_log_vkey(str(card.get("log_vkey"))).public_key] = "log_vkey"
446
+ except PolicyError:
447
+ pass
448
+ for k in ("policy_kid", "owner_kid"):
449
+ try:
450
+ out.setdefault(parse_policy_key(str(card.get(k))).public_key, k)
451
+ except PolicyError:
452
+ pass
453
+ return out
454
+
455
+
456
+ def schema_problems(card: Any) -> list[str]:
457
+ """Problems with ``card`` against the AICS-1 §13.1 schema (empty: valid),
458
+ including forbidden characters in any string and witnesses that reuse
459
+ the log, policy or owner key. A card of ``spec`` ``AICS-1+SUP-1`` also has
460
+ §20.9's ``sup`` member and §20.8's refusal codes; a swarms profile is no
461
+ profile of this release (check :func:`unsupported_profile` first)."""
462
+ s = _Schema()
463
+ sup = isinstance(card, dict) and card.get("spec") == SPEC_SUP
464
+ if not s.obj(card, "$", CARD_MEMBERS + (("sup",) if sup else ())):
465
+ return s.problems
466
+ s.characters(card, "$")
467
+ if s.string(card["spec"], "$.spec") and card["spec"] not in SUPPORTED_PROFILES:
468
+ s.bad("$.spec", f"must be {SPEC_BASE!r} or {SPEC_SUP!r}, the profiles this release verifies "
469
+ f"(got {escape_untrusted(card['spec'][:48])!r}; AICS-1 §§13.1, 19, 20.9)")
470
+ if s.string(card["version"], "$.version") and card["version"] != CARD_VERSION:
471
+ s.bad("$.version", f"must be {CARD_VERSION!r}: AICS-1 {CARD_VERSION} §13.1 refuses a card of another "
472
+ f"version (got {escape_untrusted(card['version'][:32])!r})")
473
+ s.string(card["origin"], "$.origin")
474
+ s.uint(card["issued_at"], "$.issued_at")
475
+ s.uint(card["expires"], "$.expires")
476
+ if isinstance(card["issued_at"], int) and isinstance(card["expires"], int) \
477
+ and not card["issued_at"] < card["expires"]:
478
+ s.bad("$", "issued_at must be before expires")
479
+ if card["policy_parent_index"] is not None:
480
+ s.uint(card["policy_parent_index"], "$.policy_parent_index")
481
+ s.string(card["log_vkey"], "$.log_vkey")
482
+ for k in ("policy_kid", "owner_kid"):
483
+ if s.string(card[k], f"$.{k}") and not card[k].startswith("did:key:z"):
484
+ s.bad(f"$.{k}", "must be a did:key")
485
+ prefixes = card["tile_prefixes"]
486
+ if not isinstance(prefixes, list) or not prefixes:
487
+ s.bad("$.tile_prefixes", "must list at least one tile prefix")
488
+ else:
489
+ for i, p in enumerate(prefixes):
490
+ s.https(p, f"$.tile_prefixes[{i}]")
491
+ s.https(card["api_base"], "$.api_base")
492
+ gate = card["gate"]
493
+ if s.obj(gate, "$.gate", ("tls_host", "tls_port", "plaintext_port", "https_url")):
494
+ s.string(gate["tls_host"], "$.gate.tls_host")
495
+ s.uint(gate["tls_port"], "$.gate.tls_port", equals=7700)
496
+ s.uint(gate["plaintext_port"], "$.gate.plaintext_port", equals=7701)
497
+ s.https(gate["https_url"], "$.gate.https_url")
498
+ if not isinstance(card["proxy"], list):
499
+ s.bad("$.proxy", "must be an array")
500
+ else:
501
+ for i, p in enumerate(card["proxy"]):
502
+ if s.obj(p, f"$.proxy[{i}]", ("host", "mode", "tls_termination")):
503
+ s.string(p["host"], f"$.proxy[{i}].host")
504
+ if p["mode"] not in ("cloudflare", "dns-only"):
505
+ s.bad(f"$.proxy[{i}].mode", "must be 'cloudflare' or 'dns-only'")
506
+ s.string(p["tls_termination"], f"$.proxy[{i}].tls_termination")
507
+ wp = card["witness_policy"]
508
+ if s.obj(wp, "$.witness_policy", ("quorum", "witnesses")):
509
+ s.uint(wp["quorum"], "$.witness_policy.quorum")
510
+ ws = wp["witnesses"]
511
+ if not isinstance(ws, list):
512
+ s.bad("$.witness_policy.witnesses", "must be an array")
513
+ else:
514
+ names, keys = set(), set()
515
+ service = _service_keys(card)
516
+ for i, w in enumerate(ws):
517
+ path = f"$.witness_policy.witnesses[{i}]"
518
+ if not s.obj(w, path, ("name", "vkey", "trust_domain", "independent")):
519
+ continue
520
+ s.string(w["name"], f"{path}.name")
521
+ s.string(w["trust_domain"], f"{path}.trust_domain")
522
+ if not isinstance(w["independent"], bool):
523
+ s.bad(f"{path}.independent", "must be a boolean")
524
+ try:
525
+ vk = parse_witness_vkey(str(w["vkey"]))
526
+ except PolicyError as exc:
527
+ s.bad(f"{path}.vkey", exc.message)
528
+ continue
529
+ if vk.name != w["name"]:
530
+ s.bad(f"{path}.vkey", "key name differs from the witness name")
531
+ if vk.public_key in service:
532
+ s.bad(f"{path}.vkey", f"reuses the public key of {service[vk.public_key]}: the log, policy and "
533
+ "owner keys cannot act as witnesses (AICS-1 §3.1, §6.3)")
534
+ if w["name"] in names or vk.public_key in keys:
535
+ s.bad(path, "witness names and public keys must be unique")
536
+ names.add(w["name"])
537
+ keys.add(vk.public_key)
538
+ if isinstance(wp["quorum"], int) and not isinstance(wp["quorum"], bool) and wp["quorum"] > len(ws):
539
+ s.bad("$.witness_policy.quorum", "exceeds the number of witnesses")
540
+ s.uint(card["max_checkpoint_age"], "$.max_checkpoint_age", minimum=60)
541
+ s.uint(card["mmd_seconds"], "$.mmd_seconds", equals=60)
542
+ s.uint(card["policy_notice_seconds"], "$.policy_notice_seconds")
543
+ lim = card["limits"]
544
+ if s.obj(lim, "$.limits", (*FIXED_LIMITS, "writes", "registrations", "preidentity",
545
+ "flood_suspension_seconds")):
546
+ for k, v in FIXED_LIMITS.items():
547
+ s.uint(lim[k], f"$.limits.{k}", equals=v)
548
+ for k in ("writes", "registrations", "preidentity"):
549
+ s.count_limit(lim[k], f"$.limits.{k}")
550
+ s.uint(lim["flood_suspension_seconds"], "$.limits.flood_suspension_seconds", positive=True)
551
+ ret = card["retention"]
552
+ if s.obj(ret, "$.retention", ("dm_seconds", "capsule_max_seconds", "ciphertext_budget_bytes", "eviction")):
553
+ for k in ("dm_seconds", "capsule_max_seconds", "ciphertext_budget_bytes"):
554
+ s.uint(ret[k], f"$.retention.{k}", positive=True)
555
+ if ret["eviction"] != "oldest-ts-index":
556
+ s.bad("$.retention.eviction", "must be 'oldest-ts-index'")
557
+ if MANDATORY_SCHEME not in s.str_list(card["schemes"], "$.schemes"):
558
+ s.bad("$.schemes", f"must include {MANDATORY_SCHEME}")
559
+ tiers = s.str_list(card["tiers"], "$.tiers")
560
+ if not set(TIERS) <= set(tiers):
561
+ s.bad("$.tiers", f"must include {', '.join(TIERS)}")
562
+ rc = card["reason_codes"]
563
+ if s.obj(rc, "$.reason_codes", tuple(REASON_REGISTRIES)):
564
+ patterns = card["secret_patterns"] if isinstance(card["secret_patterns"], list) else []
565
+ active = {f"secret_format:{p['id']}" for p in patterns
566
+ if isinstance(p, dict) and isinstance(p.get("id"), str) and _PATTERN_ID.fullmatch(p["id"])}
567
+ for k in REASON_REGISTRIES:
568
+ if k not in rc:
569
+ continue
570
+ codes = s.str_list(rc[k], f"$.reason_codes.{k}")
571
+ expected = set(REASON_REGISTRIES[k]) | (active if k == "refusal" else set())
572
+ if k == "refusal" and sup:
573
+ expected |= set(SUP_REFUSALS)
574
+ if isinstance(rc[k], list) and set(codes) != expected:
575
+ extra = sorted(set(codes) - expected)
576
+ missing = sorted(expected - set(codes))
577
+ s.bad(f"$.reason_codes.{k}", "must equal the AICS-1 §17.2 registry"
578
+ + (" with the twelve §20.8 codes of the SUP-1 profile" if k == "refusal" and sup else "")
579
+ + (" plus one secret_format:<id> per secret pattern" if k == "refusal" else "")
580
+ + (f" (unregistered: {', '.join(escape_untrusted(c) for c in extra[:8])})" if extra else "")
581
+ + (f" (missing: {', '.join(missing[:8])})" if missing else ""))
582
+ if not isinstance(card["secret_patterns"], list):
583
+ s.bad("$.secret_patterns", "must be an array")
584
+ else:
585
+ ids = []
586
+ for i, p in enumerate(card["secret_patterns"]):
587
+ if s.obj(p, f"$.secret_patterns[{i}]", ("id", "pattern_hash")):
588
+ if not isinstance(p["id"], str) or not _PATTERN_ID.fullmatch(p["id"]):
589
+ s.bad(f"$.secret_patterns[{i}].id", "must match [a-z0-9_]{1,32}")
590
+ ids.append(p["id"])
591
+ s.hash(p["pattern_hash"], f"$.secret_patterns[{i}].pattern_hash")
592
+ if len(set(map(str, ids))) != len(ids):
593
+ s.bad("$.secret_patterns", "ids must be unique")
594
+ pol = card["policies"]
595
+ if s.obj(pol, "$.policies", ("charter_hash", "terms_hash", "privacy_hash")):
596
+ for k in pol:
597
+ s.hash(pol[k], f"$.policies.{k}")
598
+ seen_ids: list[str] = []
599
+ if not isinstance(card["assurances"], list):
600
+ s.bad("$.assurances", "must be an array")
601
+ else:
602
+ for i, a in enumerate(card["assurances"]):
603
+ path = f"$.assurances[{i}]"
604
+ if not s.obj(a, path, ("id", "status", "claim", "assumptions", "evidence")):
605
+ continue
606
+ s.string(a["id"], f"{path}.id")
607
+ seen_ids.append(a["id"])
608
+ if a["status"] not in STATUSES:
609
+ s.bad(f"{path}.status", "must be proven, promised or not-offered")
610
+ s.string(a["claim"], f"{path}.claim")
611
+ s.str_list(a["assumptions"], f"{path}.assumptions", unique=False)
612
+ if not isinstance(a["evidence"], list):
613
+ s.bad(f"{path}.evidence", "must be an array")
614
+ else:
615
+ for j, e in enumerate(a["evidence"]):
616
+ if s.obj(e, f"{path}.evidence[{j}]", ("hash", "media_type")):
617
+ s.hash(e["hash"], f"{path}.evidence[{j}].hash")
618
+ s.string(e["media_type"], f"{path}.evidence[{j}].media_type")
619
+ missing = [x for x in REQUIRED_ASSURANCES if seen_ids.count(x) != 1]
620
+ if missing:
621
+ s.bad("$.assurances", f"needs exactly one assurance for each of G1-G10, N1-N5 (bad: {', '.join(missing)})")
622
+ if any(a.get("id", "").startswith("N") and a.get("status") != "not-offered"
623
+ for a in card["assurances"] if isinstance(a, dict) and isinstance(a.get("id"), str)):
624
+ s.bad("$.assurances", "N1-N5 must be not-offered (AICS-1 §13.3)")
625
+ if card["spec_versions"] != SPEC_VERSIONS:
626
+ s.bad("$.spec_versions", f"must be {json.dumps(SPEC_VERSIONS, sort_keys=True)}")
627
+ if sup:
628
+ _sup_problems(s, card)
629
+ return s.problems
630
+
631
+
632
+ def assurance_problems(card: dict[str, Any]) -> list[str]:
633
+ """Statuses outside the AICS-1 §13.3 boundaries, judged from the card's
634
+ own content (empty: within bounds). Call only on a schema-valid card.
635
+
636
+ * G5 (single view) is ``promised`` without independent witnesses and an
637
+ honest quorum-intersection argument: ``proven`` needs a quorum of at
638
+ least 2 with at least one witness declared independent, and must state
639
+ the honest-intersection assumption (§6.3).
640
+ * G7 (running code tied to source) is ``promised`` absent runtime
641
+ evidence: ``proven`` with no evidence at all is out of bounds.
642
+ """
643
+ out: list[str] = []
644
+ wp = card["witness_policy"]
645
+ for i, a in enumerate(card["assurances"]):
646
+ if a["status"] != "proven":
647
+ continue
648
+ path = f"$.assurances[{i}] ({a['id']})"
649
+ if a["id"] == "G5":
650
+ independent = [w for w in wp["witnesses"] if w["independent"] is True]
651
+ if wp["quorum"] < 2 or not independent:
652
+ out.append(f"{path}: 'proven' needs a witness quorum of at least 2 with at least one independent "
653
+ f"witness; this card's policy has quorum {wp['quorum']} and {len(independent)} witness(es) "
654
+ "declared independent, so G5 stays 'promised' (AICS-1 §6.3, §13.3)")
655
+ if not a["assumptions"]:
656
+ out.append(f"{path}: 'proven' must state the honest quorum-intersection assumption (AICS-1 §6.3)")
657
+ elif a["id"] == "G7" and not a["evidence"]:
658
+ out.append(f"{path}: 'proven' without runtime evidence; source publication or a release signature "
659
+ "alone is insufficient, so G7 stays 'promised' (AICS-1 §13.3)")
660
+ return out
661
+
662
+
663
+ # ---------------------------------------------------------------------------
664
+ # comparing with the pinned policy and the saved card
665
+ # ---------------------------------------------------------------------------
666
+
667
+
668
+ def anchor_mismatches(card: dict[str, Any], policy: Policy) -> list[dict[str, Any]]:
669
+ """Trust anchors the card states that differ from the pinned bundle."""
670
+ out = []
671
+ for f in ("origin", "log_vkey", "policy_kid", "owner_kid"):
672
+ pinned = getattr(policy, f)
673
+ stated = card.get(f)
674
+ if pinned is None:
675
+ continue
676
+ if f in ("policy_kid", "owner_kid"):
677
+ try:
678
+ same = parse_policy_key(str(stated)).public_key == parse_policy_key(pinned).public_key
679
+ except PolicyError:
680
+ same = False
681
+ else:
682
+ same = stated == pinned
683
+ if not same:
684
+ out.append({"field": f, "card": stated, "pinned": pinned})
685
+ return out
686
+
687
+
688
+ def witness_terms(card: dict[str, Any]) -> tuple[int, frozenset[str], int]:
689
+ """(quorum, normalised witness vkeys, max_checkpoint_age) of a schema-valid card."""
690
+ from aics_tlog import format_vkey
691
+
692
+ wp = card["witness_policy"]
693
+ keys = frozenset(format_vkey(parse_witness_vkey(w["vkey"])) for w in wp["witnesses"])
694
+ return wp["quorum"], keys, card["max_checkpoint_age"]
695
+
696
+
697
+ def at_least_as_strict(new: tuple[int, frozenset[str], int], old: tuple[int, frozenset[str], int]) -> bool:
698
+ """Does every checkpoint the ``new`` witness terms accept also pass the
699
+ ``old`` ones? Terms are (quorum, witness vkeys, max_checkpoint_age). In
700
+ the worst case the new quorum is filled first by witnesses the old terms
701
+ do not know, so it must still contain ``old`` quorum known ones. Old
702
+ terms with quorum 0 accept every log-signed checkpoint, so anything is at
703
+ least as strict as they are."""
704
+ nq, nw, na = new
705
+ oq, ow, oa = old
706
+ if oq == 0:
707
+ return True
708
+ return na <= oa and nq - len(nw - ow) >= oq
709
+
710
+
711
+ def compare_witness(old: tuple[int, frozenset[str], int], new: tuple[int, frozenset[str], int]) -> str:
712
+ """'same', 'stronger' (new terms at least as strict) or 'weaker' (they
713
+ accept some checkpoint the old terms reject: a downgrade). Two quorum-0
714
+ policies accept the same checkpoints."""
715
+ if old == new or (old[0] == 0 and new[0] == 0):
716
+ return "same"
717
+ return "stronger" if at_least_as_strict(new, old) else "weaker"
718
+
719
+
720
+ def terms_dict(terms: tuple[int, frozenset[str], int]) -> dict[str, Any]:
721
+ q, w, a = terms
722
+ return {"quorum": q, "witness_vkeys": sorted(w), "max_checkpoint_age": None if a == UINT_MAX else a}
723
+
724
+
725
+ def policy_terms(policy: Policy) -> tuple[int, frozenset[str], int]:
726
+ return (policy.quorum, frozenset(policy.witness_vkeys),
727
+ policy.max_checkpoint_age if policy.max_checkpoint_age is not None else UINT_MAX)
728
+
729
+
730
+ CLAIMED_PROVEN = "claimed proven (evidence not verified)"
731
+ TABLE_NOTE = ("operator claims: statuses and text are the card's own statements (untrusted text). aics checked that "
732
+ "the statuses stay within the AICS-1 §13.3 bounds; it did not fetch or check the evidence behind any "
733
+ "'proven' row")
734
+
735
+
736
+ def guarantee_table(card: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
737
+ """The card's assurances grouped by the status the card states. Each
738
+ item carries ``evidence_verified: false``: this tool never fetches the
739
+ evidence documents, so a ``proven`` row is the operator's claim."""
740
+ table: dict[str, list[dict[str, Any]]] = {s: [] for s in STATUSES}
741
+ table["other"] = []
742
+ for a in card.get("assurances") or []:
743
+ if not isinstance(a, dict):
744
+ continue
745
+ item = {"id": a.get("id"), "claim": a.get("claim"), "status": a.get("status"),
746
+ "assumptions": a.get("assumptions") or [], "evidence": len(a.get("evidence") or []),
747
+ "evidence_verified": False}
748
+ table[a.get("status") if a.get("status") in STATUSES else "other"].append(item)
749
+ return table
750
+
751
+
752
+ def render_table(table: dict[str, list[dict[str, Any]]]) -> list[str]:
753
+ """Text rows of the table. Every string from the card is escaped, and a
754
+ ``proven`` row reads as claimed unless its evidence was verified."""
755
+ rows = []
756
+ for st in (*STATUSES, "other"):
757
+ for it in table.get(st, []):
758
+ shown = st if st != "other" else str(it.get("status"))
759
+ if st == "proven" and not it.get("evidence_verified"):
760
+ shown = CLAIMED_PROVEN
761
+ rows.append((escape_untrusted(shown), escape_untrusted(str(it.get("id") or "")),
762
+ escape_untrusted(str(it.get("claim") or "")), str(it.get("evidence", 0))))
763
+ if not rows:
764
+ return ["(the card lists no assurances)"]
765
+ head = ("STATUS", "ID", "CLAIM", "EVIDENCE")
766
+ widths = [max(len(str(r[i])) for r in [head, *rows]) for i in range(2)]
767
+ out = [TABLE_NOTE + ":"]
768
+ for r in [head, *rows]:
769
+ out.append(f"{r[0]:<{widths[0]}} {r[1]:<{widths[1]}} {r[3]:>8} {r[2]}")
770
+ return [line.rstrip() for line in out]
771
+
772
+
773
+ def json_diff(old: Any, new: Any, path: str = "$") -> list[dict[str, Any]]:
774
+ if isinstance(old, dict) and isinstance(new, dict):
775
+ out: list[dict[str, Any]] = []
776
+ for k in sorted(set(old) | set(new)):
777
+ p = f"{path}.{k}"
778
+ if k not in old:
779
+ out.append({"op": "added", "path": p, "new": new[k]})
780
+ elif k not in new:
781
+ out.append({"op": "removed", "path": p, "old": old[k]})
782
+ else:
783
+ out += json_diff(old[k], new[k], p)
784
+ return out
785
+ if isinstance(old, list) and isinstance(new, list):
786
+ out = []
787
+ for i in range(max(len(old), len(new))):
788
+ p = f"{path}[{i}]"
789
+ if i >= len(old):
790
+ out.append({"op": "added", "path": p, "new": new[i]})
791
+ elif i >= len(new):
792
+ out.append({"op": "removed", "path": p, "old": old[i]})
793
+ else:
794
+ out += json_diff(old[i], new[i], p)
795
+ return out
796
+ if old != new or type(old) is not type(new):
797
+ return [{"op": "changed", "path": path, "old": old, "new": new}]
798
+ return []
799
+
800
+
801
+ # ---------------------------------------------------------------------------
802
+ # where the card is, and the local store
803
+ # ---------------------------------------------------------------------------
804
+
805
+
806
+ def resolve_url(arg: str | None, policy: Policy | None) -> str:
807
+ if not arg:
808
+ if policy is None or not policy.card_url:
809
+ raise AicsError("no card URL: pass one or pin card_url in the policy")
810
+ return policy.card_url
811
+ url = arg if "://" in arg else f"https://{arg}"
812
+ parts = urllib.parse.urlsplit(url)
813
+ if parts.path.endswith(".json") or "/.well-known/" in parts.path:
814
+ return url
815
+ return urllib.parse.urlunsplit((parts.scheme, parts.netloc, WELL_KNOWN, "", ""))
816
+
817
+
818
+ def fetch_card(fetcher: Any, url: str) -> bytes:
819
+ resp = fetcher.get(url, max_bytes=CARD_MAX_BYTES, accept="application/json")
820
+ if resp.status != 200:
821
+ raise FetchError(f"{url}: HTTP {resp.status}")
822
+ return resp.body
823
+
824
+
825
+ @dataclass
826
+ class StoredCard:
827
+ card: dict[str, Any]
828
+ meta: dict[str, Any] = field(default_factory=dict)
829
+
830
+
831
+ def store_key(policy_kid: str) -> str:
832
+ """Cards are kept per pinned policy key (its canonical did:key), not per
833
+ URL host: the same card fetched through another host or URL is compared
834
+ with the same history."""
835
+ try:
836
+ return parse_policy_key(policy_kid).did
837
+ except PolicyError:
838
+ return policy_kid
839
+
840
+
841
+ class CardStore:
842
+ def __init__(self, root: Path) -> None:
843
+ self.root = root
844
+
845
+ @property
846
+ def index(self) -> Path:
847
+ return self.root / "index.jsonl"
848
+
849
+ def latest(self, policy_kid: str) -> StoredCard | None:
850
+ """The stored card with the latest ``issued_at`` for this policy key
851
+ (the order of the index does not matter: storing an older card later
852
+ cannot reset the history)."""
853
+ key = store_key(policy_kid)
854
+ best: tuple[int, int, StoredCard] | None = None
855
+ for pos, rec in enumerate(_home.read_jsonl(self.index)):
856
+ if rec.get("policy_kid") != key or not isinstance(rec.get("file"), str):
857
+ continue
858
+ try:
859
+ card = loads_strict((self.root / rec["file"]).read_bytes())
860
+ except (OSError, ValueError, UnicodeDecodeError):
861
+ continue
862
+ if not isinstance(card, dict):
863
+ continue
864
+ issued = card.get("issued_at")
865
+ rank = issued if isinstance(issued, int) and not isinstance(issued, bool) else -1
866
+ if best is None or (rank, pos) >= best[:2]:
867
+ best = (rank, pos, StoredCard(card=card, meta=rec))
868
+ return best[2] if best else None
869
+
870
+ def save(self, policy_kid: str, url: str, card: dict[str, Any], *, now: int, status: str) -> tuple[Path, bool]:
871
+ """Store ``J(card)`` under its own hash (never overwritten) and log the check."""
872
+ key = store_key(policy_kid)
873
+ h = card_hash(card)
874
+ path = self.root / _home.slug(key) / f"{h}.json"
875
+ created = _home.write_new(path, canonicalize(card))
876
+ _home.append_jsonl(self.index, {
877
+ "verified_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
878
+ "policy_kid": key,
879
+ "host": urllib.parse.urlsplit(url).hostname or url,
880
+ "url": url,
881
+ "card_hash": h,
882
+ "file": str(path.relative_to(self.root)),
883
+ "new": created,
884
+ "status": status,
885
+ "issued_at": card.get("issued_at"),
886
+ "expires": card.get("expires"),
887
+ })
888
+ return path, created
889
+
890
+
891
+ @dataclass
892
+ class History:
893
+ """A schema-valid card compared with the card saved for the same policy key."""
894
+
895
+ previous: StoredCard | None = None
896
+ diff: list[dict[str, Any]] | None = None
897
+ witness: str | None = None # same | stronger | weaker (vs the saved card), None if not comparable
898
+ superseded: bool = False # issued before the saved card
899
+ equivocation: bool = False # same issued_at, different card: the policy key signed two cards for one moment
900
+
901
+ def problem(self) -> str | None:
902
+ if self.equivocation:
903
+ return "equivocation"
904
+ if self.superseded:
905
+ return "superseded"
906
+ if self.witness == "weaker":
907
+ return "witness-downgrade"
908
+ return None
909
+
910
+
911
+ def compare_with_saved(store: CardStore, policy_kid: str, card: dict[str, Any]) -> History:
912
+ prev = store.latest(policy_kid)
913
+ hist = History(previous=prev)
914
+ if prev is None:
915
+ return hist
916
+ hist.diff = json_diff({k: v for k, v in prev.card.items() if k != "signatures"},
917
+ {k: v for k, v in card.items() if k != "signatures"})
918
+ try:
919
+ hist.witness = compare_witness(witness_terms(prev.card), witness_terms(card))
920
+ before, now_issued = prev.card["issued_at"], card["issued_at"]
921
+ hist.superseded = now_issued < before
922
+ hist.equivocation = now_issued == before and card_hash(prev.card) != card_hash(card)
923
+ except (KeyError, TypeError, ValueError, PolicyError): # a saved card from an older schema
924
+ hist.witness = None
925
+ return hist
926
+
927
+
928
+ HISTORY_MESSAGES = {
929
+ "equivocation": "the policy key signed two different cards with the same issued_at: equivocation",
930
+ "superseded": "the card was issued before the card saved earlier: a superseded card",
931
+ "witness-downgrade": "the card weakens the witness policy of the card saved earlier; its authorisation and notice "
932
+ "cannot be checked yet, so it is rejected (AICS-1 §6.3, §14.5 step 5)",
933
+ }