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/commands.py ADDED
@@ -0,0 +1,1656 @@
1
+ """Implementations of the ``aics`` commands.
2
+
3
+ Each command takes the parsed arguments and an :class:`Env` (network,
4
+ DNS, clock, home directory - injectable for tests) and returns a
5
+ :class:`Result`, or raises :class:`~aics_cli.errors.AicsError` (exit 1:
6
+ error, incomplete, stale or unproven) or
7
+ :class:`~aics_cli.errors.VerificationFailed` (exit 2: invalid, with
8
+ evidence).
9
+
10
+ Exit 2 is kept for what the output can back with evidence: log-signed
11
+ conflicts (an equal-size split, a constructive not-a-prefix proof, a
12
+ smaller head that is not a prefix of a saved one), a log-signed checkpoint
13
+ no log can produce, an invalid checkpoint served by the log origin itself,
14
+ an invalid or downgrading signed card, and a disagreement between the
15
+ out-of-band key sources. A stale or under-witnessed head, an older head
16
+ that is consistent with the saved one, and an invalid note from a mirror,
17
+ a peer or a file exit 1: anyone on those paths can produce them.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import sys
24
+ import time
25
+ import tomllib
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Any, Callable
29
+
30
+ from aics_identity import linesig as idlinesig
31
+ from aics_tlog import format_vkey, parse_checkpoint, parse_note
32
+
33
+ from . import __version__
34
+ from . import card as cardmod
35
+ from . import home as _home
36
+ from . import keystore
37
+ from . import message as msgmod
38
+ from .checkpoints import (
39
+ DEFAULT_MAX_SKEW,
40
+ CheckpointStore,
41
+ Cp,
42
+ NoteCheck,
43
+ SavedNote,
44
+ api_key_warnings,
45
+ check_note,
46
+ extract_note,
47
+ fetch_checkpoint,
48
+ log_signed,
49
+ )
50
+ from .consistency import Attempt, EndpointSource, GivenProof, Relation, TileSource, relate
51
+ from .dnstxt import TxtResult, lookup_txt
52
+ from .encoding import b64e, escape_untrusted, parse_hash_lines
53
+ from .errors import (
54
+ EXIT_OK,
55
+ EXIT_UNAVAILABLE,
56
+ AicsError,
57
+ DnsError,
58
+ FetchError,
59
+ PolicyError,
60
+ UsageError,
61
+ VerificationFailed,
62
+ )
63
+ from .evidence import split_bundle
64
+ from .net import Fetcher, HttpFetcher, join_url
65
+ from .policy import (
66
+ TRUST_FIELDS,
67
+ Policy,
68
+ SourceValues,
69
+ archived_log_keys,
70
+ compare_sources,
71
+ default_urls,
72
+ is_out_of_band,
73
+ load,
74
+ malformed,
75
+ parse_log_vkey,
76
+ parse_policy_key,
77
+ parse_witness_vkey,
78
+ policy_path,
79
+ prefix_urls,
80
+ save,
81
+ )
82
+ from .tiles_http import HttpTileReader, TileSourceError
83
+ from .trust import TrustBundle, bundle_from_text, parse_txt, published_bundle
84
+
85
+ UNAVAILABLE_MESSAGE = "not available until the server ships this"
86
+ DEFAULT_REQUIRED_SOURCES = 2
87
+
88
+ #: Reasons a log-signed note is self-contained evidence, whoever served it.
89
+ SELF_CONTAINED_EVIDENCE = ("bad_empty_root",)
90
+
91
+
92
+ @dataclass
93
+ class Env:
94
+ fetcher: Fetcher = field(default_factory=HttpFetcher)
95
+ resolve_txt: Callable[..., TxtResult] = lookup_txt
96
+ clock: Callable[[], float] = time.time
97
+ home: Path | None = None
98
+ stdin: Any = None
99
+
100
+ def now(self) -> int:
101
+ return int(self.clock())
102
+
103
+
104
+ @dataclass
105
+ class Result:
106
+ code: int
107
+ data: dict[str, Any]
108
+ text: list[str]
109
+
110
+
111
+ def _home_dir(args: Any, env: Env) -> Path:
112
+ h = getattr(args, "home", None)
113
+ if h:
114
+ return Path(h).expanduser()
115
+ if env.home is not None:
116
+ return env.home
117
+ return _home.default_home()
118
+
119
+
120
+ def _iso(ts: int | None) -> str | None:
121
+ return None if ts is None else time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts))
122
+
123
+
124
+ def _ok(data: dict[str, Any], text: list[str]) -> Result:
125
+ return Result(EXIT_OK, {"ok": True, **data}, text)
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # keys and line signatures
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ def cmd_keys_new(args: Any, env: Env) -> Result:
134
+ directory = Path(args.dir).expanduser() if args.dir else _home_dir(args, env)
135
+ ident, paths = keystore.new_keys(directory)
136
+ data = {**ident.to_dict(), "dir": str(directory), "files": [str(p) for p in paths]}
137
+ text = ident.lines() + [f"# private keys written to {directory} with mode 0600; nobody else has them"]
138
+ return _ok(data, text)
139
+
140
+
141
+ def cmd_keys_show(args: Any, env: Env) -> Result:
142
+ directory = Path(args.dir).expanduser() if args.dir else _home_dir(args, env)
143
+ ident, warnings = keystore.show_keys(directory)
144
+ data = {**ident.to_dict(), "dir": str(directory), "warnings": warnings}
145
+ return _ok(data, ident.lines() + [f"# warning: {w}" for w in warnings])
146
+
147
+
148
+ def _signing_key(args: Any, env: Env):
149
+ if getattr(args, "key", None):
150
+ return keystore.load_ed25519_private(Path(args.key).expanduser())
151
+ directory = Path(args.dir).expanduser() if getattr(args, "dir", None) else _home_dir(args, env)
152
+ return keystore.load_ed25519_private(directory / _home.ED25519_KEY)
153
+
154
+
155
+ def cmd_sign_line(args: Any, env: Env) -> Result:
156
+ key = _signing_key(args, env)
157
+ try:
158
+ sig = idlinesig.sign_line(args.session, args.line, key)
159
+ except (ValueError, TypeError) as exc:
160
+ raise AicsError(str(exc)) from None
161
+ pub = key.public_key().public_bytes_raw()
162
+ data = {"sig": sig, "reply": f"sig {sig}", "session": args.session, "line": args.line,
163
+ "pub": f"pub {b64e(pub)}"}
164
+ return _ok(data, [f"sig {sig}"])
165
+
166
+
167
+ def _sig_arg(args: Any, env: Env) -> str:
168
+ """The signature from --sig, or from --sig-file (a file, or '-' for stdin)."""
169
+ if getattr(args, "sig_file", None):
170
+ if args.sig is not None:
171
+ raise UsageError("give --sig or --sig-file, not both")
172
+ if args.sig_file == "-":
173
+ return (env.stdin or sys.stdin).read()
174
+ try:
175
+ return Path(args.sig_file).expanduser().read_text("utf-8")
176
+ except (OSError, UnicodeDecodeError) as exc:
177
+ raise AicsError(f"cannot read {args.sig_file}: {exc}") from None
178
+ if args.sig is None:
179
+ raise UsageError("give the signature with --sig=SIG (or --sig-file FILE, '-' for stdin)")
180
+ return args.sig
181
+
182
+
183
+ def cmd_verify_line(args: Any, env: Env) -> Result:
184
+ sig_text = _sig_arg(args, env)
185
+ pub = keystore.parse_public_key_text(args.pub) if args.pub else _signing_key(args, env).public_key()
186
+ pub_text = f"pub {b64e(pub.public_bytes_raw())}"
187
+ # AICS-1 §11.3: the signed bytes are exactly UTF8(session) || LF || UTF8(line); wider forms only on request
188
+ leeway = bool(getattr(args, "leeway", False))
189
+ try:
190
+ form = idlinesig.verify_line(args.session, args.line, sig_text, pub, leeway=leeway)
191
+ except idlinesig.InvalidLineSignature as exc:
192
+ tried = idlinesig.candidate_messages(args.session, args.line, leeway=leeway)
193
+ raise VerificationFailed(
194
+ f"line signature does not verify for this session id, line and key: {exc}",
195
+ evidence={
196
+ "type": "aics-line-signature-failure/v1",
197
+ "session": args.session,
198
+ "line": args.line,
199
+ "sig": sig_text,
200
+ "pub": pub_text,
201
+ "signed_bytes_tried_base64": [b64e(m) for m in tried],
202
+ },
203
+ ) from None
204
+ except idlinesig.MalformedLineSignature as exc:
205
+ raise AicsError(f"malformed signature: {exc}") from None
206
+ except (ValueError, TypeError) as exc:
207
+ raise AicsError(str(exc)) from None
208
+ text = [f"ok: {form} signature valid"]
209
+ if leeway:
210
+ text.append("note: --leeway accepted the exact bytes or a wider form; a protocol write needs the exact bytes")
211
+ return _ok({"valid": True, "form": form, "pub": pub_text, "leeway": leeway}, text)
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # policy
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def _dns_trust(domain: str, env: Env, nameserver: str | None) -> tuple[list[SourceValues], TrustBundle | None,
220
+ dict[str, Any]]:
221
+ name = f"_aics.{domain.rstrip('.')}"
222
+ txt = env.resolve_txt(name, nameserver=nameserver)
223
+ info: dict[str, Any] = {"name": name, "records": txt.records, "dnssec_ad": txt.ad, "resolver": txt.resolver}
224
+ if not txt.records:
225
+ raise PolicyError(f"no TXT record at {name}" + (" (NXDOMAIN)" if txt.nxdomain else ""), dns=info)
226
+ parsed = parse_txt(txt.records)
227
+ info["notes"] = parsed.notes
228
+ sources: list[SourceValues] = []
229
+ if parsed.bundle is not None:
230
+ sources.append(SourceValues(f"dns:{name}", parsed.bundle.values()))
231
+ info["bundle_sha256"] = parsed.bundle.sha256
232
+ elif parsed.legacy_log_vkey is not None:
233
+ sources.append(SourceValues(f"dns-log-vkey:{name}", {"log_vkey": parsed.legacy_log_vkey}))
234
+ return sources, parsed.bundle, info
235
+
236
+
237
+ def _api_claims(url: str, env: Env) -> SourceValues:
238
+ resp = env.fetcher.get(url, max_bytes=1024 * 1024, accept="application/json, text/plain;q=0.5")
239
+ if resp.status != 200:
240
+ raise FetchError(f"{url}: HTTP {resp.status}")
241
+ values: dict[str, Any] = {}
242
+ try:
243
+ fetched = extract_note(resp.body, url)
244
+ values["origin"] = parse_checkpoint(parse_note(fetched.note_text).text).origin
245
+ claims = fetched.api_claims
246
+ except (AicsError, ValueError):
247
+ try:
248
+ obj = cardmod.loads_strict(resp.body)
249
+ except (ValueError, UnicodeDecodeError):
250
+ obj = {}
251
+ claims = obj if isinstance(obj, dict) else {}
252
+ if isinstance(claims.get("origin"), str):
253
+ values["origin"] = claims["origin"]
254
+ for k in ("log_vkey", "server_vkey", "vkey"):
255
+ if isinstance(claims.get(k), str):
256
+ values["log_vkey"] = claims[k].strip()
257
+ break
258
+ for k in ("policy_kid", "owner_kid"):
259
+ if isinstance(claims.get(k), str):
260
+ values[k] = claims[k].strip()
261
+ return SourceValues(f"api:{url}", values)
262
+
263
+
264
+ def _manual_trust(args: Any) -> dict[str, Any]:
265
+ v: dict[str, Any] = {}
266
+ for f, flag in (("origin", args.origin), ("log_vkey", args.log_vkey), ("policy_kid", args.policy_kid),
267
+ ("owner_kid", args.owner_kid)):
268
+ if flag:
269
+ v[f] = flag
270
+ return v
271
+
272
+
273
+ def _verify_card_signature(card: dict[str, Any], policy_kid: str) -> list[cardmod.SigCheck]:
274
+ return cardmod.check_signatures(card, [parse_policy_key(policy_kid)])
275
+
276
+
277
+ def _refuse_unsupported_profile(card: dict[str, Any], what: str, **details: Any) -> None:
278
+ """AICS-1 §19: a client without a profile rejects a card of that profile; it never reads it as a base card."""
279
+ profile = cardmod.unsupported_profile(card)
280
+ if profile is not None:
281
+ raise AicsError(
282
+ f"unsupported profile: {what} is a {escape_untrusted(profile)} card, and this aics release verifies only "
283
+ f"{' and '.join(cardmod.SUPPORTED_PROFILES)} cards; AICS-1 §19 requires a client without the profile to "
284
+ "reject it rather than read it as a base card (use a verifier that implements it)",
285
+ profile=profile, supported_profiles=list(cardmod.SUPPORTED_PROFILES), **details)
286
+
287
+
288
+ def _bootstrap_card(url: str, trust: dict[str, Any], env: Env, home: Path, *, accept_signed: bool,
289
+ allow_weaker: bool, warnings: list[str]) -> tuple[dict[str, Any], str]:
290
+ """Fetch the card and check it under the trust values, with the same
291
+ saved-card rules as ``aics verify card``; returns (card, label)."""
292
+ try:
293
+ raw = cardmod.fetch_card(env.fetcher, url)
294
+ except FetchError as exc:
295
+ raise PolicyError(f"cannot fetch the card for the witness policy: {exc.message}; pass --card-url, or "
296
+ "--no-card with --witness/--quorum/--max-age from an independent source") from None
297
+ try:
298
+ card = cardmod.loads_strict(raw)
299
+ except (ValueError, UnicodeDecodeError) as exc:
300
+ raise PolicyError(f"the card at {url} is not strict JSON: {exc}") from None
301
+ if not isinstance(card, dict):
302
+ raise PolicyError(f"the card at {url} is not a JSON object")
303
+ checks = _verify_card_signature(card, trust["policy_kid"])
304
+ evidence = {"type": "aics-card-failure/v1", "url": url, "card": card,
305
+ "signatures": [c.report() for c in checks]}
306
+ if not any(c.status == "valid" for c in checks) or any(c.status == "invalid" for c in checks):
307
+ raise VerificationFailed("the card is not validly signed by the pinned policy key; nothing was pinned",
308
+ evidence=evidence)
309
+ _refuse_unsupported_profile(card, f"the card at {url}")
310
+ problems = cardmod.schema_problems(card)
311
+ if problems:
312
+ raise VerificationFailed("the card does not follow the AICS-1 §13.1 schema; nothing was pinned",
313
+ evidence={**evidence, "schema_problems": problems})
314
+ now = env.now()
315
+ if card["issued_at"] > now + cardmod.FUTURE_SKEW or now >= card["expires"]:
316
+ raise VerificationFailed("the card is not currently valid (issued_at/expires); nothing was pinned",
317
+ evidence={**evidence, "checked_at": now})
318
+ pseudo = Policy(origin=trust["origin"], log_vkey=trust["log_vkey"], policy_kid=trust.get("policy_kid"),
319
+ owner_kid=trust.get("owner_kid"))
320
+ mism = cardmod.anchor_mismatches(card, pseudo)
321
+ if mism:
322
+ raise VerificationFailed("the card's keys differ from the trust bundle; nothing was pinned",
323
+ evidence={**evidence, "mismatches": mism})
324
+ for p in cardmod.assurance_problems(card):
325
+ warnings.append(f"the card states an assurance outside the AICS-1 §13.3 bounds (aics verify card rejects "
326
+ f"the card for it): {p}")
327
+ h = cardmod.card_hash(card)
328
+ bootstrap = bool(trust.get("bootstrap_card_hash")) and h == trust["bootstrap_card_hash"]
329
+ if not bootstrap and not accept_signed:
330
+ raise PolicyError(
331
+ "the served card is signed by the pinned policy key but is not the bootstrap card the trust bundle pins "
332
+ f"(card hash {h}); proving a later card current needs the log's policy entries "
333
+ f"({UNAVAILABLE_MESSAGE}). Give --witness/--quorum/--max-age from an independent source, --no-card, "
334
+ "or --accept-signed-card",
335
+ card_hash=h, bootstrap_card_hash=trust.get("bootstrap_card_hash"),
336
+ )
337
+ hist = cardmod.compare_with_saved(cardmod.CardStore(home / "cards"), trust["policy_kid"], card)
338
+ problem = hist.problem()
339
+ if problem is not None and not (problem == "witness-downgrade" and allow_weaker):
340
+ hint = " (pass --allow-weaker to adopt it anyway)" if problem == "witness-downgrade" else ""
341
+ raise VerificationFailed(
342
+ f"{cardmod.HISTORY_MESSAGES[problem]}; nothing was pinned{hint}",
343
+ evidence={**evidence, "reason": problem, "card_hash": h,
344
+ "saved_card": hist.previous.card if hist.previous else None,
345
+ "saved": hist.previous.meta if hist.previous else None},
346
+ )
347
+ if problem == "witness-downgrade":
348
+ warnings.append("--allow-weaker: the card weakens the witness policy of the card saved earlier "
349
+ "for this policy key")
350
+ if bootstrap:
351
+ return card, f"card:bootstrap:{h}"
352
+ warnings.append("the served card is not the bootstrap card the bundle pins; its witness policy was taken "
353
+ "on its signature alone (--accept-signed-card): its currency is unproven")
354
+ return card, f"card-signature-only:{url}"
355
+
356
+
357
+ def _card_urls(card: dict[str, Any], origin: str) -> dict[str, Any]:
358
+ prefixes = [p if p.endswith("/") else p + "/" for p in card["tile_prefixes"]]
359
+ own = f"https://{origin}/"
360
+ main = own if own in prefixes else prefixes[0]
361
+ out = prefix_urls(main, card["api_base"])
362
+ out["mirrors"] = [p for p in prefixes if p != main]
363
+ return out
364
+
365
+
366
+ def _source_kinds(labels: list[str]) -> list[str]:
367
+ kinds: list[str] = []
368
+ for s in labels:
369
+ if not is_out_of_band(s):
370
+ continue
371
+ k = s.split(":", 1)[0]
372
+ k = "dns" if k == "dns-log-vkey" else k
373
+ if k not in kinds:
374
+ kinds.append(k)
375
+ return kinds
376
+
377
+
378
+ def _trust_root(policy: Policy, pypi_status: str) -> str:
379
+ """Where the pinned log key comes from, in a few words."""
380
+ kinds = _source_kinds(policy.key_sources("log_vkey"))
381
+ if pypi_status == "agrees" and "pypi" not in kinds:
382
+ kinds.append("pypi")
383
+ if kinds == ["dns"]:
384
+ return "dns only, unauthenticated"
385
+ if len(kinds) == 1:
386
+ return f"{kinds[0]} only"
387
+ return " + ".join(kinds) if kinds else "none recorded"
388
+
389
+
390
+ def _existing_policy(home: Path, warnings: list[str]) -> tuple[Policy | None, str | None, str | None]:
391
+ """The policy being replaced (if any): (policy, origin, log_vkey)."""
392
+ p = policy_path(home)
393
+ if not p.exists():
394
+ return None, None, None
395
+ try:
396
+ old = load(home)
397
+ return old, old.origin, old.log_vkey
398
+ except PolicyError as exc:
399
+ warnings.append(f"the existing policy could not be loaded ({exc.message}); it is archived, and only its "
400
+ "origin and log key are compared")
401
+ try:
402
+ data = tomllib.loads(p.read_text("utf-8"))
403
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
404
+ return None, None, None
405
+ origin = data.get("origin") if isinstance(data.get("origin"), str) else None
406
+ vkey = data.get("log_vkey") if isinstance(data.get("log_vkey"), str) else None
407
+ try:
408
+ vkey = format_vkey(parse_log_vkey(vkey)) if vkey else None
409
+ except PolicyError:
410
+ vkey = None
411
+ return None, origin, vkey
412
+
413
+
414
+ def _repin_checks(old: Policy | None, old_origin: str | None, old_log_vkey: str | None, new: Policy, home: Path,
415
+ args: Any, env: Env, warnings: list[str]) -> dict[str, Any]:
416
+ """What re-pinning may not do silently (AICS-1 §6.3, §6.4): adopt a
417
+ weaker witness policy, or orphan the saved checkpoint history."""
418
+ out: dict[str, Any] = {}
419
+ if old is not None:
420
+ before, after = cardmod.policy_terms(old), cardmod.policy_terms(new)
421
+ cmp = cardmod.compare_witness(before, after)
422
+ out["witness_policy_vs_previous"] = cmp
423
+ if cmp == "weaker":
424
+ if not args.allow_weaker:
425
+ raise PolicyError(
426
+ "refusing to replace the pinned witness policy with a weaker one: the new terms accept "
427
+ "checkpoints the pinned ones reject. Witness-policy changes need a logged card under the "
428
+ "previously trusted policy, and a client must not bootstrap a weaker quorum (AICS-1 §6.3); "
429
+ "nothing was pinned. Pass --allow-weaker to adopt it anyway",
430
+ previous=cardmod.terms_dict(before), new=cardmod.terms_dict(after),
431
+ )
432
+ warnings.append("--allow-weaker: the new witness policy is weaker than the one it replaces "
433
+ f"(was quorum {old.quorum} of {len(old.witness_vkeys)}, max age "
434
+ f"{old.max_checkpoint_age or 'unset'})")
435
+ store = CheckpointStore(home / "checkpoints")
436
+ reset = bool(args.reset_history)
437
+ if old_origin is None:
438
+ if reset:
439
+ warnings.append("--reset-history ignored: no policy was pinned before")
440
+ return out
441
+ old_notes = store.notes(old_origin)
442
+ if not old_notes:
443
+ if reset:
444
+ warnings.append(f"--reset-history ignored: no checkpoint is saved for {old_origin}")
445
+ return out
446
+ files = [n.file for n in old_notes]
447
+ if old_origin != new.origin:
448
+ if not reset:
449
+ raise PolicyError(
450
+ f"the new trust material names the origin {new.origin!r}, but {len(old_notes)} checkpoint(s) are "
451
+ f"saved for the pinned origin {old_origin!r}; re-pinning would silently orphan that history. Pass "
452
+ "--reset-history to start a new history (recorded in the checkpoint index; no file is deleted); "
453
+ "nothing was pinned",
454
+ saved=len(old_notes), previous_origin=old_origin,
455
+ )
456
+ store.reset(old_origin, now=env.now(), files=files, policy=old,
457
+ reason=f"aics policy init --reset-history: the origin changed to {new.origin}")
458
+ warnings.append(f"--reset-history: {len(files)} saved checkpoint(s) of {old_origin} no longer take part in "
459
+ f"continuity checks (files kept, reset recorded in {store.index})")
460
+ out["history_reset"] = {"origin": old_origin, "dropped": len(files)}
461
+ elif reset:
462
+ store.reset(old_origin, now=env.now(), files=files, policy=old,
463
+ reason="aics policy init --reset-history")
464
+ warnings.append(f"--reset-history: {len(files)} saved checkpoint(s) of {old_origin} no longer take part in "
465
+ f"continuity checks (files kept, reset recorded in {store.index})")
466
+ out["history_reset"] = {"origin": old_origin, "dropped": len(files)}
467
+ elif old_log_vkey is not None and old_log_vkey != new.log_vkey:
468
+ warnings.append(
469
+ f"the log key changes from {old_log_vkey} to {new.log_vkey}: the {len(old_notes)} saved checkpoint(s) "
470
+ "stay verified under the key they were saved under, and verify checkpoint / verify consistency need a "
471
+ "consistency proof from them to the new log's head (AICS-1 §6.4: replacing a key does not reset history)"
472
+ )
473
+ out["log_key_change"] = {"from": old_log_vkey, "to": new.log_vkey, "saved": len(old_notes)}
474
+ return out
475
+
476
+
477
+ def cmd_policy_init(args: Any, env: Env) -> Result:
478
+ home = _home_dir(args, env)
479
+ warnings: list[str] = []
480
+ existing = policy_path(home)
481
+ if existing.exists() and not args.force:
482
+ raise PolicyError(f"{existing} exists; pass --force to replace it (the old one is archived)")
483
+ old, old_origin, old_log_vkey = _existing_policy(home, warnings)
484
+ oob: list[SourceValues] = []
485
+ bundles: list[TrustBundle] = []
486
+ dns_info: dict[str, Any] | None = None
487
+ domain = (args.from_dns or args.domain or "").strip().rstrip(".").lower() or None
488
+ if args.from_dns:
489
+ srcs, b, dns_info = _dns_trust(domain, env, args.nameserver)
490
+ oob += srcs
491
+ if b is not None:
492
+ bundles.append(b)
493
+ if not dns_info["dnssec_ad"]:
494
+ warnings.append(f"the DNS answer for {dns_info['name']} was not DNSSEC-authenticated (AD bit unset): "
495
+ "DNS alone is not a secure root (AICS-1 §6.4)")
496
+ if args.bundle:
497
+ b = bundle_from_text(args.bundle)
498
+ bundles.append(b)
499
+ oob.append(SourceValues("readme", b.values()))
500
+ manual = _manual_trust(args)
501
+ if manual:
502
+ oob.append(SourceValues("manual", manual))
503
+ origin_hint = next((str(s.values["origin"]) for s in oob if s.values.get("origin")), None)
504
+ pub, pub_status = published_bundle(domain, origin_hint)
505
+ if pub is not None:
506
+ bundles.append(pub)
507
+ oob.append(SourceValues(f"pypi:aics-cli=={__version__}", pub.values()))
508
+ else:
509
+ warnings.append(f"the aics-cli package's AICS_TRUST_BUNDLE constant: {pub_status}; nothing to cross-check")
510
+ api = _api_claims(args.from_api, env) if args.from_api else None
511
+
512
+ if not oob:
513
+ if api is not None:
514
+ raise PolicyError(
515
+ "refusing to pin a key obtained from the API host alone: the server under scrutiny cannot vouch "
516
+ "for its own key (AICS-1 §6.4); use --from-dns DOMAIN or --bundle from the public README",
517
+ api_claims=api.values,
518
+ )
519
+ raise UsageError("give --from-dns DOMAIN, --bundle (the README block), or the keys by hand")
520
+
521
+ rows = compare_sources(oob + ([api] if api is not None else []))
522
+ bad = malformed(rows, {s.label for s in oob})
523
+ if bad:
524
+ raise PolicyError("malformed key material; nothing was pinned: " + "; ".join(bad))
525
+ if api is not None:
526
+ for r in rows:
527
+ if api.label in r["errors"]:
528
+ warnings.append(f"ignored a malformed {r['field']} from the API host")
529
+ api.values.pop(r["field"], None)
530
+ disagreements = [r for r in rows if not r["agree"]]
531
+ if disagreements:
532
+ raise VerificationFailed(
533
+ "out-of-band sources disagree; bootstrap stopped and nothing was pinned (AICS-1 §6.4)",
534
+ evidence={"type": "aics-key-source-disagreement/v1", "fields": disagreements, "dns": dns_info},
535
+ )
536
+
537
+ trust: dict[str, Any] = {}
538
+ sources: dict[str, list[str]] = {}
539
+ for f in TRUST_FIELDS:
540
+ for s in oob:
541
+ v = s.values.get(f)
542
+ if v is None:
543
+ continue
544
+ trust.setdefault(f, v)
545
+ sources.setdefault(f, []).append(s.label)
546
+ if f in trust and api is not None and api.values.get(f) is not None:
547
+ sources[f].append(api.label) # confirms; never counts as a source
548
+ for f in ("origin", "log_vkey"):
549
+ if f not in trust:
550
+ if api is not None and api.values.get(f) is not None:
551
+ raise PolicyError(f"refusing to pin {f} from the API host alone; no out-of-band source gives it",
552
+ api_claims=api.values)
553
+ raise PolicyError(f"no out-of-band source gives {f}")
554
+ kinds = _source_kinds(sources["log_vkey"])
555
+ required = args.require_sources if args.require_sources is not None else DEFAULT_REQUIRED_SOURCES
556
+ if len(kinds) < required:
557
+ if args.require_sources is None:
558
+ raise PolicyError(
559
+ f"only {len(kinds)} out-of-band source ({', '.join(kinds)}) gives the log key, and "
560
+ f"{DEFAULT_REQUIRED_SOURCES} independent kinds are required by default"
561
+ + (": DNS that this tool cannot DNSSEC-validate is not a secure root by itself (AICS-1 §6.4)"
562
+ if kinds == ["dns"] else "")
563
+ + ". Add another source (--bundle with the public README block, or the aics package's constant once "
564
+ "it is published), or pass --require-sources 1 to pin from a single source knowingly; nothing was "
565
+ "pinned",
566
+ sources=sources["log_vkey"],
567
+ )
568
+ raise PolicyError(
569
+ f"only {len(kinds)} out-of-band source(s) give the log key; --require-sources is {required}",
570
+ sources=sources["log_vkey"],
571
+ )
572
+ if len(kinds) == 1:
573
+ warnings.append(f"the log key rests on one kind of source ({kinds[0]}), accepted with --require-sources 1")
574
+
575
+ bundle_card = trust.get("bootstrap_card_hash")
576
+ urls = default_urls(domain, str(trust["origin"]))
577
+ witness: dict[str, Any] = {"witness_vkeys": [], "quorum": 0, "max_checkpoint_age": None, "witness_meta": {}}
578
+ card_label = None
579
+ card_obj = None
580
+ card_url = args.card_url or urls["card_url"]
581
+ if args.no_card:
582
+ if bundle_card and args.quorum is None:
583
+ raise UsageError(
584
+ "the trust bundle pins a bootstrap card, and the card carries the witness policy; with --no-card give "
585
+ "the witness policy yourself from an independent source: --witness/--quorum/--max-age (--quorum 0 "
586
+ "accepts checkpoints without witnesses)"
587
+ )
588
+ elif not trust.get("policy_kid"):
589
+ warnings.append("no policy key: the witness policy was not read from a card")
590
+ elif not card_url:
591
+ if bundle_card:
592
+ raise PolicyError(
593
+ "the trust bundle pins a bootstrap card, but no card URL could be formed (no domain, and the origin "
594
+ "is not a host name): pass --card-url URL or --domain DOMAIN, or --no-card with "
595
+ "--witness/--quorum/--max-age; nothing was pinned"
596
+ )
597
+ warnings.append("no card URL: the witness policy was not read from a card")
598
+ else:
599
+ card_obj, card_label = _bootstrap_card(card_url, trust, env, home, accept_signed=args.accept_signed_card,
600
+ allow_weaker=args.allow_weaker, warnings=warnings)
601
+ wp = card_obj["witness_policy"]
602
+ witness = {
603
+ "witness_vkeys": [format_vkey(parse_witness_vkey(w["vkey"])) for w in wp["witnesses"]],
604
+ "quorum": wp["quorum"],
605
+ "max_checkpoint_age": card_obj["max_checkpoint_age"],
606
+ # the operator's own declarations, kept as such (AICS-1 §6.3)
607
+ "witness_meta": {w["name"]: {"declared_trust_domain": w["trust_domain"],
608
+ "declared_independent": w["independent"]}
609
+ for w in wp["witnesses"]},
610
+ }
611
+ urls.update(_card_urls(card_obj, str(trust["origin"])))
612
+ for f in ("witness_vkeys", "quorum", "max_checkpoint_age"):
613
+ sources[f] = [card_label]
614
+ card_terms = (witness["quorum"], frozenset(witness["witness_vkeys"]),
615
+ witness["max_checkpoint_age"] or cardmod.UINT_MAX)
616
+ if args.witness is not None:
617
+ witness["witness_vkeys"] = [format_vkey(parse_witness_vkey(w)) for w in args.witness]
618
+ witness["witness_meta"] = {k: v for k, v in witness["witness_meta"].items()
619
+ if any(w.startswith(k + "+") for w in witness["witness_vkeys"])}
620
+ sources["witness_vkeys"] = ["manual"]
621
+ if args.quorum is not None:
622
+ witness["quorum"] = args.quorum
623
+ sources["quorum"] = ["manual"]
624
+ if args.max_age is not None:
625
+ witness["max_checkpoint_age"] = args.max_age
626
+ sources["max_checkpoint_age"] = ["manual"]
627
+ if args.independent:
628
+ names = {parse_witness_vkey(w).name for w in witness["witness_vkeys"]}
629
+ for name in args.independent:
630
+ if name not in names:
631
+ raise UsageError(f"--independent {name!r} is not one of the pinned witnesses "
632
+ f"({', '.join(sorted(names)) or 'none'})")
633
+ witness["witness_meta"].setdefault(name, {}).update(independent=True, independent_source="manual")
634
+ sources["witness_independence"] = ["manual"]
635
+ if card_obj is not None:
636
+ mine = (witness["quorum"], frozenset(witness["witness_vkeys"]),
637
+ witness["max_checkpoint_age"] or cardmod.UINT_MAX)
638
+ if not cardmod.at_least_as_strict(mine, card_terms):
639
+ warnings.append("the witness terms given by hand are weaker than the card's")
640
+ for f, flag in (("checkpoint_url", args.checkpoint_url), ("tiles_url", args.tiles_url),
641
+ ("consistency_url", args.consistency_url)):
642
+ if flag is not None:
643
+ urls[f] = flag
644
+ if args.mirror is not None:
645
+ urls["mirrors"] = list(args.mirror)
646
+
647
+ full_bundle = bundles[0] if bundles else None
648
+ policy = Policy(
649
+ origin=str(trust["origin"]),
650
+ log_vkey=format_vkey(parse_log_vkey(str(trust["log_vkey"]))),
651
+ policy_kid=parse_policy_key(trust["policy_kid"]).did if trust.get("policy_kid") else None,
652
+ owner_kid=parse_policy_key(trust["owner_kid"]).did if trust.get("owner_kid") else None,
653
+ bootstrap_card_hash=trust.get("bootstrap_card_hash"),
654
+ trust_bundle_sha256=full_bundle.sha256 if full_bundle else None,
655
+ witness_vkeys=witness["witness_vkeys"],
656
+ quorum=int(witness["quorum"]),
657
+ max_checkpoint_age=witness["max_checkpoint_age"],
658
+ witness_meta=witness["witness_meta"],
659
+ domain=domain,
660
+ checkpoint_url=urls["checkpoint_url"],
661
+ tiles_url=urls["tiles_url"],
662
+ card_url=card_url or "",
663
+ consistency_url=urls["consistency_url"],
664
+ mirrors=list(urls["mirrors"]),
665
+ sources=sources,
666
+ created_at=_iso(env.now()) or "",
667
+ )
668
+ policy.validate()
669
+ if not policy.policy_kid:
670
+ warnings.append("no policy key pinned: aics verify card cannot run until one is pinned")
671
+ if policy.quorum == 0:
672
+ warnings.append("witness quorum 0: checkpoint freshness is unproven and single view (G5) is promised")
673
+ repin = _repin_checks(old, old_origin, old_log_vkey, policy, home, args, env, warnings)
674
+ path = save(policy, home, force=args.force)
675
+ if card_obj is not None:
676
+ cardmod.CardStore(home / "cards").save(policy.policy_kid, card_url, card_obj, now=env.now(),
677
+ status="bootstrap" if card_label.startswith("card:") else
678
+ "signature-only")
679
+ trust_root = _trust_root(policy, "agrees" if pub is not None else pub_status)
680
+ data = {"policy": policy.summary(), "path": str(path), "dns": dns_info, "trust_root": trust_root,
681
+ "repin": repin, "warnings": warnings}
682
+ text = [f"pinned policy written to {path}",
683
+ f"origin {policy.origin}",
684
+ f"log key {policy.log_vkey} [{', '.join(sources['log_vkey'])}]",
685
+ f"trust root {trust_root}",
686
+ f"policy {policy.policy_kid or '(none)'}",
687
+ f"witnesses {len(policy.witness_vkeys)}, quorum {policy.quorum}, "
688
+ f"max checkpoint age {policy.max_checkpoint_age or 'unset'}"
689
+ + (f" [{', '.join(sources.get('quorum', []))}]" if sources.get("quorum") else "")]
690
+ text += [f"warning: {w}" for w in warnings]
691
+ return _ok(data, text)
692
+
693
+
694
+ def _bundle_of(policy: Policy) -> TrustBundle | None:
695
+ if all(getattr(policy, f) for f in TRUST_FIELDS):
696
+ return TrustBundle(origin=policy.origin, log_vkey=policy.log_vkey, policy_kid=policy.policy_kid,
697
+ owner_kid=policy.owner_kid, bootstrap_card_hash=policy.bootstrap_card_hash)
698
+ return None
699
+
700
+
701
+ def cmd_policy_show(args: Any, env: Env) -> Result:
702
+ policy = load(_home_dir(args, env))
703
+ status, disagree = _pypi_check(policy)
704
+ b = _bundle_of(policy)
705
+ data = {"policy": policy.summary(), "trust_bundle": b.to_dict() if b else None,
706
+ "trust_bundle_sha256": b.sha256 if b else None, "txt_record": b.txt if b else None,
707
+ "pypi_constant": status, "trust_root": _trust_root(policy, status)}
708
+ if status == "disagrees":
709
+ raise VerificationFailed(
710
+ "the pinned policy contradicts the trust bundle published in this aics package (run: aics policy diff)",
711
+ evidence={"type": "aics-key-source-disagreement/v1", "fields": disagree}, **data)
712
+ text = [f"{k} = {json.dumps(v)}" for k, v in policy.summary().items()]
713
+ text.append(f"trust root: {data['trust_root']}; pypi constant: {status}")
714
+ if b:
715
+ text.append(f"trust bundle (TXT _aics.<domain>): {b.txt}")
716
+ text.append(f"trust bundle sha256: {b.sha256}")
717
+ return _ok(data, text)
718
+
719
+
720
+ def cmd_policy_diff(args: Any, env: Env) -> Result:
721
+ home = _home_dir(args, env)
722
+ try:
723
+ policy: Policy | None = load(home)
724
+ except PolicyError as exc:
725
+ if "no pinned policy" not in exc.message:
726
+ raise
727
+ policy = None
728
+ domain = (args.domain or (policy.domain if policy else None) or "").strip().rstrip(".").lower()
729
+ if not domain:
730
+ raise UsageError("no domain: pass --domain or pin a policy with policy init --from-dns")
731
+ sources: list[SourceValues] = []
732
+ dns_error = None
733
+ dns_info = None
734
+ try:
735
+ srcs, _b, dns_info = _dns_trust(domain, env, args.nameserver)
736
+ sources += [SourceValues("dns", s.values) for s in srcs]
737
+ except (DnsError, PolicyError) as exc:
738
+ dns_error = exc.message
739
+ try:
740
+ pub, pub_status = published_bundle(domain, policy.origin if policy else None)
741
+ except PolicyError as exc:
742
+ pub, pub_status = None, f"invalid: {exc.message}"
743
+ sources.append(SourceValues("pypi", pub.values() if pub else {}))
744
+ if policy is not None:
745
+ sources.append(SourceValues("policy", policy.trust_values()))
746
+ rows = compare_sources(sources)
747
+ for r in rows:
748
+ r["missing"] = [s.label for s in sources if s.values.get(r["field"]) is None]
749
+ disagree = [r for r in rows if not r["agree"]]
750
+ bad = malformed(rows)
751
+ data = {
752
+ "domain": domain,
753
+ "rows": rows,
754
+ "malformed": bad,
755
+ "dns": dns_info,
756
+ "dns_error": dns_error,
757
+ "pypi": pub_status,
758
+ "policy_path": str(policy.path) if policy else None,
759
+ }
760
+ text = [f"{'FIELD':<20} {'AGREE':<6} SOURCES"]
761
+ for r in rows:
762
+ vals = "; ".join(f"{k}={escape_untrusted(v)}" for k, v in r["values"].items()) or "(nobody publishes it)"
763
+ miss = f" [not published: {', '.join(r['missing'])}]" if r["missing"] else ""
764
+ text.append(f"{r['field']:<20} {'yes' if r['agree'] else 'NO':<6} {vals}{miss}")
765
+ text.append(f"pypi constant: {pub_status}")
766
+ if disagree:
767
+ raise VerificationFailed(
768
+ f"sources disagree on: {', '.join(r['field'] for r in disagree)}",
769
+ evidence={"type": "aics-key-source-disagreement/v1", "fields": disagree, "dns": dns_info},
770
+ **data,
771
+ )
772
+ if dns_error:
773
+ raise AicsError(f"DNS could not be checked: {dns_error}", **data)
774
+ if bad:
775
+ raise AicsError("malformed key material: " + "; ".join(bad), **data)
776
+ return _ok(data, text)
777
+
778
+
779
+ # ---------------------------------------------------------------------------
780
+ # the pinned policy, as every verifying command loads it
781
+ # ---------------------------------------------------------------------------
782
+
783
+
784
+ def _pypi_check(policy: Policy) -> tuple[str, list[dict[str, Any]]]:
785
+ try:
786
+ pub, status = published_bundle(policy.domain, policy.origin)
787
+ except PolicyError as exc:
788
+ return f"invalid: {exc.message}", []
789
+ if pub is None:
790
+ return status, []
791
+ rows = compare_sources([SourceValues("pypi", pub.values()), SourceValues("policy", policy.trust_values())])
792
+ bad = [r for r in rows if not r["agree"]]
793
+ return ("disagrees" if bad else "agrees"), bad
794
+
795
+
796
+ def _load_policy(home: Path) -> tuple[Policy, str]:
797
+ """Load the pinned policy and hold it against the trust bundle shipped in
798
+ this package: a policy the package contradicts is not used by any
799
+ command (exit 2, the disagreement is the evidence)."""
800
+ policy = load(home)
801
+ status, bad = _pypi_check(policy)
802
+ if status == "disagrees":
803
+ raise VerificationFailed(
804
+ "the pinned policy contradicts the trust bundle published in this aics package; nothing was checked "
805
+ "(compare the sources with: aics policy diff)",
806
+ evidence={"type": "aics-key-source-disagreement/v1", "fields": bad},
807
+ )
808
+ return policy, status
809
+
810
+
811
+ def _key_sources(policy: Policy, pypi_status: str) -> dict[str, Any]:
812
+ return {
813
+ "recorded": {f: policy.key_sources(f) for f in ("origin", "log_vkey", "policy_kid", "witness_vkeys", "quorum")
814
+ if f in policy.sources or f in ("origin", "log_vkey")},
815
+ "pypi_constant": pypi_status,
816
+ "trust_root": _trust_root(policy, pypi_status),
817
+ "dns": "compare with: aics policy diff",
818
+ }
819
+
820
+
821
+ # ---------------------------------------------------------------------------
822
+ # checkpoints
823
+ # ---------------------------------------------------------------------------
824
+
825
+
826
+ def _failure(chk: NoteCheck, policy: Policy, note_text: str, source: str) -> VerificationFailed:
827
+ return VerificationFailed(
828
+ f"checkpoint from {source}: {chk.error}",
829
+ evidence={
830
+ "type": "aics-checkpoint-failure/v1",
831
+ "reason": chk.reason,
832
+ "source": source,
833
+ "note": note_text,
834
+ "pinned": {"origin": policy.origin, "log_vkey": policy.log_vkey,
835
+ "witness_vkeys": policy.witness_vkeys, "quorum": policy.quorum,
836
+ "max_checkpoint_age": policy.max_checkpoint_age},
837
+ "witnesses": chk.witnesses,
838
+ },
839
+ checkpoint=chk.report(),
840
+ )
841
+
842
+
843
+ def _read_text_arg(arg: str, env: Env) -> tuple[str, str]:
844
+ """A note given as a file path, '-' (stdin) or an https URL."""
845
+ if arg == "-":
846
+ stream = env.stdin or sys.stdin
847
+ return stream.read(), "stdin"
848
+ if arg.startswith("https://") or arg.startswith("http://"):
849
+ return fetch_checkpoint(env.fetcher, arg).note_text, arg
850
+ try:
851
+ return Path(arg).expanduser().read_text("utf-8"), arg
852
+ except (OSError, UnicodeDecodeError) as exc:
853
+ raise AicsError(f"cannot read {arg}: {exc}") from None
854
+
855
+
856
+ def _check_or_raise(text: str, policy: Policy, env: Env, source: str, *, origin: bool,
857
+ max_skew: int = DEFAULT_MAX_SKEW, require_fresh: bool = True) -> NoteCheck:
858
+ """Accept the note as a current head, or raise with the right exit code:
859
+ 2 only when the output is evidence (anything the log origin itself
860
+ served, or a log-signed checkpoint no log can produce), 1 for a stale or
861
+ under-witnessed head and for an invalid note from any other source."""
862
+ chk = check_note(text, policy, source=source, now=env.now(), max_skew=max_skew, require_fresh=require_fresh)
863
+ if chk.ok and chk.cp is not None:
864
+ return chk
865
+ if chk.reason == "malformed":
866
+ raise AicsError(f"{source}: {chk.error}")
867
+ if chk.reason == "quorum":
868
+ raise AicsError(
869
+ f"stale: the checkpoint from {source} is log-signed, but {chk.error}. A stale or under-witnessed head "
870
+ "is not accepted as current; it is not evidence against the log",
871
+ result="stale", reason=chk.reason, source=source, note=text, witnesses=chk.witnesses,
872
+ checkpoint=chk.report(),
873
+ )
874
+ if origin or chk.reason in SELF_CONTAINED_EVIDENCE:
875
+ raise _failure(chk, policy, text, source)
876
+ raise AicsError(
877
+ f"invalid: the checkpoint from {source} does not verify ({chk.error}). It did not come from the log origin, "
878
+ "and anyone on that path can produce such a note, so it is not evidence against the log",
879
+ result="invalid", reason=chk.reason, source=source, note=text, witnesses=chk.witnesses,
880
+ checkpoint=chk.report(),
881
+ )
882
+
883
+
884
+ def _current(policy: Policy, env: Env, url: str, *, max_skew: int, warnings: list[str]) -> tuple[Cp, NoteCheck]:
885
+ fetched = fetch_checkpoint(env.fetcher, url)
886
+ warnings += api_key_warnings(fetched, policy)
887
+ chk = _check_or_raise(fetched.note_text, policy, env, url, origin=url == policy.checkpoint_url,
888
+ max_skew=max_skew)
889
+ warnings += chk.warnings
890
+ return chk.cp, chk
891
+
892
+
893
+ def _endpoint(policy: Policy, env: Env) -> list[Any]:
894
+ if not policy.consistency_url:
895
+ return []
896
+ return [EndpointSource(f"consistency endpoint {policy.consistency_url}", policy.consistency_url, env.fetcher)]
897
+
898
+
899
+ def _proof_sources(policy: Policy, env: Env, ref: Cp | None, readers: dict[str, HttpTileReader]) -> list[Any]:
900
+ """Tiles first (authenticated, and cached across many saved checkpoints),
901
+ then mirrors, then the proof endpoint."""
902
+ srcs: list[Any] = []
903
+ bases = ([policy.tiles_url] if policy.tiles_url else []) + list(policy.mirrors)
904
+ for base in bases:
905
+ reader = readers.setdefault(base, HttpTileReader(base, env.fetcher))
906
+ srcs.append(TileSource(f"tiles {base}", reader, ref=ref))
907
+ return srcs + _endpoint(policy, env)
908
+
909
+
910
+ def _relate_current(old: Cp, current: Cp, sources: list[Any]) -> Relation:
911
+ """``old`` (saved) against ``current``. A current tree smaller than a
912
+ saved one is never adopted (AICS-1 §14.1 step 4, §14.2 step 1): the
913
+ histories are compared, a proven fork is ``split-proven``, a consistent
914
+ older head is ``historical`` and anything else is ``unproven``."""
915
+ if old.size <= current.size:
916
+ return relate(old, current, sources)
917
+ historical = [TileSource(s.label, s.reader, ref=None) if isinstance(s, TileSource) else s for s in sources]
918
+ hist = relate(current, old, historical)
919
+ if hist.status == "split-proven":
920
+ return hist
921
+ attempts = [Attempt("history", hist.status, "the smaller current head against the larger saved one",
922
+ current, old)] + hist.attempts
923
+ if hist.status == "consistent":
924
+ return Relation(status="historical", a=current, b=old, reason="older-consistent-head", attempts=attempts,
925
+ winning=hist.winning)
926
+ return Relation(status="unproven", a=current, b=old, reason="older-head-unproven", attempts=attempts)
927
+
928
+
929
+ def _split_words(rel: Relation, current: Cp) -> str:
930
+ if rel.reason == "bad-empty-root":
931
+ return "a log-signed size-0 checkpoint carries a root that is not SHA-256(\"\")"
932
+ if current.size < max(rel.a.size, rel.b.size):
933
+ return "the log now serves a smaller tree that is not a prefix of the saved one: it rewrote its history"
934
+ return "the log forked or rewrote its history"
935
+
936
+
937
+ def _saved_keys(saved: SavedNote, policy: Policy, archived: dict[str, tuple[str, str]]) -> list[str]:
938
+ """Log keys other than the pinned one that ``saved`` was verified with
939
+ when it was saved: recorded in the index, or in the archived policy
940
+ whose digest the index recorded."""
941
+ keys = list(saved.recorded_log_vkeys())
942
+ for digest in saved.policy_digests():
943
+ hit = archived.get(digest)
944
+ if hit and hit[0] == policy.origin and hit[1] not in keys:
945
+ keys.append(hit[1])
946
+ return [k for k in keys if k != policy.log_vkey]
947
+
948
+
949
+ def _verify_saved(saved: SavedNote, policy: Policy, env: Env, home: Path,
950
+ cache: dict[str, Any]) -> tuple[Cp | None, str | None]:
951
+ """Authenticate a saved note by its log signature: under the pinned key,
952
+ or under the key it was saved under (a log-key replacement does not
953
+ reset history, AICS-1 §6.4). Witness lines play no part: continuity is
954
+ about what the log signed."""
955
+ chk = check_note(saved.text, policy, source=str(saved.path), now=env.now(), require_fresh=False)
956
+ if chk.cp is not None:
957
+ return chk.cp, None
958
+ if chk.reason == "malformed":
959
+ return None, chk.error
960
+ if "archived" not in cache:
961
+ cache["archived"] = archived_log_keys(home)
962
+ for vkey in _saved_keys(saved, policy, cache["archived"]):
963
+ try:
964
+ cp, _err, _reason = log_signed(saved.text, origin=policy.origin, log_vkey=vkey, source=str(saved.path))
965
+ except PolicyError: # a malformed recorded key
966
+ continue
967
+ if cp is not None:
968
+ return cp, None
969
+ return None, chk.error
970
+
971
+
972
+ def _unverifiable(store: CheckpointStore, policy: Policy, env: Env, home: Path,
973
+ cache: dict[str, Any]) -> tuple[list[Cp], list[dict[str, Any]]]:
974
+ good: list[Cp] = []
975
+ bad: list[dict[str, Any]] = []
976
+ for saved in store.notes(policy.origin):
977
+ cp, err = _verify_saved(saved, policy, env, home, cache)
978
+ if cp is None:
979
+ bad.append({"file": saved.file, "path": str(saved.path), "reason": err})
980
+ else:
981
+ good.append(cp)
982
+ return good, bad
983
+
984
+
985
+ def _reset_reason(command: str, bad: list[dict[str, Any]]) -> str:
986
+ return (f"{command} --reset-history: unverifiable saved checkpoint(s): "
987
+ + "; ".join(f"{b['file']}: {b['reason']}" for b in bad))
988
+
989
+
990
+ def _head(store: CheckpointStore, policy: Policy, env: Env, home: Path, warnings: list[str], *,
991
+ reset_history: bool = False, command: str = "aics") -> tuple[Cp | None, dict[str, Any] | None]:
992
+ """The saved head, authenticated by its log signature (under the pinned
993
+ key or the key it was saved under). A saved head that cannot be
994
+ authenticated is never silently replaced: that is an error unless the
995
+ user passes --reset-history, and the reset is recorded. Returns
996
+ (head, reset record)."""
997
+ saved = store.head(policy.origin)
998
+ if saved is None:
999
+ return None, None
1000
+ cache: dict[str, Any] = {}
1001
+ cp, err = _verify_saved(saved, policy, env, home, cache)
1002
+ if cp is not None:
1003
+ if cp.log_vkey and cp.log_vkey != policy.log_vkey:
1004
+ warnings.append(f"the saved head {saved.path.name} is signed by the log key pinned when it was saved "
1005
+ f"({cp.log_vkey}); continuity across the key change needs a consistency proof from it")
1006
+ return cp, None
1007
+ if not reset_history:
1008
+ raise AicsError(
1009
+ f"the saved head {saved.path} cannot be authenticated with the pinned log key or with the key it was "
1010
+ f"saved under: {err}. Continuity cannot be checked, so nothing was accepted. Inspect the file; to drop "
1011
+ f"the unverifiable saved checkpoints of {policy.origin} from continuity checks, rerun with "
1012
+ f"--reset-history (recorded in {store.index}; no file is deleted)",
1013
+ saved_head=str(saved.path), reason=err,
1014
+ )
1015
+ _good, bad = _unverifiable(store, policy, env, home, cache)
1016
+ files = sorted({b["file"] for b in bad} | {saved.file})
1017
+ store.reset(policy.origin, now=env.now(), files=files, policy=policy,
1018
+ reason=_reset_reason(command, bad or [{"file": saved.file, "reason": err}]))
1019
+ warnings.append(f"--reset-history: {len(files)} unverifiable saved checkpoint(s) no longer take part in "
1020
+ f"continuity checks (files kept, reset recorded in {store.index})")
1021
+ head, _ = _head(store, policy, env, home, warnings, reset_history=False, command=command)
1022
+ return head, {"dropped": files, "reasons": bad}
1023
+
1024
+
1025
+ def cmd_verify_checkpoint(args: Any, env: Env) -> Result:
1026
+ home = _home_dir(args, env)
1027
+ policy, pypi_status = _load_policy(home)
1028
+ trust_root = _trust_root(policy, pypi_status)
1029
+ warnings: list[str] = []
1030
+ store = CheckpointStore(home / "checkpoints")
1031
+ reset_history = bool(getattr(args, "reset_history", False))
1032
+ history_reset = None
1033
+ continuity: dict[str, Any]
1034
+ if args.note:
1035
+ text, source = _read_text_arg(args.note, env)
1036
+ chk = _check_or_raise(text, policy, env, source, origin=False, max_skew=args.max_skew)
1037
+ warnings += chk.warnings
1038
+ cp = chk.cp
1039
+ continuity = {"status": "not checked (local note)"}
1040
+ saved = None
1041
+ else:
1042
+ url = args.url or policy.checkpoint_url
1043
+ if not url:
1044
+ raise PolicyError("no checkpoint URL: pass --url or pin checkpoint_url")
1045
+ cp, chk = _current(policy, env, url, max_skew=args.max_skew, warnings=warnings)
1046
+ head, history_reset = _head(store, policy, env, home, warnings, reset_history=reset_history,
1047
+ command="aics verify checkpoint")
1048
+ if reset_history and history_reset is None:
1049
+ warnings.append("--reset-history ignored: " + ("no checkpoint is saved for this origin" if head is None
1050
+ else "the saved head verifies"))
1051
+ saved = None
1052
+ if head is None or head.sha256 == cp.sha256:
1053
+ if head is not None:
1054
+ status = "same as the saved head"
1055
+ else:
1056
+ status = "first (history reset)" if history_reset else "first"
1057
+ continuity = {"status": status}
1058
+ role = "head"
1059
+ else:
1060
+ rel = _relate_current(head, cp, _proof_sources(policy, env, cp, {}))
1061
+ continuity = {"status": rel.status, "saved_head": head.brief(), "detail": rel.report()}
1062
+ role = {"consistent": "head", "historical": "observed"}.get(rel.status, "evidence")
1063
+ if not args.no_save:
1064
+ s = store.save(cp, now=env.now(), role=role, continuity=rel.status, policy=policy)
1065
+ saved = {"path": str(s.path), "new": s.created, "role": role}
1066
+ if rel.status == "split-proven":
1067
+ raise VerificationFailed(
1068
+ f"the checkpoint is valid but is not a continuation of the saved head (size {head.size}): "
1069
+ + _split_words(rel, cp),
1070
+ evidence=split_bundle(policy, rel, now=env.now(), context=f"aics verify checkpoint ({url})"),
1071
+ checkpoint=chk.report(), saved=saved,
1072
+ )
1073
+ if rel.status == "historical":
1074
+ raise AicsError(
1075
+ f"historical, not adopted: the served checkpoint (size {cp.size}) is older than the saved head "
1076
+ f"(size {head.size}) and consistent with it; an older consistent head is historical only "
1077
+ "(AICS-1 §14.1 step 4), so the head stays where it was",
1078
+ result="historical", checkpoint=chk.report(), continuity=continuity, saved=saved,
1079
+ )
1080
+ if rel.status != "consistent":
1081
+ raise AicsError(
1082
+ f"incomplete: the checkpoint verifies, but its consistency with the saved head (size {head.size}) "
1083
+ "could not be proven, so the head was not advanced (AICS-1 §14.1 step 4); check tiles_url",
1084
+ checkpoint=chk.report(), continuity=continuity, saved=saved,
1085
+ )
1086
+ if saved is None and not args.no_save:
1087
+ s = store.save(cp, now=env.now(), role=role, continuity=continuity["status"], policy=policy)
1088
+ saved = {"path": str(s.path), "new": s.created, "role": role}
1089
+ data = {**chk.report(), "source": cp.source, "continuity": continuity, "saved": saved,
1090
+ "trust_root": trust_root, "key_sources": _key_sources(policy, pypi_status), "warnings": warnings}
1091
+ if history_reset:
1092
+ data["history_reset"] = history_reset
1093
+ wit = ", ".join(f"{w['name']} {w['status']}" + (f" ({w['age_seconds']}s old)" if "age_seconds" in w else "")
1094
+ for w in chk.witnesses) or "none pinned"
1095
+ text = [
1096
+ f"checkpoint ok: {cp.origin} size {cp.size} (trust root: {trust_root})",
1097
+ f"root {cp.root_b64}",
1098
+ f"log key {policy.log_vkey} [{', '.join(policy.key_sources('log_vkey'))}; pypi constant: {pypi_status}]",
1099
+ f"witnesses {chk.counted}/{policy.quorum} required: {wit}",
1100
+ f"freshness {chk.freshness}",
1101
+ f"continuity {continuity['status']}",
1102
+ ]
1103
+ if saved:
1104
+ text.append(f"saved {saved['path']} ({'new' if saved['new'] else 'already saved'}, {saved['role']})")
1105
+ text += [f"warning: {w}" for w in warnings]
1106
+ return _ok(data, text)
1107
+
1108
+
1109
+ def cmd_verify_consistency(args: Any, env: Env) -> Result:
1110
+ home = _home_dir(args, env)
1111
+ policy, pypi_status = _load_policy(home)
1112
+ trust_root = _trust_root(policy, pypi_status)
1113
+ store = CheckpointStore(home / "checkpoints")
1114
+ warnings: list[str] = []
1115
+ url = args.url or policy.checkpoint_url
1116
+ if not url:
1117
+ raise PolicyError("no checkpoint URL: pass --url or pin checkpoint_url")
1118
+ verified, unverifiable = _unverifiable(store, policy, env, home, {})
1119
+ history_reset = None
1120
+ if unverifiable:
1121
+ if not getattr(args, "reset_history", False):
1122
+ raise AicsError(
1123
+ f"{len(unverifiable)} saved checkpoint(s) of {policy.origin} cannot be authenticated with the pinned "
1124
+ "log key or with the key they were saved under, so their consistency with today's log cannot be "
1125
+ "checked: " + "; ".join(f"{u['path']}: {u['reason']}" for u in unverifiable)
1126
+ + f". Inspect them; rerun with --reset-history to drop them from continuity checks (recorded in "
1127
+ f"{store.index}; no file is deleted)",
1128
+ unverifiable=unverifiable,
1129
+ )
1130
+ store.reset(policy.origin, now=env.now(), files=[u["file"] for u in unverifiable], policy=policy,
1131
+ reason=_reset_reason("aics verify consistency", unverifiable))
1132
+ history_reset = {"dropped": [u["file"] for u in unverifiable], "reasons": unverifiable}
1133
+ warnings.append(f"--reset-history: {len(unverifiable)} unverifiable saved checkpoint(s) no longer take part "
1134
+ f"in continuity checks (files kept, reset recorded in {store.index})")
1135
+ elif getattr(args, "reset_history", False):
1136
+ warnings.append("--reset-history ignored: every saved checkpoint verifies")
1137
+ latest, chk = _current(policy, env, url, max_skew=args.max_skew, warnings=warnings)
1138
+ for cp in verified:
1139
+ if cp.log_vkey and cp.log_vkey != policy.log_vkey:
1140
+ warnings.append(f"saved size {cp.size} is checked under the log key it was saved under ({cp.log_vkey})")
1141
+ readers: dict[str, HttpTileReader] = {}
1142
+ results: list[Relation] = []
1143
+ for cp in verified:
1144
+ if cp.sha256 == latest.sha256:
1145
+ continue
1146
+ results.append(_relate_current(cp, latest, _proof_sources(policy, env, latest, readers)))
1147
+ failures = [r for r in results if r.status == "split-proven"]
1148
+ unproven = [r for r in results if r.status == "unproven"]
1149
+ historical = [r for r in results if r.status == "historical"]
1150
+ proven = [r for r in results if r.status == "consistent"]
1151
+ if failures or unproven:
1152
+ role = "evidence"
1153
+ elif historical:
1154
+ role = "observed"
1155
+ else:
1156
+ role = "head"
1157
+ first = (failures or unproven or historical or [None])[0]
1158
+ s = store.save(latest, now=env.now(), role=role, policy=policy,
1159
+ continuity="consistent" if first is None else first.status)
1160
+ data: dict[str, Any] = {
1161
+ "latest": latest.brief(),
1162
+ "freshness": chk.freshness,
1163
+ "saved_latest": {"path": str(s.path), "new": s.created, "role": role},
1164
+ "checked": [r.report() for r in results],
1165
+ "consistent": len(proven),
1166
+ "trust_root": trust_root,
1167
+ "warnings": warnings,
1168
+ }
1169
+ if history_reset:
1170
+ data["history_reset"] = history_reset
1171
+ if failures:
1172
+ r = failures[0]
1173
+ raise VerificationFailed(
1174
+ f"{len(failures)} saved checkpoint(s) are not a prefix of the log served now (size {latest.size}): "
1175
+ + _split_words(r, latest),
1176
+ evidence=split_bundle(policy, r, now=env.now(), context=f"aics verify consistency --saved ({url})"),
1177
+ **data,
1178
+ )
1179
+ if unproven:
1180
+ raise AicsError(
1181
+ f"consistency unproven for {len(unproven)} saved checkpoint(s): no valid proof could be obtained. "
1182
+ "This is an availability alert, not evidence of a split (AICS-1 §14.6); check tiles_url/consistency_url",
1183
+ **data,
1184
+ )
1185
+ if historical:
1186
+ biggest = max(max(r.a.size, r.b.size) for r in historical)
1187
+ raise AicsError(
1188
+ f"historical, not adopted: the log serves size {latest.size}, older than {len(historical)} saved "
1189
+ f"checkpoint(s) (up to size {biggest}) and consistent with them; an older consistent head is historical "
1190
+ "only (AICS-1 §14.1 step 4)",
1191
+ result="historical", **data,
1192
+ )
1193
+ text = [f"consistent: {len(proven)} saved checkpoint(s) are prefixes of {latest.origin} size {latest.size} "
1194
+ f"(trust root: {trust_root})"]
1195
+ if not results:
1196
+ text = [f"no earlier checkpoint saved for {latest.origin}; saved size {latest.size} as the head "
1197
+ f"(trust root: {trust_root})"]
1198
+ for r in results:
1199
+ src = r.winning.source if r.winning else (r.reason or "")
1200
+ text.append(f" size {r.a.size:<10} -> {r.b.size}: {r.status} ({src})")
1201
+ text += [f"warning: {w}" for w in data["warnings"]]
1202
+ return _ok(data, text)
1203
+
1204
+
1205
+ # ---------------------------------------------------------------------------
1206
+ # gossip-check
1207
+ # ---------------------------------------------------------------------------
1208
+
1209
+
1210
+ def _reference_sources(policy: Policy, env: Env, notes: list[str], witness_ok: dict[str, bool]) -> list[Any]:
1211
+ """Proof sources: the endpoint, then each tile prefix with its own current,
1212
+ log-signed checkpoint as the reference."""
1213
+ srcs: list[Any] = []
1214
+ pairs = []
1215
+ if policy.checkpoint_url and policy.tiles_url:
1216
+ pairs.append((policy.checkpoint_url, policy.tiles_url))
1217
+ pairs += [(join_url(m, "checkpoint"), m) for m in policy.mirrors]
1218
+ for cp_url, base in pairs:
1219
+ try:
1220
+ fetched = fetch_checkpoint(env.fetcher, cp_url)
1221
+ except (FetchError, AicsError) as exc:
1222
+ notes.append(f"{cp_url}: {exc.message}")
1223
+ continue
1224
+ chk = check_note(fetched.note_text, policy, source=cp_url, now=env.now(), require_fresh=False)
1225
+ if chk.cp is None or chk.reason in SELF_CONTAINED_EVIDENCE:
1226
+ notes.append(f"{cp_url}: reference checkpoint rejected: {chk.error}")
1227
+ continue
1228
+ witness_ok.setdefault(chk.cp.sha256, chk.witness_ok)
1229
+ srcs.append(TileSource(f"tiles {base} (reference size {chk.cp.size})", HttpTileReader(base, env.fetcher),
1230
+ ref=chk.cp))
1231
+ return srcs + _endpoint(policy, env)
1232
+
1233
+
1234
+ def _witness_reason(chk: NoteCheck) -> str | None:
1235
+ if chk.witness_ok:
1236
+ return None
1237
+ bad = [w["name"] for w in chk.witnesses if w["status"] == "invalid"]
1238
+ if bad:
1239
+ return f"an invalid cosignature line under pinned witness {', '.join(bad)}"
1240
+ return f"{chk.counted} valid cosignature(s) < quorum {chk.quorum}"
1241
+
1242
+
1243
+ def cmd_gossip_check(args: Any, env: Env) -> Result:
1244
+ home = _home_dir(args, env)
1245
+ policy, _pypi_status = _load_policy(home)
1246
+ cps: list[Cp] = []
1247
+ witness_ok: dict[str, bool] = {}
1248
+ witness_why: dict[str, str | None] = {}
1249
+ for i, arg in enumerate((args.note1, args.note2), 1):
1250
+ text, source = _read_text_arg(arg, env)
1251
+ chk = check_note(text, policy, source=source, now=env.now(), require_fresh=False)
1252
+ if chk.cp is None: # not log-signed by the pinned key: proves nothing about this log
1253
+ raise AicsError(f"note {i} ({source}) is not evidence: {chk.error}", note=i, reason=chk.reason,
1254
+ witnesses=chk.witnesses)
1255
+ # Evidence is authenticated by the log signature alone (AICS-1 §14.6
1256
+ # step 2); the witness status is reported separately.
1257
+ witness_ok[chk.cp.sha256] = chk.witness_ok
1258
+ witness_why[chk.cp.sha256] = _witness_reason(chk)
1259
+ cps.append(chk.cp)
1260
+ a, b = cps
1261
+ notes: list[str] = []
1262
+ sources: list[Any] = []
1263
+ if a.size != b.size:
1264
+ if args.proof:
1265
+ try:
1266
+ hashes = parse_hash_lines(Path(args.proof).expanduser().read_text("utf-8"))
1267
+ except (OSError, ValueError, UnicodeDecodeError) as exc:
1268
+ raise AicsError(f"cannot read proof {args.proof}: {exc}") from None
1269
+ sources.append(GivenProof(f"proof file {args.proof}", hashes))
1270
+ if not args.offline:
1271
+ sources += _reference_sources(policy, env, notes, witness_ok)
1272
+ rel = relate(a, b, sources)
1273
+ checked = []
1274
+ for c in cps:
1275
+ entry = {**c.brief(), "witness_policy_met": witness_ok[c.sha256]}
1276
+ if witness_why[c.sha256]:
1277
+ entry["witness_reason"] = witness_why[c.sha256]
1278
+ checked.append(entry)
1279
+ data: dict[str, Any] = {
1280
+ "relation": rel.report(),
1281
+ "notes_checked": checked,
1282
+ "source_notes": notes,
1283
+ }
1284
+ if rel.status == "consistent":
1285
+ data["split"] = False
1286
+ how = rel.reason or (rel.winning.source if rel.winning else "")
1287
+ return _ok(data, [f"no split: size {rel.a.size} is a prefix of size {rel.b.size} ({escape_untrusted(how)})"])
1288
+ if rel.status == "split-proven":
1289
+ bundle = split_bundle(policy, rel, now=env.now(), context="aics gossip-check", witness_status=witness_ok)
1290
+ classification = bundle["classification"]
1291
+ # A split view (G5) is proven only between notes that all met the
1292
+ # witness policy; otherwise the log equivocated, but a client
1293
+ # following the policy would not have accepted both notes.
1294
+ data["relation"]["status"] = classification
1295
+ data["classification"] = classification
1296
+ data["split"] = classification == "split-proven"
1297
+ data["log_equivocation"] = True
1298
+ if args.out:
1299
+ out = Path(args.out).expanduser()
1300
+ try:
1301
+ _home.write_new(out, (json.dumps(bundle, indent=2) + "\n").encode("utf-8"))
1302
+ except FileExistsError:
1303
+ raise AicsError(f"{out} exists; evidence files are never overwritten") from None
1304
+ data["evidence_file"] = str(out)
1305
+ raise VerificationFailed(f"{classification}: {bundle['explanation']}", evidence=bundle, **data)
1306
+ data["split"] = None
1307
+ if a.size != b.size and not sources:
1308
+ raise AicsError("consistency-unproven: the notes differ in size and, offline, a proof is needed "
1309
+ "(--proof FILE)", **data)
1310
+ raise AicsError("consistency-unproven: no valid consistency proof could be obtained. That is an availability "
1311
+ "alert, not evidence of a split (AICS-1 §14.6 step 5)", **data)
1312
+
1313
+
1314
+ # ---------------------------------------------------------------------------
1315
+ # card
1316
+ # ---------------------------------------------------------------------------
1317
+
1318
+ CARD_CHECKED = [
1319
+ "JWS-over-JCS signature by the pinned policy key (AICS-1 §13.2)",
1320
+ "schema (AICS-1 §13.1; the sup member and §20.8 codes of an AICS-1+SUP-1 card, §20.9) and status bounds "
1321
+ "(AICS-1 §13.3)",
1322
+ "validity window (issued_at, expires)",
1323
+ "origin and keys against the pinned trust bundle",
1324
+ "history against the card saved for this policy key (supersession, equivocation, witness downgrade)",
1325
+ ]
1326
+ CARD_NOT_CHECKED = [
1327
+ "the evidence behind 'proven' assurances (not fetched or checked)",
1328
+ "the card's policy leaf, its inclusion and activation, notice periods and supersession by later policy events "
1329
+ "(AICS-1 §14.5 steps 3-4)",
1330
+ ]
1331
+
1332
+
1333
+ def cmd_verify_card(args: Any, env: Env) -> Result:
1334
+ home = _home_dir(args, env)
1335
+ policy, pypi_status = _load_policy(home)
1336
+ if not policy.policy_kid:
1337
+ raise PolicyError("no policy key is pinned, so the card cannot be verified; pin one (policy init)")
1338
+ url = cardmod.resolve_url(args.url, policy)
1339
+ if args.file:
1340
+ try:
1341
+ raw = Path(args.file).expanduser().read_bytes()
1342
+ except OSError as exc:
1343
+ raise AicsError(f"cannot read {args.file}: {exc}") from None
1344
+ else:
1345
+ raw = cardmod.fetch_card(env.fetcher, url)
1346
+ try:
1347
+ card = cardmod.loads_strict(raw)
1348
+ except (ValueError, UnicodeDecodeError) as exc:
1349
+ raise AicsError(f"card is not strict JSON: {exc}") from None
1350
+ if not isinstance(card, dict):
1351
+ raise AicsError("card is not a JSON object")
1352
+ checks = cardmod.check_signatures(card, policy.policy_keys())
1353
+ sig_reports = [c.report() for c in checks]
1354
+ try:
1355
+ h = cardmod.card_hash(card)
1356
+ except ValueError as exc:
1357
+ raise AicsError(f"card cannot be canonicalised (RFC 8785): {exc}") from None
1358
+ base = {"type": "aics-card-failure/v1", "url": url, "card_hash": h, "signatures": sig_reports,
1359
+ "pinned_policy_kid": policy.policy_kid, "card": card}
1360
+ if any(c.status == "invalid" for c in checks):
1361
+ raise VerificationFailed("card signature does not verify: the card was altered after signing",
1362
+ evidence={**base, "reason": "invalid-signature"})
1363
+ if not any(c.status == "valid" for c in checks):
1364
+ raise VerificationFailed("the card carries no valid signature from the pinned policy key",
1365
+ evidence={**base, "reason": "no-pinned-signature"})
1366
+ _refuse_unsupported_profile(card, "the signed card", url=url, card_hash=h, signatures=sig_reports)
1367
+ problems = cardmod.schema_problems(card)
1368
+ if problems:
1369
+ raise VerificationFailed("the card does not follow the AICS-1 §13.1 schema",
1370
+ evidence={**base, "reason": "schema", "problems": problems})
1371
+ bounds = cardmod.assurance_problems(card)
1372
+ if bounds:
1373
+ raise VerificationFailed("the signed card claims statuses outside the AICS-1 §13.3 boundaries",
1374
+ evidence={**base, "reason": "assurance-bounds", "problems": bounds})
1375
+ now = env.now()
1376
+ if card["issued_at"] > now + cardmod.FUTURE_SKEW:
1377
+ raise VerificationFailed(f"the card is not valid yet (issued_at {_iso(card['issued_at'])})",
1378
+ evidence={**base, "reason": "not-yet-valid", "checked_at": _iso(now)})
1379
+ if now >= card["expires"]:
1380
+ raise VerificationFailed(f"the card expired at {_iso(card['expires'])} (stale)",
1381
+ evidence={**base, "reason": "expired", "checked_at": _iso(now)})
1382
+ anchors = cardmod.anchor_mismatches(card, policy)
1383
+ store = cardmod.CardStore(home / "cards")
1384
+ hist = cardmod.compare_with_saved(store, policy.policy_kid, card)
1385
+ bootstrap = bool(policy.bootstrap_card_hash) and h == policy.bootstrap_card_hash
1386
+ vs_pinned = cardmod.compare_witness(cardmod.policy_terms(policy), cardmod.witness_terms(card))
1387
+ table = cardmod.guarantee_table(card)
1388
+ trust_root = _trust_root(policy, pypi_status)
1389
+ data: dict[str, Any] = {
1390
+ "url": url,
1391
+ "card_hash": h,
1392
+ "signatures": sig_reports,
1393
+ "issued_at": _iso(card["issued_at"]),
1394
+ "expires": _iso(card["expires"]),
1395
+ "bootstrap": bootstrap,
1396
+ "currency": "bootstrap card; later policy events not checked" if bootstrap else
1397
+ f"unproven: needs its policy leaf in the log ({UNAVAILABLE_MESSAGE})",
1398
+ "witness_policy_vs_saved_card": hist.witness,
1399
+ "witness_policy_vs_pinned": vs_pinned,
1400
+ "previous": hist.previous.meta if hist.previous else None,
1401
+ "diff": hist.diff,
1402
+ "table": table,
1403
+ "table_note": cardmod.TABLE_NOTE,
1404
+ "anchor_mismatches": anchors,
1405
+ "checked": CARD_CHECKED,
1406
+ "unproven": CARD_NOT_CHECKED,
1407
+ "trust_root": trust_root,
1408
+ }
1409
+ if anchors:
1410
+ raise VerificationFailed(
1411
+ "the signed card states keys that differ from the pinned trust bundle: "
1412
+ + ", ".join(m["field"] for m in anchors),
1413
+ evidence={**base, "reason": "anchor-mismatch", "mismatches": anchors}, **data)
1414
+ problem = hist.problem()
1415
+ if problem is not None:
1416
+ raise VerificationFailed(
1417
+ cardmod.HISTORY_MESSAGES[problem],
1418
+ evidence={**base, "reason": problem, "saved_card": hist.previous.card if hist.previous else None,
1419
+ "saved": hist.previous.meta if hist.previous else None},
1420
+ **data)
1421
+ path, created = store.save(policy.policy_kid, url, card, now=now,
1422
+ status="bootstrap" if bootstrap else "signature-only")
1423
+ data["stored"] = {"path": str(path), "new": created}
1424
+ text = [f"card: {escape_untrusted(url)}",
1425
+ f"signature valid (policy key {policy.policy_kid})",
1426
+ f"validity {_iso(card['issued_at'])} .. {_iso(card['expires'])}",
1427
+ f"currency {data['currency']}"]
1428
+ text += [f"checked {x}" for x in CARD_CHECKED]
1429
+ text += [f"unproven {x}" for x in CARD_NOT_CHECKED]
1430
+ text += [f"witnesses quorum {card['witness_policy']['quorum']} of {len(card['witness_policy']['witnesses'])}, "
1431
+ f"max age {card['max_checkpoint_age']}s ({vs_pinned} vs your pinned policy)",
1432
+ f"trust root {trust_root}",
1433
+ f"stored {path} ({'new' if created else 'unchanged'})"]
1434
+ if hist.diff is None:
1435
+ text.append("changes first card stored for this policy key")
1436
+ elif not hist.diff:
1437
+ text.append("changes none since the stored card")
1438
+ else:
1439
+ text.append(f"changes {len(hist.diff)} since the stored card:")
1440
+ for d in hist.diff[:50]:
1441
+ p = escape_untrusted(d["path"])
1442
+ if d["op"] == "changed":
1443
+ text.append(f" ~ {p}: {json.dumps(d['old'])} -> {json.dumps(d['new'])}")
1444
+ elif d["op"] == "added":
1445
+ text.append(f" + {p}: {json.dumps(d['new'])}")
1446
+ else:
1447
+ text.append(f" - {p}: {json.dumps(d['old'])}")
1448
+ text.append("")
1449
+ text += cardmod.render_table(table)
1450
+ if vs_pinned == "stronger":
1451
+ text.append("note: the card's witness policy is stricter than your pinned one; re-pin to adopt it")
1452
+ if not bootstrap:
1453
+ raise AicsError(
1454
+ "incomplete: the card is signed by the pinned policy key, schema-valid and unexpired, but it is not the "
1455
+ "bootstrap card the trust bundle pins, and proving a later card current needs its policy leaf "
1456
+ f"({UNAVAILABLE_MESSAGE})", text_lines=text, **data)
1457
+ return _ok(data, text)
1458
+
1459
+
1460
+ # ---------------------------------------------------------------------------
1461
+ # status
1462
+ # ---------------------------------------------------------------------------
1463
+
1464
+
1465
+ def cmd_status(args: Any, env: Env) -> Result:
1466
+ home = _home_dir(args, env)
1467
+ policy, pypi_status = _load_policy(home)
1468
+ trust_root = _trust_root(policy, pypi_status)
1469
+ store = CheckpointStore(home / "checkpoints")
1470
+ warnings: list[str] = []
1471
+ previous, _ = _head(store, policy, env, home, warnings, command="aics status")
1472
+ targets = [("origin", policy.checkpoint_url, policy.tiles_url)]
1473
+ if args.mirrors:
1474
+ if not policy.mirrors:
1475
+ warnings.append("no mirrors are pinned in the policy")
1476
+ targets += [(f"mirror {m}", join_url(m, "checkpoint"), m) for m in policy.mirrors]
1477
+ rows: list[dict[str, Any]] = []
1478
+ live: list[tuple[str, Cp, HttpTileReader | None]] = []
1479
+ evidence: list[dict[str, Any]] = []
1480
+ problems: list[str] = []
1481
+ for name, cp_url, tiles in targets:
1482
+ row: dict[str, Any] = {"name": name, "url": cp_url}
1483
+ try:
1484
+ fetched = fetch_checkpoint(env.fetcher, cp_url)
1485
+ except (FetchError, AicsError) as exc:
1486
+ row.update(status="unreachable", error=exc.message)
1487
+ rows.append(row)
1488
+ continue
1489
+ chk = check_note(fetched.note_text, policy, source=cp_url, now=env.now(), max_skew=args.max_skew)
1490
+ if not chk.ok or chk.cp is None:
1491
+ status = {"malformed": "malformed", "quorum": "stale"}.get(chk.reason or "", "invalid")
1492
+ row.update(status=status, error=chk.error, witnesses=chk.witnesses)
1493
+ # Only what the origin itself served, or a log-signed note no log
1494
+ # can produce, is evidence; a mirror's bad note may be the edge's.
1495
+ if status == "invalid" and (name == "origin" or chk.reason in SELF_CONTAINED_EVIDENCE):
1496
+ evidence.append(_failure(chk, policy, fetched.note_text, cp_url).evidence)
1497
+ row["evidence"] = True
1498
+ problems.append(f"{name} served an invalid checkpoint")
1499
+ rows.append(row)
1500
+ continue
1501
+ store.save(chk.cp, now=env.now(), role="observed", policy=policy)
1502
+ row.update(status="ok", size=chk.cp.size, root=chk.cp.root_b64, sha256=chk.cp.sha256,
1503
+ fresh_cosignatures=chk.counted, freshness=chk.freshness)
1504
+ rows.append(row)
1505
+ live.append((name, chk.cp, HttpTileReader(tiles, env.fetcher) if tiles else None))
1506
+ endpoint = _endpoint(policy, env)
1507
+ comparisons = [(live[i][0], live[i][1], live[j][0], live[j][1])
1508
+ for i in range(len(live)) for j in range(i + 1, len(live))]
1509
+ origin_live = next((c for n, c, _ in live if n == "origin"), None)
1510
+ if previous is not None and origin_live is not None and previous.sha256 != origin_live.sha256:
1511
+ comparisons.append(("saved head", previous, "origin", origin_live))
1512
+ pairs: list[dict[str, Any]] = []
1513
+ for na, ca, nb, cb in comparisons:
1514
+ big = cb if cb.size >= ca.size else ca
1515
+ # A saved head larger than what the origin serves is compared against
1516
+ # tiles authenticated by the saved head's own root (any live reader).
1517
+ older_origin = na == "saved head" and big is ca
1518
+ srcs: list[Any] = []
1519
+ for _n, c, reader in sorted(live, key=lambda t: (t[1] is not big, -t[1].size)):
1520
+ if reader is not None and (c.size >= big.size or older_origin):
1521
+ srcs.append(TileSource(f"tiles {reader.base_url} (reference size {c.size})", reader, ref=c))
1522
+ rel = _relate_current(ca, cb, srcs + endpoint) if na == "saved head" else relate(ca, cb, srcs + endpoint)
1523
+ pairs.append({"a": na, "b": nb, "a_size": ca.size, "b_size": cb.size, "status": rel.status,
1524
+ "detail": rel.report()})
1525
+ if rel.status == "split-proven":
1526
+ evidence.append(split_bundle(policy, rel, now=env.now(), context=f"aics status: {na} vs {nb}"))
1527
+ problems.append(f"{na} ~ {nb}: split-proven")
1528
+ not_provable = [p for p in pairs if p["status"] != "consistent"]
1529
+ data: dict[str, Any] = {
1530
+ "sources": rows,
1531
+ "pairs": pairs,
1532
+ "not_provable": [{"a": p["a"], "b": p["b"], "status": p["status"]} for p in not_provable],
1533
+ "saved": {"count": store.count(policy.origin), "head_size": previous.size if previous else None},
1534
+ "trust_root": trust_root,
1535
+ "warnings": warnings,
1536
+ }
1537
+ text = [f"{'SOURCE':<40} {'STATUS':<12} {'SIZE':>10} ROOT"]
1538
+ for r in rows:
1539
+ text.append(f"{r['name'][:40]:<40} {r['status']:<12} {str(r.get('size', '-')):>10} "
1540
+ f"{r.get('root', r.get('error', ''))}")
1541
+ for p in pairs:
1542
+ mark = "ok" if p["status"] == "consistent" else p["status"].upper()
1543
+ text.append(f"pair {p['a']} ({p['a_size']}) ~ {p['b']} ({p['b_size']}): {mark}")
1544
+ text.append(f"trust root: {trust_root}")
1545
+ text += [f"warning: {w}" for w in warnings]
1546
+ if evidence:
1547
+ raise VerificationFailed("; ".join(problems), evidence={"type": "aics-status-evidence/v1", "items": evidence},
1548
+ **data)
1549
+ gaps = [f"{r['name']} {r['status']}" for r in rows if r["status"] != "ok"]
1550
+ gaps += [f"{p['a']} ~ {p['b']}: consistency unproven" for p in pairs if p["status"] == "unproven"]
1551
+ gaps += [f"{p['a']} ~ {p['b']}: historical (the origin serves an older head than the saved one; not adopted)"
1552
+ for p in pairs if p["status"] == "historical"]
1553
+ if gaps:
1554
+ raise AicsError("status incomplete: " + "; ".join(gaps), **data)
1555
+ return _ok(data, text)
1556
+
1557
+
1558
+ # ---------------------------------------------------------------------------
1559
+ # verify message (AICS-1 §14.3)
1560
+ # ---------------------------------------------------------------------------
1561
+
1562
+
1563
+ def _read_input(path: str, env: Env) -> bytes:
1564
+ if path == "-":
1565
+ stream = env.stdin if env.stdin is not None else sys.stdin.buffer
1566
+ data = stream.read(msgmod.INPUT_MAX_BYTES + 1)
1567
+ return data.encode("utf-8") if isinstance(data, str) else data
1568
+ try:
1569
+ with Path(path).expanduser().open("rb") as f:
1570
+ return f.read(msgmod.INPUT_MAX_BYTES + 1)
1571
+ except OSError as exc:
1572
+ raise AicsError(f"cannot read {path}: {exc}") from None
1573
+
1574
+
1575
+ def _prefix_reader(args: Any, policy: Policy, env: Env) -> tuple[Any, str] | None:
1576
+ """Where the entry prefix comes from when the input carries none: --tiles (a directory, or a URL online),
1577
+ else the pinned tile prefix unless --offline."""
1578
+ from aics_tlog import FileSystemTileReader
1579
+
1580
+ if args.tiles:
1581
+ if args.tiles.startswith(("https://", "http://")):
1582
+ if args.offline:
1583
+ raise UsageError("--offline reads nothing from the network: give --tiles a local directory")
1584
+ return HttpTileReader(args.tiles, env.fetcher), f"the tiles at {args.tiles}"
1585
+ root = Path(args.tiles).expanduser()
1586
+ if not root.is_dir():
1587
+ raise UsageError(f"--tiles {args.tiles}: not a directory")
1588
+ return FileSystemTileReader(root), f"the tile directory {root}"
1589
+ if args.offline or not policy.tiles_url:
1590
+ return None
1591
+ return HttpTileReader(policy.tiles_url, env.fetcher), f"the pinned tiles at {policy.tiles_url}"
1592
+
1593
+
1594
+ def cmd_verify_message(args: Any, env: Env) -> Result:
1595
+ if not args.bundle:
1596
+ raise UsageError("verify message needs --bundle FILE: an AICS-1 §14.3 wire bundle (GET /v2/entries/<index> "
1597
+ "serves one) or a verification package; '-' reads it from stdin")
1598
+ home = _home_dir(args, env)
1599
+ policy, pypi_status = _load_policy(home)
1600
+ if not policy.policy_kid or not policy.owner_kid:
1601
+ raise PolicyError("the history check needs the pinned policy and owner keys (the trust bundle's policy_kid "
1602
+ "and owner_kid): pin them with aics policy init")
1603
+ try:
1604
+ inp = msgmod.parse_input(_read_input(args.bundle, env), policy)
1605
+ except msgmod.InputProblem as exc:
1606
+ raise AicsError(str(exc), result="invalid-input") from None
1607
+ reader = None if inp.prefix is not None else _prefix_reader(args, policy, env)
1608
+
1609
+ def prefix_source(size: int) -> tuple[list[bytes] | None, str]:
1610
+ if reader is None:
1611
+ return None, ("no entry prefix: the input is a bare bundle, so give a §14.3 package, --tiles DIR, or run "
1612
+ "without --offline to read the pinned tiles")
1613
+ tiles, label = reader
1614
+ try:
1615
+ return msgmod.read_prefix(tiles, size), f"the entry bundles of {label}"
1616
+ except (ValueError, TileSourceError, OSError) as exc:
1617
+ return None, f"the entry prefix of size {size} could not be read from {label}: {exc}"
1618
+
1619
+ larger_head = None
1620
+ if not args.offline:
1621
+ readers: dict[str, HttpTileReader] = {}
1622
+
1623
+ def larger_head(bundle_cp: Cp, saved_cp: Cp) -> tuple[str, str]:
1624
+ rel = relate(bundle_cp, saved_cp, _proof_sources(policy, env, None, readers))
1625
+ detail = {"consistent": "a consistency proof from the tiles verifies",
1626
+ "split-proven": "the tiles prove the two log-signed checkpoints inconsistent"}.get(
1627
+ rel.status, "no consistency proof could be obtained from the tiles")
1628
+ return rel.status, detail
1629
+
1630
+ store = CheckpointStore(home / "checkpoints")
1631
+ saved = [(n.text, f"saved checkpoint {n.file}") for n in store.notes(policy.origin)]
1632
+ outcome = msgmod.verify(inp, policy, now=env.now(), prefix_source=prefix_source, saved=saved,
1633
+ larger_head=larger_head)
1634
+ data = {**outcome.data, "offline": bool(args.offline), "trust_root": _trust_root(policy, pypi_status)}
1635
+ if outcome.input_problem:
1636
+ raise AicsError(outcome.message, text_lines=outcome.text, **data)
1637
+ if outcome.evidence is not None:
1638
+ raise VerificationFailed(outcome.message, evidence=outcome.evidence, **data)
1639
+ if outcome.result != "valid":
1640
+ raise AicsError(outcome.message, text_lines=outcome.text, **data)
1641
+ return _ok(data, outcome.text)
1642
+
1643
+
1644
+ # ---------------------------------------------------------------------------
1645
+ # not yet available
1646
+ # ---------------------------------------------------------------------------
1647
+
1648
+ UNAVAILABLE_DETAIL: dict[str, str] = {}
1649
+
1650
+
1651
+ def cmd_unavailable(args: Any, env: Env) -> Result:
1652
+ data: dict[str, Any] = {"ok": False, "available": False, "error": UNAVAILABLE_MESSAGE}
1653
+ detail = UNAVAILABLE_DETAIL.get(getattr(args, "cmd_name", ""))
1654
+ if detail:
1655
+ data["detail"] = detail
1656
+ return Result(EXIT_UNAVAILABLE, data, [UNAVAILABLE_MESSAGE])