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/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""aics - verify an AICS log, its assurance card and your own keys yourself.
|
|
2
|
+
|
|
3
|
+
A thin CLI over ``aics-tlog`` and ``aics-identity``; keys are pinned out of
|
|
4
|
+
band (docs/spec/AICS-1.md §6.4), never taken from the server being checked.
|
|
5
|
+
Entry point: ``aics`` (:func:`aics_cli.cli.main`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
aics_cli/__main__.py
ADDED
aics_cli/bundle_build.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""``aics trust build``: the production trust bundle from the operator's own files (runbook step 5).
|
|
2
|
+
|
|
3
|
+
The operator holds two files at launch: ``pubkeys.txt``, which aics-server
|
|
4
|
+
writes for out-of-band publication (``origin``, ``log``, ``policy`` and
|
|
5
|
+
``owner`` lines), and the bootstrap card exactly as the server serves it
|
|
6
|
+
after the owner logged it. This command builds the AICS-1 §6.4 bundle
|
|
7
|
+
``{v, origin, log_vkey, policy_kid, owner_kid, bootstrap_card_hash}`` from
|
|
8
|
+
them and prints the four forms that must carry the same bytes: the JCS
|
|
9
|
+
bundle, the TXT value ``aics1=<U64(J(bundle))>``, its ``hexH`` digest and the
|
|
10
|
+
README block. It refuses before printing anything unless:
|
|
11
|
+
|
|
12
|
+
* ``pubkeys.txt`` gives each of origin, log, policy and owner exactly once
|
|
13
|
+
(a repeated label must repeat the same value; ``policy-kid``/``owner-kid``
|
|
14
|
+
and ``log-kid``, when present, must name the same keys), the three keys
|
|
15
|
+
are distinct Ed25519 keys and the owner key exists;
|
|
16
|
+
* the card file is ``J(card)`` - its bytes are the RFC 8785 serialization of
|
|
17
|
+
the object they parse to - is a §13.1 card, is signed by the policy key,
|
|
18
|
+
and states the origin, log, policy and owner keys of ``pubkeys.txt``;
|
|
19
|
+
* a policy leaf of kind ``card`` names ``hexH`` of exactly those bytes (and
|
|
20
|
+
their size), in the ``/v2/policies`` listing fetched from the card's
|
|
21
|
+
``api_base`` (or ``--policies-url``) or read from ``--policies-file``;
|
|
22
|
+
when ``pubkeys.txt`` states ``charter-sha256``, leaf 0 must be that charter.
|
|
23
|
+
|
|
24
|
+
The listing is the server's own claim: it says which leaf names the card,
|
|
25
|
+
and runbook step 4 has already checked that the server serves the logged
|
|
26
|
+
card. Nothing private is read; the key files never leave the server.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from aics_identity import keys as idkeys
|
|
38
|
+
from aics_identity.canonical import canonicalize
|
|
39
|
+
|
|
40
|
+
from . import card as cardmod
|
|
41
|
+
from .errors import AicsError, PolicyError, UsageError
|
|
42
|
+
from .net import join_url
|
|
43
|
+
from .policy import parse_log_vkey, parse_policy_key
|
|
44
|
+
from .trust import TrustBundle, bundle_from_object, bundle_from_text, bundle_from_u64
|
|
45
|
+
|
|
46
|
+
#: The labels of pubkeys.txt the bundle takes, and the bundle member each fills.
|
|
47
|
+
PUBKEY_FIELDS = {"origin": "origin", "log": "log_vkey", "policy": "policy_kid", "owner": "owner_kid"}
|
|
48
|
+
#: Lines that repeat a key under another label (aics-server writes both forms).
|
|
49
|
+
PUBKEY_ALIASES = {"policy-kid": "policy", "owner-kid": "owner"}
|
|
50
|
+
README_LABEL = "AICS trust bundle (AICS-1 §6.4), the same bytes as the DNS TXT record _aics.<domain>:"
|
|
51
|
+
_HEX64 = re.compile(r"[0-9a-f]{64}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read(path: str, what: str) -> bytes:
|
|
55
|
+
try:
|
|
56
|
+
return Path(path).expanduser().read_bytes()
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise AicsError(f"cannot read the {what} {path}: {exc}") from None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_pubkeys(text: str) -> dict[str, str]:
|
|
62
|
+
"""``label value`` lines (``#`` comments) as aics-server's ``pubkeys_text`` writes them."""
|
|
63
|
+
seen: dict[str, str] = {}
|
|
64
|
+
for n, raw in enumerate(text.splitlines(), 1):
|
|
65
|
+
line = raw.strip()
|
|
66
|
+
if not line or line.startswith("#"):
|
|
67
|
+
continue
|
|
68
|
+
label, _, value = line.partition(" ")
|
|
69
|
+
value = value.strip()
|
|
70
|
+
if not value or " " in value:
|
|
71
|
+
raise PolicyError(f"pubkeys.txt line {n}: expected 'label value', got {line[:80]!r}")
|
|
72
|
+
if label in seen and seen[label] != value:
|
|
73
|
+
raise PolicyError(f"pubkeys.txt gives two different values for {label!r}; nothing was built")
|
|
74
|
+
seen[label] = value
|
|
75
|
+
missing = [label for label in PUBKEY_FIELDS if label not in seen]
|
|
76
|
+
if missing:
|
|
77
|
+
raise PolicyError(f"pubkeys.txt has no {', '.join(missing)} line; it is the file aics-server writes "
|
|
78
|
+
"(/data/pubkeys.txt)")
|
|
79
|
+
if seen["owner"] == "none":
|
|
80
|
+
raise PolicyError("pubkeys.txt names no owner key ('owner none'): a production bundle pins the owner key")
|
|
81
|
+
for alias, label in PUBKEY_ALIASES.items():
|
|
82
|
+
if alias in seen and seen[alias] != seen[label]:
|
|
83
|
+
raise PolicyError(f"pubkeys.txt: {alias} {seen[alias]} is not {label} {seen[label]}")
|
|
84
|
+
if "log-kid" in seen:
|
|
85
|
+
log_pub = parse_log_vkey(seen["log"]).public_key
|
|
86
|
+
if seen["log-kid"] != idkeys.did_key(idkeys.public_key_from_bytes(log_pub)):
|
|
87
|
+
raise PolicyError("pubkeys.txt: log-kid is not the did:key of the log line's key")
|
|
88
|
+
return seen
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _card_problems(card: Any, pubkeys: dict[str, str]) -> list[str]:
|
|
92
|
+
problems: list[str] = []
|
|
93
|
+
profile = cardmod.unsupported_profile(card)
|
|
94
|
+
if profile:
|
|
95
|
+
return [f"the card is of an unsupported profile ({profile})"]
|
|
96
|
+
problems += cardmod.schema_problems(card)
|
|
97
|
+
if problems:
|
|
98
|
+
return problems
|
|
99
|
+
for label, member in PUBKEY_FIELDS.items():
|
|
100
|
+
stated, want = card.get(member), pubkeys[label]
|
|
101
|
+
if member in ("policy_kid", "owner_kid"):
|
|
102
|
+
try:
|
|
103
|
+
same = parse_policy_key(str(stated)).public_key == parse_policy_key(want).public_key
|
|
104
|
+
except PolicyError:
|
|
105
|
+
same = False
|
|
106
|
+
else:
|
|
107
|
+
same = stated == want
|
|
108
|
+
if not same:
|
|
109
|
+
problems.append(f"the card's {member} {stated!r} is not pubkeys.txt's {label} {want!r}")
|
|
110
|
+
checks = cardmod.check_signatures(card, [parse_policy_key(pubkeys["policy"])])
|
|
111
|
+
if not any(c.status == "valid" for c in checks):
|
|
112
|
+
details = "; ".join(f"{c.status}{': ' + c.detail if c.detail else ''}" for c in checks)
|
|
113
|
+
problems.append(f"no signature of the card verifies with the policy key ({details})")
|
|
114
|
+
return problems
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _policies(args: Any, env: Any, card: dict[str, Any]) -> tuple[list[dict[str, Any]], str]:
|
|
118
|
+
if args.policies_file and args.policies_url:
|
|
119
|
+
raise UsageError("give --policies-url or --policies-file, not both")
|
|
120
|
+
if args.policies_file:
|
|
121
|
+
raw, source = _read(args.policies_file, "policies listing"), f"file {args.policies_file}"
|
|
122
|
+
else:
|
|
123
|
+
url = args.policies_url or join_url(str(card["api_base"]), "v2/policies")
|
|
124
|
+
resp = env.fetcher.get(url, accept="application/json")
|
|
125
|
+
if resp.status != 200:
|
|
126
|
+
raise AicsError(f"GET {url}: HTTP {resp.status}; give the listing with --policies-file")
|
|
127
|
+
raw, source = resp.body, url
|
|
128
|
+
try:
|
|
129
|
+
doc = cardmod.loads_strict(raw)
|
|
130
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
131
|
+
raise AicsError(f"the policies listing ({source}) is not strict JSON: {exc}") from None
|
|
132
|
+
rows = doc.get("policies") if isinstance(doc, dict) else None
|
|
133
|
+
if not isinstance(rows, list) or not all(isinstance(r, dict) for r in rows):
|
|
134
|
+
raise AicsError(f"the policies listing ({source}) has no 'policies' array of objects (GET /v2/policies)")
|
|
135
|
+
return rows, source
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build(args: Any, env: Any) -> dict[str, Any]:
|
|
139
|
+
pubkeys = parse_pubkeys(_read(args.pubkeys, "key file").decode("utf-8", errors="replace"))
|
|
140
|
+
doc = _read(args.card, "card")
|
|
141
|
+
try:
|
|
142
|
+
card = cardmod.loads_strict(doc)
|
|
143
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
144
|
+
raise AicsError(f"the card file is not strict JSON: {exc}") from None
|
|
145
|
+
if not isinstance(card, dict):
|
|
146
|
+
raise AicsError("the card file is not a JSON object")
|
|
147
|
+
if canonicalize(card) != doc:
|
|
148
|
+
raise AicsError("the card file is not J(card): its bytes are not the RFC 8785 serialization of the card "
|
|
149
|
+
"(save the served bytes unchanged, e.g. curl -o, no pretty-printing or final newline)")
|
|
150
|
+
problems = _card_problems(card, pubkeys)
|
|
151
|
+
if problems:
|
|
152
|
+
raise AicsError("the card cannot be the bootstrap card: " + "; ".join(problems[:8]),
|
|
153
|
+
problems=problems)
|
|
154
|
+
digest, size = hashlib.sha256(doc).hexdigest(), len(doc)
|
|
155
|
+
|
|
156
|
+
rows, source = _policies(args, env, card)
|
|
157
|
+
named = [r for r in rows if r.get("kind") == "card" and r.get("document_hash") == digest]
|
|
158
|
+
if not named:
|
|
159
|
+
raise AicsError(f"no policy leaf of kind card names this card (hexH {digest}) in {source}: log it first "
|
|
160
|
+
"(runbook step 4) and save the served bytes after logging")
|
|
161
|
+
leaf = named[0]
|
|
162
|
+
if leaf.get("size") is not None and leaf.get("size") != size:
|
|
163
|
+
raise AicsError(f"policy leaf {leaf.get('leaf_index')} names the card's hash with size {leaf.get('size')}, "
|
|
164
|
+
f"the file has {size} bytes")
|
|
165
|
+
notes: list[str] = []
|
|
166
|
+
cards = [r for r in rows if r.get("kind") == "card"]
|
|
167
|
+
if cards and cards[0] is not leaf:
|
|
168
|
+
notes.append(f"policy leaf {leaf.get('leaf_index')} is not the log's first card leaf "
|
|
169
|
+
f"({cards[0].get('leaf_index')}); a bootstrap bundle normally pins the first card")
|
|
170
|
+
charter = pubkeys.get("charter-sha256")
|
|
171
|
+
if charter is not None:
|
|
172
|
+
first = rows[0] if rows else {}
|
|
173
|
+
if not (_HEX64.fullmatch(charter) and first.get("kind") == "charter" and first.get("document_hash") == charter
|
|
174
|
+
and first.get("leaf_index") == 0):
|
|
175
|
+
raise AicsError(f"leaf 0 of {source} is not the charter pubkeys.txt names (charter-sha256 {charter})")
|
|
176
|
+
|
|
177
|
+
bundle = bundle_from_object({"v": 1, **{member: pubkeys[label] for label, member in PUBKEY_FIELDS.items()},
|
|
178
|
+
"bootstrap_card_hash": digest})
|
|
179
|
+
readme = "\n".join([README_LABEL, bundle.txt, f"sha256: {bundle.sha256}"]) + "\n"
|
|
180
|
+
# every published form must read back as these bytes, the way aics policy init reads it
|
|
181
|
+
for form in (bundle_from_u64(bundle.txt[len("aics1="):]), bundle_from_text(readme),
|
|
182
|
+
bundle_from_text(bundle.jcs.decode("utf-8"))):
|
|
183
|
+
if form.jcs != bundle.jcs: # pragma: no cover - a format drift between this file and trust.py
|
|
184
|
+
raise AicsError("internal error: a published form does not read back as the bundle bytes")
|
|
185
|
+
return {"bundle": bundle, "card_hash": digest, "card_size": size, "card_leaf_index": leaf.get("leaf_index"),
|
|
186
|
+
"policies_source": source, "readme_block": readme, "notes": notes}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_trust_build(args: Any, env: Any) -> Any:
|
|
190
|
+
from .commands import _ok
|
|
191
|
+
|
|
192
|
+
out = build(args, env)
|
|
193
|
+
bundle: TrustBundle = out["bundle"]
|
|
194
|
+
jcs = bundle.jcs.decode("utf-8")
|
|
195
|
+
data = {"bundle_jcs": jcs, "bundle": json.loads(jcs), "txt": bundle.txt, "sha256": bundle.sha256,
|
|
196
|
+
"readme_block": out["readme_block"], "card_hash": out["card_hash"], "card_size": out["card_size"],
|
|
197
|
+
"card_leaf_index": out["card_leaf_index"], "policies_source": out["policies_source"],
|
|
198
|
+
"notes": out["notes"]}
|
|
199
|
+
text = [
|
|
200
|
+
f"checked: the card is J(card), a §13.1 card signed by the policy key, {out['card_size']} bytes, hexH "
|
|
201
|
+
f"{out['card_hash']}, named by policy leaf {out['card_leaf_index']} ({out['policies_source']})",
|
|
202
|
+
"checked: origin, log, policy and owner are pubkeys.txt's",
|
|
203
|
+
"",
|
|
204
|
+
f"bundle (JCS, {len(bundle.jcs)} bytes):",
|
|
205
|
+
jcs,
|
|
206
|
+
"",
|
|
207
|
+
"DNS TXT _aics.<domain> (exactly one RR):",
|
|
208
|
+
bundle.txt,
|
|
209
|
+
"",
|
|
210
|
+
f"sha256 {bundle.sha256}",
|
|
211
|
+
"",
|
|
212
|
+
"README block:",
|
|
213
|
+
*(" " + line for line in out["readme_block"].splitlines()),
|
|
214
|
+
*(f"note: {n}" for n in out["notes"]),
|
|
215
|
+
]
|
|
216
|
+
return _ok(data, text)
|