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/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])
|