aics-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aics_cli/__init__.py +8 -0
- aics_cli/__main__.py +3 -0
- aics_cli/bundle_build.py +216 -0
- aics_cli/card.py +933 -0
- aics_cli/checkpoints.py +498 -0
- aics_cli/cli.py +298 -0
- aics_cli/commands.py +1656 -0
- aics_cli/consistency.py +280 -0
- aics_cli/dnstxt.py +319 -0
- aics_cli/encoding.py +125 -0
- aics_cli/errors.py +57 -0
- aics_cli/evidence.py +88 -0
- aics_cli/home.py +112 -0
- aics_cli/keystore.py +189 -0
- aics_cli/message.py +875 -0
- aics_cli/net.py +117 -0
- aics_cli/policy.py +618 -0
- aics_cli/published_keys.py +34 -0
- aics_cli/tiles_http.py +59 -0
- aics_cli/trust.py +243 -0
- aics_cli-0.1.0.dist-info/METADATA +420 -0
- aics_cli-0.1.0.dist-info/RECORD +25 -0
- aics_cli-0.1.0.dist-info/WHEEL +4 -0
- aics_cli-0.1.0.dist-info/entry_points.txt +3 -0
- aics_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
aics_cli/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'
|