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/tiles_http.py ADDED
@@ -0,0 +1,59 @@
1
+ """A tile reader over HTTP implementing ``aics_tlog``'s ``TileReader``
2
+ protocol (``read_tile`` / ``read_entry_bundle``), for a C2SP tlog-tiles
3
+ prefix: resources at ``<base>/tile/<L>/<N>[.p/<W>]`` and
4
+ ``<base>/tile/entries/<N>[.p/<W>]``.
5
+
6
+ 404 and 410 mean "not there" (the reader returns None, and aics-tlog falls
7
+ back from a partial to the full tile as the spec allows); any other failure,
8
+ including a redirect, raises :class:`TileSourceError`. Tiles are untrusted:
9
+ :mod:`aics_cli.consistency` reads them through ``aics_tlog.VerifiedTileReader``,
10
+ which authenticates each one up to a signed checkpoint before it is used.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from aics_tlog.tiles import entry_bundle_path, tile_path
16
+
17
+ from .errors import FetchError
18
+ from .net import Fetcher, join_url
19
+
20
+ FULL_TILE_BYTES = 256 * 32
21
+ BUNDLE_MAX_BYTES = 256 * (2 + 0xFFFF)
22
+
23
+
24
+ class TileSourceError(RuntimeError):
25
+ """A tile could not be fetched for a reason other than "not found"."""
26
+
27
+
28
+ class HttpTileReader:
29
+ def __init__(self, base_url: str, fetcher: Fetcher) -> None:
30
+ self.base_url = base_url
31
+ self.fetcher = fetcher
32
+ self._cache: dict[str, bytes | None] = {}
33
+ self.fetched: list[str] = []
34
+ self.missing: list[str] = []
35
+
36
+ def _get(self, path: str, max_bytes: int) -> bytes | None:
37
+ if path in self._cache:
38
+ return self._cache[path]
39
+ url = join_url(self.base_url, path)
40
+ try:
41
+ resp = self.fetcher.get(url, max_bytes=max_bytes)
42
+ except FetchError as exc:
43
+ raise TileSourceError(exc.message) from None
44
+ if resp.status == 200:
45
+ data: bytes | None = resp.body
46
+ self.fetched.append(path)
47
+ elif resp.status in (404, 410):
48
+ data = None
49
+ self.missing.append(path)
50
+ else:
51
+ raise TileSourceError(f"{url}: HTTP {resp.status}")
52
+ self._cache[path] = data
53
+ return data
54
+
55
+ def read_tile(self, level: int, index: int, width: int | None) -> bytes | None:
56
+ return self._get(tile_path(level, index, width), FULL_TILE_BYTES)
57
+
58
+ def read_entry_bundle(self, index: int, width: int | None) -> bytes | None:
59
+ return self._get(entry_bundle_path(index, width), BUNDLE_MAX_BYTES)
aics_cli/trust.py ADDED
@@ -0,0 +1,243 @@
1
+ """The out-of-band trust bundle (docs/spec/AICS-1.md §6.4).
2
+
3
+ The instance publishes the same bundle in three places: DNS TXT
4
+ ``_aics.<domain>``, the ``AICS_TRUST_BUNDLE`` constant in the ``aics`` package
5
+ on PyPI (:mod:`aics_cli.published_keys`), and a labelled block in the public
6
+ README. The bundle is the JCS JSON object::
7
+
8
+ {"v": 1, "origin": ..., "log_vkey": ..., "policy_kid": ...,
9
+ "owner_kid": ..., "bootstrap_card_hash": ...}
10
+
11
+ DNS carries one TXT RR ``aics1=<U64(J(bundle))>`` (character-strings are
12
+ concatenated first). Two different bundles at one name are an error, and so
13
+ is any disagreement between sources: bootstrap stops, there is no majority
14
+ vote. The API host and the card's own keys are never a source.
15
+
16
+ For compatibility with ops/dns-records.md, a TXT record
17
+ ``aics-log-vkey <vkey>`` is read as a partial source that gives only the log
18
+ key.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import re
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from aics_identity import keys as idkeys
30
+ from aics_identity.canonical import canonicalize
31
+
32
+ from .encoding import b64u_d, b64u_e
33
+ from .errors import PolicyError
34
+
35
+ BUNDLE_FIELDS = ("v", "origin", "log_vkey", "policy_kid", "owner_kid", "bootstrap_card_hash")
36
+ TRUST_FIELDS = BUNDLE_FIELDS[1:] # the values a policy pins
37
+ TXT_PREFIX = "aics1="
38
+ LEGACY_TXT_PREFIX = "aics-log-vkey"
39
+ _HEX64 = re.compile(r"[0-9a-f]{64}")
40
+ _TXT_VALUE = re.compile(r"aics1=([A-Za-z0-9_-]+)")
41
+ #: The README block's digest line, ``sha256: <hexH(J(bundle))>`` (AICS-1 §6.4).
42
+ _DIGEST_LINE = re.compile(r"^[ \t]*sha256:(.*)$", re.M)
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class TrustBundle:
47
+ origin: str
48
+ log_vkey: str
49
+ policy_kid: str
50
+ owner_kid: str
51
+ bootstrap_card_hash: str
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ return {"v": 1, "origin": self.origin, "log_vkey": self.log_vkey, "policy_kid": self.policy_kid,
55
+ "owner_kid": self.owner_kid, "bootstrap_card_hash": self.bootstrap_card_hash}
56
+
57
+ @property
58
+ def jcs(self) -> bytes:
59
+ return canonicalize(self.to_dict())
60
+
61
+ @property
62
+ def sha256(self) -> str:
63
+ """``hexH(J(bundle))``: the digest README and PyPI carry next to the bytes."""
64
+ return hashlib.sha256(self.jcs).hexdigest()
65
+
66
+ @property
67
+ def txt(self) -> str:
68
+ return TXT_PREFIX + b64u_e(self.jcs)
69
+
70
+ def values(self) -> dict[str, Any]:
71
+ return {f: getattr(self, f) for f in TRUST_FIELDS}
72
+
73
+
74
+ def _check_origin(origin: Any) -> str:
75
+ from .policy import check_origin_name
76
+
77
+ if not isinstance(origin, str) or not origin or not origin.isascii():
78
+ raise PolicyError("bundle origin must be non-empty ASCII")
79
+ if "://" in origin or origin.endswith("/"):
80
+ raise PolicyError("bundle origin must have no scheme and no trailing slash")
81
+ return check_origin_name(origin)
82
+
83
+
84
+ def _check_kid(kid: Any, what: str) -> bytes:
85
+ if not isinstance(kid, str) or not kid.startswith("did:key:z"):
86
+ raise PolicyError(f"bundle {what} must be an Ed25519 did:key")
87
+ try:
88
+ return idkeys.public_key_bytes(idkeys.parse_did_key(kid))
89
+ except ValueError as exc:
90
+ raise PolicyError(f"bundle {what}: {exc}") from None
91
+
92
+
93
+ def bundle_from_object(obj: Any) -> TrustBundle:
94
+ from .policy import parse_log_vkey
95
+
96
+ if not isinstance(obj, dict):
97
+ raise PolicyError("trust bundle must be a JSON object")
98
+ if set(obj) != set(BUNDLE_FIELDS):
99
+ missing = sorted(set(BUNDLE_FIELDS) - set(obj))
100
+ extra = sorted(set(obj) - set(BUNDLE_FIELDS))
101
+ raise PolicyError(f"trust bundle must have exactly {', '.join(BUNDLE_FIELDS)} "
102
+ f"(missing {missing}, unknown {extra})")
103
+ if obj["v"] != 1 or isinstance(obj["v"], bool):
104
+ raise PolicyError(f"unsupported trust bundle version {obj['v']!r}")
105
+ origin = _check_origin(obj["origin"])
106
+ if not isinstance(obj["log_vkey"], str):
107
+ raise PolicyError("bundle log_vkey must be a string")
108
+ log_vk = parse_log_vkey(obj["log_vkey"])
109
+ policy_pub = _check_kid(obj["policy_kid"], "policy_kid")
110
+ owner_pub = _check_kid(obj["owner_kid"], "owner_kid")
111
+ if len({log_vk.public_key, policy_pub, owner_pub}) != 3:
112
+ raise PolicyError("log, policy and owner keys must be distinct (AICS-1 §3.1)")
113
+ h = obj["bootstrap_card_hash"]
114
+ if not isinstance(h, str) or not _HEX64.fullmatch(h):
115
+ raise PolicyError("bundle bootstrap_card_hash must be 64 lowercase hex characters")
116
+ return TrustBundle(origin=origin, log_vkey=obj["log_vkey"], policy_kid=obj["policy_kid"],
117
+ owner_kid=obj["owner_kid"], bootstrap_card_hash=h)
118
+
119
+
120
+ def bundle_from_jcs(raw: bytes) -> TrustBundle:
121
+ """Bundle bytes must be exactly the JCS serialization of the object."""
122
+ from .card import loads_strict
123
+
124
+ try:
125
+ obj = loads_strict(raw)
126
+ except (ValueError, UnicodeDecodeError) as exc:
127
+ raise PolicyError(f"trust bundle is not strict JSON: {exc}") from None
128
+ bundle = bundle_from_object(obj)
129
+ if bundle.jcs != raw:
130
+ raise PolicyError("trust bundle bytes are not the JCS (RFC 8785) serialization of the object")
131
+ return bundle
132
+
133
+
134
+ def bundle_from_text(text: str) -> TrustBundle:
135
+ """``aics1=<U64>``, bare ``U64``, JSON text, or a file holding one of them
136
+ (e.g. the README block saved to a file).
137
+
138
+ The README block also carries the digest of the bundle bytes (AICS-1 §6.4:
139
+ "README and PyPI carry the same bytes and their hexH digest"); a stated
140
+ digest is checked exactly like the PyPI constant's
141
+ (:func:`published_bundle`), and a block whose ``sha256:`` line contradicts
142
+ its bytes, or cannot be read, is refused before anything is pinned."""
143
+ t = text.strip()
144
+ if t and "\n" not in t and len(t) < 4096 and not t.startswith(("{", TXT_PREFIX)):
145
+ p = Path(t).expanduser()
146
+ if p.is_file():
147
+ return bundle_from_text(p.read_text("utf-8"))
148
+ values = set(_TXT_VALUE.findall(t))
149
+ if len(values) > 1:
150
+ raise PolicyError(f"the text holds {len(values)} different aics1= trust bundles; give exactly one")
151
+ if values:
152
+ bundle = bundle_from_u64(values.pop())
153
+ check_stated_digest(t, bundle)
154
+ return bundle
155
+ if t.startswith("{"):
156
+ return bundle_from_jcs(t.encode("utf-8"))
157
+ return bundle_from_u64(t)
158
+
159
+
160
+ def check_stated_digest(text: str, bundle: TrustBundle) -> None:
161
+ """Refuse ``text`` (a README block) when its ``sha256:`` line is not
162
+ ``hexH(J(bundle))``. A bare ``aics1=`` value, the DNS form, states no
163
+ digest and is not refused for that."""
164
+ stated = {value.strip() for value in _DIGEST_LINE.findall(text)}
165
+ if not stated:
166
+ return
167
+ if len(stated) > 1:
168
+ raise PolicyError("the README block states more than one sha256 digest; nothing was pinned")
169
+ digest = stated.pop()
170
+ if not _HEX64.fullmatch(digest):
171
+ raise PolicyError("the README block's sha256 line is not 64 lowercase hex characters; nothing was pinned")
172
+ if digest != bundle.sha256:
173
+ raise PolicyError("the README block's sha256 line does not match its own trust bundle bytes "
174
+ f"(stated {digest}, computed {bundle.sha256}; AICS-1 §6.4); nothing was pinned")
175
+
176
+
177
+ def bundle_from_u64(value: str) -> TrustBundle:
178
+ try:
179
+ raw = b64u_d(value.strip())
180
+ except ValueError as exc:
181
+ raise PolicyError(f"trust bundle is not base64url: {exc}") from None
182
+ return bundle_from_jcs(raw)
183
+
184
+
185
+ @dataclass
186
+ class TxtBundle:
187
+ bundle: TrustBundle | None = None
188
+ legacy_log_vkey: str | None = None
189
+ notes: list[str] = field(default_factory=list)
190
+
191
+
192
+ def parse_txt(records: list[str]) -> TxtBundle:
193
+ """Read the TXT records at ``_aics.<domain>`` (each already concatenated)."""
194
+ from .policy import parse_log_vkey
195
+ from aics_tlog import format_vkey
196
+
197
+ out = TxtBundle()
198
+ bundles: dict[bytes, TrustBundle] = {}
199
+ legacy: set[str] = set()
200
+ for rec in records:
201
+ r = rec.strip()
202
+ if r.startswith(TXT_PREFIX):
203
+ b = bundle_from_u64(r[len(TXT_PREFIX):])
204
+ bundles[b.jcs] = b
205
+ elif r.startswith(LEGACY_TXT_PREFIX + " ") or r.startswith(LEGACY_TXT_PREFIX + "="):
206
+ v = r[len(LEGACY_TXT_PREFIX) + 1:].strip()
207
+ legacy.add(format_vkey(parse_log_vkey(v)))
208
+ if len(bundles) > 1:
209
+ raise PolicyError(f"{len(bundles)} different aics1= trust bundles are published at one name")
210
+ if len(legacy) > 1:
211
+ raise PolicyError("different aics-log-vkey records are published at one name")
212
+ if bundles:
213
+ out.bundle = next(iter(bundles.values()))
214
+ if legacy:
215
+ out.legacy_log_vkey = next(iter(legacy))
216
+ out.notes.append("aics-log-vkey record (ops/dns-records.md form): gives the log key only")
217
+ if out.bundle is not None and out.bundle.log_vkey != out.legacy_log_vkey:
218
+ raise PolicyError("the aics1= bundle and the aics-log-vkey record give different log keys")
219
+ if out.bundle is None and out.legacy_log_vkey is None:
220
+ raise PolicyError(f"no {TXT_PREFIX}<bundle> TXT record")
221
+ return out
222
+
223
+
224
+ def published_bundle(domain: str | None, origin: str | None = None) -> tuple[TrustBundle | None, str]:
225
+ """The bundle constant shipped in this package, and a status word.
226
+
227
+ It applies, and must then agree, whenever the domain is the one it is
228
+ published for *or* the log origin is its origin - a policy pinned from
229
+ the README alone has no domain, but the same origin."""
230
+ from . import published_keys as pk
231
+
232
+ if pk.AICS_TRUST_BUNDLE is None:
233
+ return None, "placeholder"
234
+ raw = pk.AICS_TRUST_BUNDLE
235
+ raw_bytes = raw.encode("utf-8") if isinstance(raw, str) else bytes(raw)
236
+ bundle = bundle_from_jcs(raw_bytes)
237
+ if pk.AICS_TRUST_BUNDLE_SHA256 and pk.AICS_TRUST_BUNDLE_SHA256 != bundle.sha256:
238
+ raise PolicyError("this package's AICS_TRUST_BUNDLE does not match its own published digest")
239
+ if (domain and domain == pk.AICS_TRUST_BUNDLE_DOMAIN) or (origin and origin == bundle.origin):
240
+ return bundle, "published"
241
+ if not domain and not origin:
242
+ return None, f"not compared (no domain or origin given; it is published for {pk.AICS_TRUST_BUNDLE_DOMAIN})"
243
+ return None, f"published for {pk.AICS_TRUST_BUNDLE_DOMAIN} ({bundle.origin}), not {domain or origin}"