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/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}"
|