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/policy.py ADDED
@@ -0,0 +1,618 @@
1
+ """The pinned policy (``~/.aics/policy.toml``).
2
+
3
+ Every proof the CLI checks reduces to what is pinned here:
4
+
5
+ * from the out-of-band trust bundle (docs/spec/AICS-1.md §6.4): the log
6
+ origin and checkpoint key, the policy key that signs the assurance card,
7
+ the owner key and the bootstrap card hash;
8
+ * from the bootstrap card (whose hash the bundle pins) or typed in by hand:
9
+ the witness keys, quorum and ``max_checkpoint_age`` (§6.3), and the tile
10
+ prefixes and API base.
11
+
12
+ Keys never come from the API host being verified. The file records the
13
+ source of every value; a key whose only source is the API host is refused
14
+ when the policy is written and again whenever it is loaded.
15
+
16
+ ``[witness_meta]`` keeps what the card *declares* about each witness
17
+ (``declared_trust_domain``, ``declared_independent``: the operator's own
18
+ claims) apart from what the user asserted (``independent = true`` with
19
+ ``independent_source = "manual"``, from ``policy init --independent``).
20
+ Only the user's assertion counts as independence (AICS-1 §6.3).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import os
27
+ import re
28
+ import time
29
+ import tomllib
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from aics_identity import keys as idkeys
35
+ from aics_tlog import VKey, format_vkey, parse_vkey
36
+
37
+ from . import home as _home
38
+ from .encoding import b64d_lenient
39
+ from .errors import PolicyError
40
+
41
+ POLICY_VERSION = 1
42
+ TYPE_ED25519 = 0x01
43
+ TYPE_COSIGNATURE = 0x04
44
+ MIN_CHECKPOINT_AGE = 60 # AICS-1 §13.1: max_checkpoint_age MUST be at least 60 seconds
45
+
46
+ TRUST_FIELDS = ("origin", "log_vkey", "policy_kid", "owner_kid", "bootstrap_card_hash")
47
+ WITNESS_FIELDS = ("witness_vkeys", "quorum", "max_checkpoint_age")
48
+ URL_FIELDS = ("checkpoint_url", "tiles_url", "card_url", "consistency_url", "mirrors")
49
+ INT_FIELDS = {"quorum", "max_checkpoint_age"}
50
+ _HEX64 = re.compile(r"[0-9a-f]{64}")
51
+
52
+
53
+ def is_out_of_band(source: str) -> bool:
54
+ return not source.startswith("api:")
55
+
56
+
57
+ def check_origin_name(name: str) -> str:
58
+ """A signed-note key name / log origin (c2sp.org/signed-note): non-empty,
59
+ at most 255 UTF-8 bytes, no Unicode spaces, no '+', no control characters."""
60
+ if not isinstance(name, str) or not name:
61
+ raise PolicyError("origin must be a non-empty string")
62
+ if len(name.encode("utf-8")) > 255:
63
+ raise PolicyError("origin is longer than 255 bytes")
64
+ if any(ord(c) < 0x20 or c.isspace() or c == "+" for c in name):
65
+ raise PolicyError("origin contains spaces, control characters or '+'")
66
+ return name
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # keys
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class PolicyKey:
76
+ """The policy key (signs the assurance card). Pinned as a ``did:key``
77
+ (``policy_kid``, AICS-1 §3.1); a signed-note vkey or raw base64 is also
78
+ accepted when typed in by hand."""
79
+
80
+ text: str
81
+ public_key: bytes
82
+ kids: frozenset[str] # identifiers accepted as the JWS ``kid``
83
+
84
+ @property
85
+ def did(self) -> str:
86
+ return idkeys.did_key(idkeys.public_key_from_bytes(self.public_key))
87
+
88
+
89
+ def parse_policy_key(text: str) -> PolicyKey:
90
+ t = text.strip()
91
+ names: set[str] = set()
92
+ if t.startswith("did:key:"):
93
+ try:
94
+ pub = idkeys.public_key_bytes(idkeys.parse_did_key(t))
95
+ except ValueError as exc:
96
+ raise PolicyError(f"bad policy key {t!r}: {exc}") from None
97
+ names.add(t)
98
+ else:
99
+ raw_text = t[len("ed25519:"):] if t.startswith("ed25519:") else None
100
+ vk_error: str | None = None
101
+ pub = b""
102
+ if raw_text is None and "+" in t:
103
+ try:
104
+ vk = parse_vkey(t)
105
+ except ValueError as exc:
106
+ vk_error = str(exc) # maybe raw base64 that contains '+'
107
+ else:
108
+ if vk.key_type != TYPE_ED25519 or len(vk.public_key) != 32:
109
+ raise PolicyError(f"policy vkey {t!r} is not an Ed25519 (0x01) key")
110
+ pub = vk.public_key
111
+ t = format_vkey(vk)
112
+ names |= {t, vk.name, f"{vk.name}+{vk.key_id.hex()}"}
113
+ if not pub:
114
+ try:
115
+ pub = b64d_lenient(raw_text if raw_text is not None else t)
116
+ except ValueError as exc:
117
+ raise PolicyError(f"bad policy key {t!r}: {vk_error or exc}") from None
118
+ if len(pub) != 32:
119
+ raise PolicyError(f"bad policy key {t!r}: {vk_error or 'not 32 bytes'}")
120
+ names.add(t)
121
+ try:
122
+ pk = idkeys.public_key_from_bytes(pub)
123
+ except ValueError as exc:
124
+ raise PolicyError(f"bad policy key {t!r}: {exc}") from None
125
+ names |= {idkeys.did_key(pk), idkeys.key_id(pk)}
126
+ return PolicyKey(text=t, public_key=pub, kids=frozenset(names))
127
+
128
+
129
+ def parse_log_vkey(text: str) -> VKey:
130
+ try:
131
+ vk = parse_vkey(text.strip())
132
+ except ValueError as exc:
133
+ raise PolicyError(f"bad log vkey {text!r}: {exc}") from None
134
+ if vk.key_type != TYPE_ED25519 or len(vk.public_key) != 32:
135
+ raise PolicyError("the log vkey must be an Ed25519 signed-note key (type 0x01)")
136
+ return vk
137
+
138
+
139
+ def parse_witness_vkey(text: str) -> VKey:
140
+ try:
141
+ vk = parse_vkey(text.strip())
142
+ except ValueError as exc:
143
+ raise PolicyError(f"bad witness vkey {text!r}: {exc}") from None
144
+ if vk.key_type != TYPE_COSIGNATURE or len(vk.public_key) != 32:
145
+ raise PolicyError(
146
+ f"witness vkey {vk.name!r} must be an Ed25519 cosignature key (type 0x04, c2sp.org/tlog-cosignature)"
147
+ )
148
+ return vk
149
+
150
+
151
+ def _norm_value(fieldname: str, value: Any) -> Any:
152
+ """Canonical form for comparing a field across sources."""
153
+ if value is None:
154
+ return None
155
+ if fieldname == "log_vkey":
156
+ return format_vkey(parse_log_vkey(str(value)))
157
+ if fieldname in ("policy_kid", "owner_kid"):
158
+ return parse_policy_key(str(value)).public_key.hex()
159
+ if fieldname == "witness_vkeys":
160
+ return frozenset(format_vkey(parse_witness_vkey(v)) for v in value)
161
+ if fieldname in INT_FIELDS:
162
+ if isinstance(value, bool) or not isinstance(value, int):
163
+ raise PolicyError(f"{fieldname} must be an integer")
164
+ return value
165
+ if fieldname == "bootstrap_card_hash":
166
+ v = str(value).strip()
167
+ if not _HEX64.fullmatch(v):
168
+ raise PolicyError("bootstrap_card_hash must be 64 lowercase hex characters")
169
+ return v
170
+ return str(value).strip()
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # the policy
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ @dataclass
179
+ class Policy:
180
+ origin: str
181
+ log_vkey: str
182
+ policy_kid: str | None = None
183
+ owner_kid: str | None = None
184
+ bootstrap_card_hash: str | None = None
185
+ trust_bundle_sha256: str | None = None
186
+ witness_vkeys: list[str] = field(default_factory=list)
187
+ quorum: int = 0
188
+ max_checkpoint_age: int | None = None
189
+ witness_meta: dict[str, dict[str, Any]] = field(default_factory=dict)
190
+ domain: str | None = None
191
+ checkpoint_url: str = ""
192
+ tiles_url: str = ""
193
+ card_url: str = ""
194
+ consistency_url: str = ""
195
+ mirrors: list[str] = field(default_factory=list)
196
+ sources: dict[str, list[str]] = field(default_factory=dict)
197
+ created_at: str = ""
198
+ path: Path | None = None
199
+
200
+ # -- parsed views ----------------------------------------------------
201
+
202
+ def log_key(self) -> VKey:
203
+ return parse_log_vkey(self.log_vkey)
204
+
205
+ def witness_keys(self) -> list[VKey]:
206
+ return [parse_witness_vkey(w) for w in self.witness_vkeys]
207
+
208
+ def policy_keys(self) -> list[PolicyKey]:
209
+ return [parse_policy_key(self.policy_kid)] if self.policy_kid else []
210
+
211
+ def validate(self) -> None:
212
+ from .errors import FetchError
213
+ from .net import check_url
214
+
215
+ check_origin_name(self.origin)
216
+ log = self.log_key()
217
+ keys = {log.public_key: "log"}
218
+ for name in ("policy_kid", "owner_kid"):
219
+ v = getattr(self, name)
220
+ if v:
221
+ pub = parse_policy_key(v).public_key
222
+ if pub in keys:
223
+ raise PolicyError("log, policy and owner keys must be distinct (AICS-1 §3.1)")
224
+ keys[pub] = name.split("_")[0]
225
+ if self.bootstrap_card_hash is not None:
226
+ _norm_value("bootstrap_card_hash", self.bootstrap_card_hash)
227
+ witnesses = self.witness_keys()
228
+ if len({w.name for w in witnesses}) != len(witnesses):
229
+ raise PolicyError("a witness name is listed twice")
230
+ if len({w.public_key for w in witnesses}) != len(witnesses):
231
+ raise PolicyError("a witness public key is listed twice (names and keys must be unique)")
232
+ for w in witnesses:
233
+ if w.public_key in keys:
234
+ raise PolicyError(
235
+ f"witness {w.name!r} uses the {keys[w.public_key]} key: the log, policy and owner keys cannot "
236
+ "act as witnesses, or the log alone could meet the quorum (AICS-1 §3.1, §6.3)"
237
+ )
238
+ if isinstance(self.quorum, bool) or not isinstance(self.quorum, int) or self.quorum < 0:
239
+ raise PolicyError("quorum must be a non-negative integer")
240
+ if self.quorum > len(witnesses):
241
+ raise PolicyError(f"quorum {self.quorum} exceeds the {len(witnesses)} witnesses")
242
+ if self.max_checkpoint_age is not None and (
243
+ isinstance(self.max_checkpoint_age, bool)
244
+ or not isinstance(self.max_checkpoint_age, int)
245
+ or self.max_checkpoint_age < MIN_CHECKPOINT_AGE
246
+ ):
247
+ raise PolicyError(f"max_checkpoint_age must be at least {MIN_CHECKPOINT_AGE} seconds")
248
+ if self.quorum > 0 and self.max_checkpoint_age is None:
249
+ raise PolicyError("a positive witness quorum needs max_checkpoint_age (AICS-1 §6.3)")
250
+ urls = [self.checkpoint_url, self.tiles_url, self.card_url, *self.mirrors,
251
+ self.consistency_url.replace("{old}", "0").replace("{new}", "1")]
252
+ for u in urls:
253
+ if u:
254
+ try:
255
+ check_url(u)
256
+ except FetchError as exc:
257
+ raise PolicyError(f"bad URL in policy: {exc.message}") from None
258
+ for name in ("log_vkey", "origin", "policy_kid", "witness_vkeys"):
259
+ srcs = self.sources.get(name)
260
+ if srcs and not any(is_out_of_band(s) for s in srcs):
261
+ raise PolicyError(
262
+ f"refusing the pinned {name}: its only source is the API host ({', '.join(srcs)}); "
263
+ "pin it from DNS, the PyPI package or the README"
264
+ )
265
+
266
+ def key_sources(self, name: str) -> list[str]:
267
+ return list(self.sources.get(name) or ["manual"])
268
+
269
+ def summary(self) -> dict[str, Any]:
270
+ return {
271
+ "path": str(self.path) if self.path else None,
272
+ "domain": self.domain,
273
+ "origin": self.origin,
274
+ "log_vkey": self.log_vkey,
275
+ "policy_kid": self.policy_kid,
276
+ "owner_kid": self.owner_kid,
277
+ "bootstrap_card_hash": self.bootstrap_card_hash,
278
+ "trust_bundle_sha256": self.trust_bundle_sha256,
279
+ "witness_vkeys": list(self.witness_vkeys),
280
+ "quorum": self.quorum,
281
+ "max_checkpoint_age": self.max_checkpoint_age,
282
+ "witness_meta": {k: dict(v) for k, v in self.witness_meta.items()},
283
+ "checkpoint_url": self.checkpoint_url,
284
+ "tiles_url": self.tiles_url,
285
+ "card_url": self.card_url,
286
+ "consistency_url": self.consistency_url,
287
+ "mirrors": list(self.mirrors),
288
+ "sources": {k: list(v) for k, v in self.sources.items()},
289
+ "created_at": self.created_at,
290
+ }
291
+
292
+ def trust_values(self) -> dict[str, Any]:
293
+ return {f: getattr(self, f) for f in TRUST_FIELDS}
294
+
295
+ def witness_name(self, vkey: str) -> str:
296
+ return parse_witness_vkey(vkey).name
297
+
298
+ def declared(self, name: str) -> tuple[str | None, bool | None]:
299
+ """(trust_domain, independent) as the operator's card declares them."""
300
+ return declared_meta(self.witness_meta.get(name) or {})
301
+
302
+ def assessed_independent(self, name: str) -> bool:
303
+ """Did the user mark this witness independent (policy init --independent)?"""
304
+ return assessed_independent(self.witness_meta.get(name) or {})
305
+
306
+
307
+ def declared_meta(meta: dict[str, Any]) -> tuple[str | None, bool | None]:
308
+ """The card's declarations in a ``[witness_meta]`` entry. Older policy
309
+ files stored them as ``trust_domain`` / ``independent``."""
310
+ td = meta.get("declared_trust_domain", meta.get("trust_domain"))
311
+ ind = meta.get("declared_independent")
312
+ if ind is None and meta.get("independent_source") is None:
313
+ ind = meta.get("independent")
314
+ return (str(td) if td is not None else None), (ind if isinstance(ind, bool) else None)
315
+
316
+
317
+ def assessed_independent(meta: dict[str, Any]) -> bool:
318
+ return meta.get("independent") is True and meta.get("independent_source") == "manual"
319
+
320
+
321
+ def policy_digest(policy: Policy) -> str:
322
+ """SHA-256 of the policy file text (what each saved checkpoint records)."""
323
+ return hashlib.sha256(dumps(policy).encode("utf-8")).hexdigest()
324
+
325
+
326
+ def archived_log_keys(home: Path) -> dict[str, tuple[str, str]]:
327
+ """``policy_sha256 -> (origin, log_vkey)`` for the current policy file and
328
+ every archived one in ``policy-history/``, keyed both by the digest of the
329
+ file bytes and by the digest of its re-serialised form. Lets a note saved
330
+ under an earlier policy be re-verified with the log key it was saved
331
+ under."""
332
+ out: dict[str, tuple[str, str]] = {}
333
+ files = [policy_path(home)]
334
+ hist = home / "policy-history"
335
+ if hist.is_dir():
336
+ files += sorted(hist.glob("*.toml"))
337
+ for f in files:
338
+ try:
339
+ raw = f.read_bytes()
340
+ data = tomllib.loads(raw.decode("utf-8"))
341
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
342
+ continue
343
+ origin, vkey = data.get("origin"), data.get("log_vkey")
344
+ if not isinstance(origin, str) or not isinstance(vkey, str):
345
+ continue
346
+ out.setdefault(hashlib.sha256(raw).hexdigest(), (origin, vkey.strip()))
347
+ try:
348
+ out.setdefault(policy_digest(loads(raw.decode("utf-8"))), (origin, vkey.strip()))
349
+ except PolicyError:
350
+ pass
351
+ return out
352
+
353
+
354
+ def prefix_urls(prefix: str, api_base: str | None) -> dict[str, Any]:
355
+ """URLs for a C2SP tlog-tiles prefix and the AICS API base (§6.1, §12.1)."""
356
+ p = prefix if prefix.endswith("/") else prefix + "/"
357
+ out: dict[str, Any] = {"checkpoint_url": p + "checkpoint", "tiles_url": p}
358
+ if api_base:
359
+ out["consistency_url"] = api_base.rstrip("/") + "/v2/tlog/consistency?from={old}&to={new}"
360
+ return out
361
+
362
+
363
+ def _origin_host(origin: str | None) -> str | None:
364
+ if not origin or " " in origin:
365
+ return None
366
+ host = origin.split("/", 1)[0]
367
+ return host if "." in host else None
368
+
369
+
370
+ def default_urls(domain: str | None, origin: str | None) -> dict[str, Any]:
371
+ """URLs implied when no card is available: the log's tile prefix is
372
+ ``https://<origin>/`` when the origin looks like ``host/path`` (AICS-1
373
+ §12.2 recommends the origin ``api.<domain>/tiles``); the card is at
374
+ ``https://<domain>/.well-known/aics-card.json``. Without a domain the
375
+ card host is derived from the origin host (``api.<domain>`` gives
376
+ ``<domain>``, §12.2); the bootstrap card hash authenticates whatever is
377
+ fetched there."""
378
+ out: dict[str, Any] = {"checkpoint_url": "", "tiles_url": "", "card_url": "", "consistency_url": "",
379
+ "mirrors": []}
380
+ host = _origin_host(origin)
381
+ if host:
382
+ out.update(prefix_urls(f"https://{origin}/", f"https://{host}"))
383
+ elif domain:
384
+ out.update(prefix_urls(f"https://api.{domain}/tiles/", f"https://api.{domain}"))
385
+ if domain:
386
+ out["card_url"] = f"https://{domain}/.well-known/aics-card.json"
387
+ elif host:
388
+ card_host = host[len("api."):] if host.startswith("api.") and host.count(".") >= 2 else host
389
+ out["card_url"] = f"https://{card_host}/.well-known/aics-card.json"
390
+ return out
391
+
392
+
393
+ # ---------------------------------------------------------------------------
394
+ # TOML I/O (tomllib reads; the writer handles this schema only)
395
+ # ---------------------------------------------------------------------------
396
+
397
+
398
+ def _toml_str(s: str) -> str:
399
+ out = ['"']
400
+ for ch in s:
401
+ o = ord(ch)
402
+ if ch == '"':
403
+ out.append('\\"')
404
+ elif ch == "\\":
405
+ out.append("\\\\")
406
+ elif ch == "\n":
407
+ out.append("\\n")
408
+ elif ch == "\t":
409
+ out.append("\\t")
410
+ elif ch == "\r":
411
+ out.append("\\r")
412
+ elif o < 0x20 or o == 0x7F:
413
+ out.append(f"\\u{o:04x}")
414
+ else:
415
+ out.append(ch)
416
+ out.append('"')
417
+ return "".join(out)
418
+
419
+
420
+ def _toml_value(v: Any) -> str:
421
+ if isinstance(v, bool):
422
+ return "true" if v else "false"
423
+ if isinstance(v, int):
424
+ return str(v)
425
+ if isinstance(v, str):
426
+ return _toml_str(v)
427
+ if isinstance(v, dict):
428
+ return "{ " + ", ".join(f"{_toml_str(k)} = {_toml_value(x)}" for k, x in v.items()) + " }"
429
+ if isinstance(v, (list, tuple)):
430
+ if not v:
431
+ return "[]"
432
+ return "[\n" + "".join(f" {_toml_value(x)},\n" for x in v) + "]"
433
+ raise TypeError(f"cannot write {type(v).__name__} to TOML")
434
+
435
+
436
+ def dumps(policy: Policy) -> str:
437
+ lines = [
438
+ "# aics pinned policy. Trust anchors come from the out-of-band trust bundle",
439
+ "# (DNS TXT _aics.<domain>, the aics-cli package on PyPI, the public README); the",
440
+ "# witness policy from the bootstrap card the bundle pins, or by hand. Never",
441
+ "# from the API host alone. [sources] records where each value came from.",
442
+ f"version = {POLICY_VERSION}",
443
+ ]
444
+ if policy.domain:
445
+ lines.append(f"domain = {_toml_str(policy.domain)}")
446
+ lines += [f"origin = {_toml_str(policy.origin)}", f"log_vkey = {_toml_str(policy.log_vkey)}"]
447
+ for name in ("policy_kid", "owner_kid", "bootstrap_card_hash", "trust_bundle_sha256"):
448
+ v = getattr(policy, name)
449
+ if v:
450
+ lines.append(f"{name} = {_toml_str(v)}")
451
+ lines += [f"witness_vkeys = {_toml_value(policy.witness_vkeys)}", f"quorum = {policy.quorum}"]
452
+ if policy.max_checkpoint_age is not None:
453
+ lines.append(f"max_checkpoint_age = {policy.max_checkpoint_age}")
454
+ lines += [
455
+ f"checkpoint_url = {_toml_str(policy.checkpoint_url)}",
456
+ f"tiles_url = {_toml_str(policy.tiles_url)}",
457
+ f"card_url = {_toml_str(policy.card_url)}",
458
+ f"consistency_url = {_toml_str(policy.consistency_url)}",
459
+ f"mirrors = {_toml_value(policy.mirrors)}",
460
+ f"created_at = {_toml_str(policy.created_at)}",
461
+ "",
462
+ "[witness_meta]",
463
+ ]
464
+ for name in sorted(policy.witness_meta):
465
+ lines.append(f"{_toml_str(name)} = {_toml_value(policy.witness_meta[name])}")
466
+ lines += ["", "[sources]"]
467
+ for k in sorted(policy.sources):
468
+ lines.append(f"{k} = {_toml_value(list(policy.sources[k]))}")
469
+ return "\n".join(lines) + "\n"
470
+
471
+
472
+ def loads(text: str, path: Path | None = None) -> Policy:
473
+ try:
474
+ data = tomllib.loads(text)
475
+ except tomllib.TOMLDecodeError as exc:
476
+ raise PolicyError(f"policy file is not valid TOML: {exc}") from None
477
+ version = data.get("version", POLICY_VERSION)
478
+ if version != POLICY_VERSION:
479
+ raise PolicyError(f"unsupported policy version {version!r}")
480
+
481
+ def get(name: str, typ: type, default: Any) -> Any:
482
+ v = data.get(name, default)
483
+ if v is None:
484
+ return default
485
+ if not isinstance(v, typ) or (typ is int and isinstance(v, bool)):
486
+ raise PolicyError(f"policy field {name!r} has the wrong type")
487
+ return v
488
+
489
+ def get_list(name: str) -> list[str]:
490
+ v = data.get(name, [])
491
+ if isinstance(v, str):
492
+ v = [v]
493
+ if not isinstance(v, list) or not all(isinstance(x, str) for x in v):
494
+ raise PolicyError(f"policy field {name!r} must be a list of strings")
495
+ return [x.strip() for x in v if x.strip()]
496
+
497
+ if "origin" not in data or "log_vkey" not in data:
498
+ raise PolicyError("policy file needs 'origin' and 'log_vkey'")
499
+ policy_kid = get("policy_kid", str, None)
500
+ if policy_kid is None: # older spelling
501
+ legacy = get_list("policy_vkeys") or get_list("policy_vkey")
502
+ policy_kid = legacy[0] if legacy else None
503
+ sources_raw = data.get("sources", {})
504
+ if not isinstance(sources_raw, dict):
505
+ raise PolicyError("[sources] must be a table")
506
+ sources: dict[str, list[str]] = {}
507
+ for k, v in sources_raw.items():
508
+ if isinstance(v, str):
509
+ v = [v]
510
+ if not isinstance(v, list) or not all(isinstance(x, str) for x in v):
511
+ raise PolicyError(f"sources.{k} must be a list of strings")
512
+ sources[k] = v
513
+ meta_raw = data.get("witness_meta", {})
514
+ if not isinstance(meta_raw, dict) or not all(isinstance(v, dict) for v in meta_raw.values()):
515
+ raise PolicyError("[witness_meta] must map witness names to tables")
516
+ pol = Policy(
517
+ origin=get("origin", str, ""),
518
+ log_vkey=get("log_vkey", str, "").strip(),
519
+ policy_kid=policy_kid,
520
+ owner_kid=get("owner_kid", str, None),
521
+ bootstrap_card_hash=get("bootstrap_card_hash", str, None),
522
+ trust_bundle_sha256=get("trust_bundle_sha256", str, None),
523
+ witness_vkeys=get_list("witness_vkeys"),
524
+ quorum=get("quorum", int, 0),
525
+ max_checkpoint_age=get("max_checkpoint_age", int, None),
526
+ witness_meta={k: dict(v) for k, v in meta_raw.items()},
527
+ domain=get("domain", str, None),
528
+ checkpoint_url=get("checkpoint_url", str, ""),
529
+ tiles_url=get("tiles_url", str, ""),
530
+ card_url=get("card_url", str, ""),
531
+ consistency_url=get("consistency_url", str, ""),
532
+ mirrors=get_list("mirrors"),
533
+ sources=sources,
534
+ created_at=get("created_at", str, ""),
535
+ path=path,
536
+ )
537
+ pol.validate()
538
+ return pol
539
+
540
+
541
+ def policy_path(home: Path) -> Path:
542
+ return home / _home.POLICY
543
+
544
+
545
+ def load(home: Path) -> Policy:
546
+ path = policy_path(home)
547
+ try:
548
+ text = path.read_text("utf-8")
549
+ except FileNotFoundError:
550
+ raise PolicyError(
551
+ f"no pinned policy at {path}; run: aics policy init --from-dns <domain>"
552
+ ) from None
553
+ return loads(text, path)
554
+
555
+
556
+ def save(policy: Policy, home: Path, *, force: bool = False) -> Path:
557
+ """Write the policy; an existing file is only replaced with ``force``,
558
+ and then archived under ``policy-history/`` first (never lost)."""
559
+ policy.validate()
560
+ text = dumps(policy)
561
+ loads(text) # round-trip check: what we write is what we will read
562
+ _home.ensure_dir(home)
563
+ path = policy_path(home)
564
+ if path.exists():
565
+ if not force:
566
+ raise PolicyError(f"{path} exists; pass --force to replace it (the old one is archived)")
567
+ hist = _home.ensure_dir(home / "policy-history")
568
+ stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
569
+ _home.write_new(hist / f"policy-{stamp}-{os.getpid()}-{time.monotonic_ns()}.toml", path.read_bytes())
570
+ tmp = path.with_name(path.name + f".tmp-{os.getpid()}")
571
+ tmp.write_text(text, "utf-8")
572
+ os.replace(tmp, path)
573
+ policy.path = path
574
+ return path
575
+
576
+
577
+ # ---------------------------------------------------------------------------
578
+ # comparing sources
579
+ # ---------------------------------------------------------------------------
580
+
581
+
582
+ @dataclass
583
+ class SourceValues:
584
+ label: str # "dns:_aics.example", "pypi:aics-cli==0.1.0", "readme", "manual", "api:https://..."
585
+ values: dict[str, Any]
586
+
587
+
588
+ def compare_sources(sources: list[SourceValues], fields: tuple[str, ...] = TRUST_FIELDS) -> list[dict[str, Any]]:
589
+ """Per field: the value each source gives and whether they agree.
590
+ ``None`` (not published by that source) is not a disagreement; malformed
591
+ values are listed under ``errors``."""
592
+ rows: list[dict[str, Any]] = []
593
+ for f in fields:
594
+ given: dict[str, Any] = {}
595
+ normed: dict[str, Any] = {}
596
+ errors: dict[str, str] = {}
597
+ for src in sources:
598
+ v = src.values.get(f)
599
+ if v is None:
600
+ continue # not published by this source
601
+ given[src.label] = v
602
+ try:
603
+ normed[src.label] = _norm_value(f, v)
604
+ except PolicyError as exc:
605
+ errors[src.label] = exc.message
606
+ distinct = {repr(sorted(v) if isinstance(v, frozenset) else v) for v in normed.values()}
607
+ rows.append({"field": f, "values": given, "agree": len(distinct) <= 1, "errors": errors})
608
+ return rows
609
+
610
+
611
+ def malformed(rows: list[dict[str, Any]], labels: set[str] | None = None) -> list[str]:
612
+ """Messages for malformed values in ``rows`` (optionally only from ``labels``)."""
613
+ out = []
614
+ for r in rows:
615
+ for label, msg in r["errors"].items():
616
+ if labels is None or label in labels:
617
+ out.append(f"{label}: {r['field']}: {msg}")
618
+ return out
@@ -0,0 +1,34 @@
1
+ """The trust bundle shipped inside the ``aics-cli`` package on PyPI.
2
+
3
+ One of the three places the instance publishes its out-of-band trust bundle
4
+ (docs/spec/AICS-1.md §6.4; docs/DESIGN.md §4.2 item 6): DNS TXT
5
+ ``_aics.<domain>``, this constant, and a labelled block in the public README.
6
+ All three carry the same JCS bytes::
7
+
8
+ {"bootstrap_card_hash":...,"log_vkey":...,"origin":...,"owner_kid":...,"policy_kid":...,"v":1}
9
+
10
+ and README and PyPI also carry their SHA-256 (``AICS_TRUST_BUNDLE_SHA256``).
11
+
12
+ PLACEHOLDER was True until the production genesis of 2026-10-19; while it is
13
+ True the constant is ``None`` and ``aics policy init`` and ``aics policy diff``
14
+ report it as "placeholder" - not published, never counted as agreement. Since
15
+ that day both constants carry the published bundle (bytes exactly as in DNS
16
+ and the README) and ``PLACEHOLDER`` is False. From then on every command holds the pinned policy against
17
+ it (by domain, or by log origin when the policy has no domain) and refuses a
18
+ policy it contradicts; and ``policy init --from-dns`` alone meets the default
19
+ of two independent key sources. The banner should advertise the ``aics
20
+ verify`` commands only once that release is out.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ PLACEHOLDER = False
26
+
27
+ #: The domain whose bundle this is.
28
+ AICS_TRUST_BUNDLE_DOMAIN = "wesupport-ai.com"
29
+
30
+ #: JCS bytes of the bundle, exactly as published in DNS (base64url-decoded) and the README.
31
+ AICS_TRUST_BUNDLE: bytes | None = b'{"bootstrap_card_hash":"80dcd2abd1c8e51c54961336c0f5da4f416d7e97c7825aaf35003e8ff0dc6c44","log_vkey":"api.wesupport-ai.com/tiles+0e2f3a1e+ASx//oWlf4UBmbnXHfpMCsF2NrQOZuN32Q25NiTBC7fV","origin":"api.wesupport-ai.com/tiles","owner_kid":"did:key:z6MkiN89VfP5ZqFRC9epYQNjDyXQvYxrGn11r4Wrf6dvVb6K","policy_kid":"did:key:z6MkhhjWtmdwmSmAZGq1kkc8EiC1u3GmQ4eS8EEvTKfbKcze","v":1}'
32
+
33
+ #: ``hexH(J(bundle))``: lowercase hex SHA-256 of AICS_TRUST_BUNDLE.
34
+ AICS_TRUST_BUNDLE_SHA256: str | None = 'bc234cc34b1b0941d0e833c41112109fe7fe020cebbba9f58f98f3a59448da0c'