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.
@@ -0,0 +1,498 @@
1
+ """Checkpoints: fetch, verify against the pinned policy, store append-only.
2
+
3
+ Verification of a checkpoint note (c2sp.org/tlog-checkpoint):
4
+
5
+ 1. the log signature, Ed25519 over the note text, from the pinned log key,
6
+ and the pinned origin (``aics_tlog.verify_checkpoint``, which also rejects
7
+ duplicate signature lines). A note that passes this is *log-signed*: it
8
+ is what the log said, whatever else is wrong with it;
9
+ 2. the AICS-1 profile (§6.2): exactly three text lines (no extension
10
+ lines), a size within 2^53-1, and the empty-tree root for size 0
11
+ (§14.2 step 1);
12
+ 3. witness cosignatures (c2sp.org/tlog-cosignature, AICS-1 §6.3): a line
13
+ under a pinned witness's (name, key ID) that does not verify rejects the
14
+ note (``bad_cosignature``); each valid cosignature counts toward the
15
+ quorum only if ``now - max_checkpoint_age <= timestamp <= now + 30``; one
16
+ public key counts once. With quorum 0 the head's freshness is reported
17
+ as unproven: a log-only note carries no authenticated time.
18
+
19
+ :func:`check_note` keeps these apart: ``cp`` is set for every log-signed
20
+ note, ``ok`` only for one that also meets the profile and the witness
21
+ policy. Callers that accept a head test ``ok``; callers that reason about
22
+ history (the saved head, saved notes, gossip evidence) need only ``cp`` -
23
+ a junk line under some witness key must not make the log's own signature
24
+ disappear.
25
+
26
+ Saved notes live in ``~/.aics/checkpoints/<origin>/<size>-<sha256>.note``;
27
+ a file is created once and never rewritten. Every save appends a line to
28
+ ``index.jsonl`` with a role: ``head`` (the local trusted head: the first
29
+ checkpoint, or one proven consistent with the previous head - AICS-1
30
+ §14.1 step 4), ``evidence`` (verified but not adopted, e.g. a fork) or
31
+ ``observed`` (verified by status checks, or an older head kept as
32
+ historical), and the log key it was verified with. A ``reset`` line
33
+ (``--reset-history``) drops the files it lists from continuity checks; the
34
+ files themselves are kept.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import hashlib
40
+ import json
41
+ import time
42
+ from dataclasses import dataclass, field
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ from aics_tlog import EMPTY_HASH, Note, parse_checkpoint, parse_note, verify_checkpoint, verify_cosignature
47
+
48
+ from . import home as _home
49
+ from .encoding import b64e
50
+ from .errors import AicsError, FetchError
51
+ from .net import Fetcher
52
+ from .policy import Policy, parse_log_vkey, policy_digest
53
+
54
+ DEFAULT_MAX_SKEW = 30 # AICS-1 §6.3: timestamp <= now + 30
55
+ UINT_MAX = 9007199254740991
56
+ NOTE_MAX_BYTES = 1024 * 1024
57
+ JSON_MAX_DEPTH = 32
58
+
59
+
60
+ @dataclass
61
+ class Cp:
62
+ """A checkpoint whose log signature verified against a pinned log key."""
63
+
64
+ note_text: str
65
+ origin: str
66
+ size: int
67
+ root: bytes
68
+ source: str
69
+ log_vkey: str | None = None # the pinned log key the signature verified with
70
+
71
+ @property
72
+ def sha256(self) -> str:
73
+ return hashlib.sha256(self.note_text.encode("utf-8")).hexdigest()
74
+
75
+ @property
76
+ def root_b64(self) -> str:
77
+ return b64e(self.root)
78
+
79
+ def brief(self) -> dict[str, Any]:
80
+ return {"origin": self.origin, "size": self.size, "root": self.root_b64,
81
+ "sha256": self.sha256, "source": self.source}
82
+
83
+ def evidence(self) -> dict[str, Any]:
84
+ d = self.brief()
85
+ d["note"] = self.note_text
86
+ if self.log_vkey:
87
+ d["log_vkey"] = self.log_vkey
88
+ return d
89
+
90
+
91
+ @dataclass
92
+ class NoteCheck:
93
+ ok: bool
94
+ cp: Cp | None = None # set for every log-signed note, even one that is otherwise invalid
95
+ error: str | None = None
96
+ # malformed | bad_signature | wrong_origin (not log-signed);
97
+ # bad_empty_root | bad_cosignature | quorum (log-signed, rejected)
98
+ reason: str | None = None
99
+ log_ok: bool = False
100
+ witness_ok: bool = False # no invalid cosignature line and the quorum is met
101
+ witnesses: list[dict[str, Any]] = field(default_factory=list)
102
+ counted: int = 0
103
+ quorum: int = 0
104
+ max_age: int | None = None
105
+ freshness: str = "unproven" # fresh | unproven | historical
106
+ declared_trust_domains: list[str] = field(default_factory=list)
107
+ declared_independent: int = 0
108
+ assessed_independent: list[str] = field(default_factory=list)
109
+ warnings: list[str] = field(default_factory=list)
110
+
111
+ def report(self) -> dict[str, Any]:
112
+ d: dict[str, Any] = {
113
+ "log_signature": "ok" if self.log_ok else "failed",
114
+ "witness_policy_met": self.witness_ok,
115
+ "witnesses": self.witnesses,
116
+ "fresh_cosignatures": self.counted,
117
+ "quorum": self.quorum,
118
+ "max_checkpoint_age": self.max_age,
119
+ "freshness": self.freshness,
120
+ # what the operator's card says about the counted witnesses: claims, not facts (AICS-1 §6.3)
121
+ "declared_trust_domains": self.declared_trust_domains,
122
+ "declared_independent_witnesses": self.declared_independent,
123
+ # witnesses the user marked independent (policy init --independent)
124
+ "assessed_independent_witnesses": self.assessed_independent,
125
+ }
126
+ if self.reason:
127
+ d["reason"] = self.reason
128
+ if self.cp:
129
+ d.update(origin=self.cp.origin, size=self.cp.size, root=self.cp.root_b64, note_sha256=self.cp.sha256)
130
+ return d
131
+
132
+
133
+ def _parse(note_text: str) -> tuple[Note | None, Any, NoteCheck | None]:
134
+ try:
135
+ note: Note = parse_note(note_text)
136
+ cp = parse_checkpoint(note.text)
137
+ except ValueError as exc:
138
+ return None, None, NoteCheck(ok=False, error=f"not a signed checkpoint note: {exc}", reason="malformed")
139
+ if cp.extension_lines:
140
+ return None, None, NoteCheck(
141
+ ok=False, reason="malformed",
142
+ error="an AICS checkpoint has exactly three text lines (AICS-1 §6.2); this one has extensions")
143
+ if cp.size > UINT_MAX:
144
+ return None, None, NoteCheck(ok=False, reason="malformed", error="tree size exceeds 2^53-1")
145
+ return note, cp, None
146
+
147
+
148
+ def log_signed(note_text: str, *, origin: str, log_vkey: str, source: str) -> tuple[Cp | None, str | None, str]:
149
+ """Authenticate a note by the log signature alone, under ``log_vkey``
150
+ and ``origin``. Returns (cp, error, reason)."""
151
+ note, cp, bad = _parse(note_text)
152
+ if bad is not None:
153
+ return None, bad.error, "malformed"
154
+ res = verify_checkpoint(note, parse_log_vkey(log_vkey), [], 0, expected_origin=origin)
155
+ if not res.log_ok:
156
+ reason = "wrong_origin" if res.origin is not None and res.origin != origin else "bad_signature"
157
+ return None, res.error or "checkpoint verification failed", reason
158
+ return Cp(note_text=note_text, origin=cp.origin, size=cp.size, root=cp.root, source=source,
159
+ log_vkey=log_vkey), None, ""
160
+
161
+
162
+ def check_note(
163
+ note_text: str,
164
+ policy: Policy,
165
+ *,
166
+ source: str,
167
+ now: int,
168
+ require_fresh: bool = True,
169
+ max_skew: int = DEFAULT_MAX_SKEW,
170
+ ) -> NoteCheck:
171
+ """Verify one signed checkpoint note against ``policy``.
172
+
173
+ ``require_fresh=False`` counts every valid cosignature regardless of its
174
+ age (for notes judged as historical statements, e.g. gossip evidence).
175
+ """
176
+ note, cp, bad = _parse(note_text)
177
+ if bad is not None:
178
+ return bad
179
+ chk = NoteCheck(ok=False, quorum=policy.quorum, max_age=policy.max_checkpoint_age if require_fresh else None)
180
+ res = verify_checkpoint(note, policy.log_key(), [], 0, expected_origin=policy.origin)
181
+ if not res.log_ok:
182
+ chk.error = res.error or "checkpoint verification failed"
183
+ chk.reason = "wrong_origin" if res.origin is not None and res.origin != policy.origin else "bad_signature"
184
+ return chk
185
+ chk.log_ok = True
186
+ chk.cp = Cp(note_text=note_text, origin=cp.origin, size=cp.size, root=cp.root, source=source,
187
+ log_vkey=policy.log_vkey)
188
+
189
+ # Witness accounting: every line under a pinned witness's (name, key ID)
190
+ # must be a valid cosignature of this checkpoint; then age, and each
191
+ # public key counts once.
192
+ invalid: list[str] = []
193
+ counted_keys: set[bytes] = set()
194
+ for wk in policy.witness_keys():
195
+ lines = [s for s in note.signatures if s.name == wk.name and s.key_id == wk.key_id]
196
+ if not lines:
197
+ chk.witnesses.append({"name": wk.name, "status": "absent"})
198
+ continue
199
+ blob = lines[0].signature
200
+ try:
201
+ valid = verify_cosignature(blob, wk.name, wk.public_key, cp.origin, cp.size, cp.root, cp.extension_lines)
202
+ except (ValueError, TypeError):
203
+ valid = False
204
+ if not valid:
205
+ chk.witnesses.append({"name": wk.name, "status": "invalid"})
206
+ invalid.append(wk.name)
207
+ continue
208
+ ts = int.from_bytes(blob[:8], "big")
209
+ entry: dict[str, Any] = {"name": wk.name, "timestamp": ts, "age_seconds": now - ts}
210
+ if require_fresh and ts > now + max_skew:
211
+ entry["status"] = "future"
212
+ elif require_fresh and policy.max_checkpoint_age is not None and now - ts > policy.max_checkpoint_age:
213
+ entry["status"] = "stale"
214
+ elif wk.public_key in counted_keys:
215
+ entry["status"] = "duplicate-key"
216
+ else:
217
+ entry["status"] = "fresh" if require_fresh else "valid"
218
+ counted_keys.add(wk.public_key)
219
+ domain, independent = policy.declared(wk.name)
220
+ if domain:
221
+ chk.declared_trust_domains.append(domain)
222
+ if independent is True:
223
+ chk.declared_independent += 1
224
+ if policy.assessed_independent(wk.name):
225
+ chk.assessed_independent.append(wk.name)
226
+ chk.witnesses.append(entry)
227
+ chk.declared_trust_domains = sorted(set(chk.declared_trust_domains))
228
+ chk.counted = len(counted_keys)
229
+ chk.witness_ok = not invalid and chk.counted >= policy.quorum
230
+
231
+ # The verdict, most serious first.
232
+ if cp.size == 0 and cp.root != EMPTY_HASH:
233
+ chk.reason = "bad_empty_root"
234
+ chk.error = ("the log signed a checkpoint of size 0 whose root is not SHA-256(\"\"), which no log can "
235
+ "produce (AICS-1 §14.2 step 1)")
236
+ return chk
237
+ if invalid:
238
+ chk.reason = "bad_cosignature"
239
+ chk.error = (f"the log signature is valid, but the line under pinned witness {', '.join(invalid)} is not a "
240
+ "valid cosignature of this checkpoint")
241
+ return chk
242
+ if chk.counted < policy.quorum:
243
+ late = [w for w in chk.witnesses if w["status"] in ("stale", "future")]
244
+ detail = "; ".join(f"{w['name']} {w['status']} (age {w['age_seconds']}s)" for w in late)
245
+ what = "fresh " if require_fresh else ""
246
+ chk.error = f"witness quorum not met: {chk.counted} valid {what}cosignatures < quorum {policy.quorum}"
247
+ if detail:
248
+ chk.error += f": {detail}"
249
+ chk.reason = "quorum"
250
+ return chk
251
+ if not require_fresh:
252
+ chk.freshness = "historical"
253
+ elif policy.quorum == 0:
254
+ chk.freshness = "unproven"
255
+ chk.warnings.append(
256
+ "witness quorum 0: the head carries no authenticated time, so its freshness is unproven, "
257
+ "and single view (G5) is promised, not proven"
258
+ )
259
+ else:
260
+ chk.freshness = "fresh"
261
+ if chk.assessed_independent:
262
+ chk.warnings.append(
263
+ f"single view (G5) rests on your own assessment that {', '.join(chk.assessed_independent)} "
264
+ "is independent and on the honest quorum-intersection assumption (AICS-1 §6.3)"
265
+ )
266
+ elif chk.declared_independent:
267
+ chk.warnings.append(
268
+ f"single view (G5) stays promised: the card declares {chk.declared_independent} counted witness(es) "
269
+ "independent, but independence is the operator's own claim and needs outside assessment "
270
+ "(AICS-1 §6.3); mark a witness you assessed yourself with: aics policy init --independent NAME"
271
+ )
272
+ else:
273
+ chk.warnings.append(
274
+ "no counted witness is independent: single view (G5) stays promised (AICS-1 §6.3)"
275
+ )
276
+ chk.ok = True
277
+ return chk
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # fetching
282
+ # ---------------------------------------------------------------------------
283
+
284
+
285
+ @dataclass
286
+ class Fetched:
287
+ note_text: str
288
+ url: str
289
+ api_claims: dict[str, Any] = field(default_factory=dict)
290
+
291
+
292
+ _NOTE_FIELDS = ("signed_checkpoint", "checkpoint", "note", "signed_note")
293
+ _KEY_FIELDS = ("server_vkey", "log_vkey", "vkey")
294
+
295
+
296
+ def extract_note(body: bytes, url: str) -> Fetched:
297
+ """The response is the signed note itself (c2sp.org/tlog-tiles
298
+ ``/checkpoint``), or JSON carrying it in ``signed_checkpoint`` (the
299
+ current server's ``/v2/tlog/checkpoint``). Keys found in the JSON are
300
+ recorded only to be compared, never used."""
301
+ from .card import check_json_depth
302
+
303
+ try:
304
+ text = body.decode("utf-8")
305
+ except UnicodeDecodeError:
306
+ raise AicsError(f"{url}: checkpoint response is not UTF-8") from None
307
+ stripped = text.lstrip()
308
+ if stripped.startswith("{"):
309
+ try:
310
+ check_json_depth(text, JSON_MAX_DEPTH)
311
+ obj = json.loads(text)
312
+ except RecursionError:
313
+ raise AicsError(f"{url}: invalid JSON: nested too deeply") from None
314
+ except ValueError as exc:
315
+ raise AicsError(f"{url}: invalid JSON: {exc}") from None
316
+ if not isinstance(obj, dict):
317
+ raise AicsError(f"{url}: unexpected JSON")
318
+ for k in _NOTE_FIELDS:
319
+ v = obj.get(k)
320
+ if isinstance(v, str) and "\n\n" in v:
321
+ claims = {kk: obj[kk] for kk in _KEY_FIELDS if isinstance(obj.get(kk), str)}
322
+ return Fetched(note_text=v, url=url, api_claims=claims)
323
+ raise AicsError(f"{url}: JSON response has no signed checkpoint note")
324
+ return Fetched(note_text=text, url=url)
325
+
326
+
327
+ def fetch_checkpoint(fetcher: Fetcher, url: str) -> Fetched:
328
+ resp = fetcher.get(url, max_bytes=NOTE_MAX_BYTES, accept="text/plain, application/json;q=0.5")
329
+ if resp.status != 200:
330
+ raise FetchError(f"{url}: HTTP {resp.status}")
331
+ return extract_note(resp.body, url)
332
+
333
+
334
+ def api_key_warnings(fetched: Fetched, policy: Policy) -> list[str]:
335
+ from .encoding import escape_untrusted
336
+
337
+ out: list[str] = []
338
+ for k, v in fetched.api_claims.items():
339
+ if v.strip() != policy.log_vkey:
340
+ out.append(
341
+ f"the API response carries {k}={escape_untrusted(v.strip())!r}, which differs from the pinned log "
342
+ "key; it was ignored (keys are never taken from the API host)"
343
+ )
344
+ return out
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # append-only store
349
+ # ---------------------------------------------------------------------------
350
+
351
+
352
+ @dataclass
353
+ class Saved:
354
+ path: Path
355
+ created: bool
356
+
357
+
358
+ @dataclass
359
+ class SavedNote:
360
+ """A saved note file and the index records that mention it."""
361
+
362
+ path: Path
363
+ text: str
364
+ file: str # path relative to the store root, as the index records it
365
+ records: list[dict[str, Any]] = field(default_factory=list)
366
+
367
+ def recorded_log_vkeys(self) -> list[str]:
368
+ """Log keys this note was verified with when it was saved, newest first."""
369
+ out: list[str] = []
370
+ for rec in reversed(self.records):
371
+ v = rec.get("log_vkey")
372
+ if isinstance(v, str) and v not in out:
373
+ out.append(v)
374
+ return out
375
+
376
+ def policy_digests(self) -> list[str]:
377
+ out: list[str] = []
378
+ for rec in reversed(self.records):
379
+ v = rec.get("policy_sha256")
380
+ if isinstance(v, str) and v not in out:
381
+ out.append(v)
382
+ return out
383
+
384
+
385
+ class CheckpointStore:
386
+ def __init__(self, root: Path) -> None:
387
+ self.root = root
388
+
389
+ @property
390
+ def index(self) -> Path:
391
+ return self.root / "index.jsonl"
392
+
393
+ def records(self) -> list[dict[str, Any]]:
394
+ return _home.read_jsonl(self.index)
395
+
396
+ def save(self, cp: Cp, *, now: int, role: str = "head", continuity: str | None = None,
397
+ policy: Policy | None = None) -> Saved:
398
+ """Save the exact note bytes (never rewritten) and log the observation
399
+ with the digest of the policy it was verified under and the log key
400
+ (AICS-1 §14.1 step 5)."""
401
+ _home.ensure_dir(self.root)
402
+ path = self.root / _home.slug(cp.origin) / f"{cp.size}-{cp.sha256}.note"
403
+ created = _home.write_new(path, cp.note_text.encode("utf-8"))
404
+ _home.append_jsonl(self.index, {
405
+ "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
406
+ "role": role,
407
+ "continuity": continuity,
408
+ "policy_sha256": policy_digest(policy) if policy is not None else None,
409
+ "log_vkey": cp.log_vkey or (policy.log_vkey if policy is not None else None),
410
+ "source": cp.source,
411
+ "origin": cp.origin,
412
+ "size": cp.size,
413
+ "root": cp.root_b64,
414
+ "sha256": cp.sha256,
415
+ "file": str(path.relative_to(self.root)),
416
+ "new": created,
417
+ })
418
+ return Saved(path=path, created=created)
419
+
420
+ def reset(self, origin: str, *, now: int, reason: str, files: list[str], policy: Policy | None = None) -> None:
421
+ """Record that ``files`` (relative paths) no longer take part in
422
+ continuity checks for ``origin``. Nothing is deleted."""
423
+ _home.ensure_dir(self.root)
424
+ _home.append_jsonl(self.index, {
425
+ "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
426
+ "role": "reset",
427
+ "origin": origin,
428
+ "reason": reason,
429
+ "dropped": sorted(set(files)),
430
+ "policy_sha256": policy_digest(policy) if policy is not None else None,
431
+ })
432
+
433
+ @staticmethod
434
+ def _dropped_after(records: list[dict[str, Any]], origin: str) -> list[set[str]]:
435
+ """For each position i, the files dropped by reset records after i:
436
+ a head record is void once a later reset drops its file (saving the
437
+ same note again later writes a new record, which counts on its own)."""
438
+ out: list[set[str]] = [set() for _ in records]
439
+ acc: set[str] = set()
440
+ for i in range(len(records) - 1, -1, -1):
441
+ out[i] = set(acc)
442
+ rec = records[i]
443
+ if rec.get("role") == "reset" and rec.get("origin") == origin:
444
+ acc |= {str(f) for f in rec.get("dropped") or []}
445
+ return out
446
+
447
+ def head(self, origin: str) -> SavedNote | None:
448
+ """The note of the latest ``head`` record for ``origin`` that no
449
+ later reset dropped."""
450
+ recs = self.records()
451
+ dropped = self._dropped_after(recs, origin)
452
+ for i in range(len(recs) - 1, -1, -1):
453
+ rec = recs[i]
454
+ if rec.get("origin") != origin or rec.get("role", "head") != "head":
455
+ continue
456
+ file = str(rec.get("file", ""))
457
+ if not file or file in dropped[i]:
458
+ continue
459
+ p = self.root / file
460
+ try:
461
+ text = p.read_text("utf-8")
462
+ except (OSError, UnicodeDecodeError):
463
+ continue
464
+ return SavedNote(path=p, text=text, file=file, records=[r for r in recs if r.get("file") == file])
465
+ return None
466
+
467
+ def notes(self, origin: str) -> list[SavedNote]:
468
+ """Every saved note for ``origin`` that is not dropped by a reset,
469
+ smallest size first."""
470
+ d = self.root / _home.slug(origin)
471
+ if not d.is_dir():
472
+ return []
473
+ recs = self.records()
474
+ by_file: dict[str, list[dict[str, Any]]] = {}
475
+ last: dict[str, str] = {} # file -> "save" | "reset"
476
+ for rec in recs:
477
+ if rec.get("role") == "reset" and rec.get("origin") == origin:
478
+ for f in rec.get("dropped") or []:
479
+ last[str(f)] = "reset"
480
+ elif rec.get("file") is not None:
481
+ by_file.setdefault(str(rec["file"]), []).append(rec)
482
+ last[str(rec["file"])] = "save"
483
+ out: list[tuple[int, SavedNote]] = []
484
+ for p in d.glob("*.note"):
485
+ rel = str(p.relative_to(self.root))
486
+ if last.get(rel) == "reset":
487
+ continue
488
+ try:
489
+ size = int(p.name.split("-", 1)[0])
490
+ text = p.read_text("utf-8")
491
+ except (ValueError, OSError, UnicodeDecodeError):
492
+ continue
493
+ out.append((size, SavedNote(path=p, text=text, file=rel, records=by_file.get(rel, []))))
494
+ out.sort(key=lambda t: (t[0], t[1].path.name))
495
+ return [n for _, n in out]
496
+
497
+ def count(self, origin: str) -> int:
498
+ return len(self.notes(origin))