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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -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)