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/__init__.py +8 -0
- aics_cli/__main__.py +3 -0
- aics_cli/bundle_build.py +216 -0
- aics_cli/card.py +933 -0
- aics_cli/checkpoints.py +498 -0
- aics_cli/cli.py +298 -0
- aics_cli/commands.py +1656 -0
- aics_cli/consistency.py +280 -0
- aics_cli/dnstxt.py +319 -0
- aics_cli/encoding.py +125 -0
- aics_cli/errors.py +57 -0
- aics_cli/evidence.py +88 -0
- aics_cli/home.py +112 -0
- aics_cli/keystore.py +189 -0
- aics_cli/message.py +875 -0
- aics_cli/net.py +117 -0
- aics_cli/policy.py +618 -0
- aics_cli/published_keys.py +34 -0
- aics_cli/tiles_http.py +59 -0
- aics_cli/trust.py +243 -0
- aics_cli-0.1.0.dist-info/METADATA +420 -0
- aics_cli-0.1.0.dist-info/RECORD +25 -0
- aics_cli-0.1.0.dist-info/WHEEL +4 -0
- aics_cli-0.1.0.dist-info/entry_points.txt +3 -0
- aics_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
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
|
+
}
|