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/message.py
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
"""``aics verify message``: one log entry checked as AICS-1 §14.3 says (DESIGN G8).
|
|
2
|
+
|
|
3
|
+
The input (``--bundle FILE``) is a §14.3 wire bundle ``{entry_bytes, index,
|
|
4
|
+
inclusion_proof, checkpoint_note, envelope, body_or_ct}`` or a complete
|
|
5
|
+
verification package ``{bundle, trust_bundle, policy_cards, entry_prefix,
|
|
6
|
+
checkpoint_notes}``. Keys come only from the pinned policy (``aics policy
|
|
7
|
+
init``): a package's ``trust_bundle`` must equal the pinned values; it never
|
|
8
|
+
replaces them.
|
|
9
|
+
|
|
10
|
+
The steps, in §14.3 order:
|
|
11
|
+
|
|
12
|
+
1. **checkpoint, inclusion, entry** - the note is signed by the pinned log
|
|
13
|
+
key under the pinned origin and meets the pinned witness policy (every
|
|
14
|
+
valid cosignature counts whatever its age: an offline bundle proves a
|
|
15
|
+
historical property, not today's head); the entry bytes are leaf
|
|
16
|
+
``index`` of that tree; they are the JCS serialisation of an entry v2
|
|
17
|
+
whose payload copies the envelope's exactly, and the bundle's envelope is
|
|
18
|
+
the embedded one (aics-tlog :func:`~aics_tlog.verify_bundle`).
|
|
19
|
+
Continuity: every checkpoint saved by ``aics verify checkpoint`` for this
|
|
20
|
+
origin, and every note of a package, whose size the prefix covers must
|
|
21
|
+
have the prefix's root at that size; a saved head larger than the
|
|
22
|
+
bundle's checkpoint needs a consistency proof (the tiles, so not
|
|
23
|
+
``--offline``).
|
|
24
|
+
2. **history** - the complete entry prefix ``[0, size)`` of the bundle's
|
|
25
|
+
checkpoint (from the package, ``--tiles DIR`` offline, or the pinned tile
|
|
26
|
+
prefix online) must reconstruct the checkpoint root; then names, key
|
|
27
|
+
rotations and revocations, recovery proofs, policy authority, every
|
|
28
|
+
sender's sequence and nonce (a ``(kid, nonce)`` that any earlier leaf
|
|
29
|
+
carries, as an envelope or as a refusal's attempt tuple, is a replay
|
|
30
|
+
however long ago it expired), and grants are replayed in index order
|
|
31
|
+
through the message index (:class:`Replay`). Without a complete prefix
|
|
32
|
+
the result is ``incomplete``, even when the signature verifies.
|
|
33
|
+
3. **envelope** - by its scheme with aics-identity (``ed25519-jcs``,
|
|
34
|
+
``text-ed25519``, ``text-sshsig``): origin and type equal the entry's, the
|
|
35
|
+
actor is the kid, freshness at the entry's ``ts`` (§4.3); a
|
|
36
|
+
``shared-secret`` entry has the policy key's attestation, and its
|
|
37
|
+
authorship is ``unproven``. Every entry of the replay gets this check.
|
|
38
|
+
4. **body** - ``body_or_ct`` against the payload's hash and size, else the
|
|
39
|
+
metadata only; a later tombstone of the entry is reported.
|
|
40
|
+
5. **DM** - the grant, its capacity and the recipient's encryption key at
|
|
41
|
+
that index (the replay).
|
|
42
|
+
6. Nothing is decrypted here: no private material is read.
|
|
43
|
+
|
|
44
|
+
Not replayed: the SUP-1 task board, owner-action market and balances
|
|
45
|
+
(§20.9; their entries' authority and asset hash are checked), polls and
|
|
46
|
+
votes beyond their signer, suspensions and freezes beyond their signer, and
|
|
47
|
+
the card active at the index. A swarms-profile entry (§19) stops the replay
|
|
48
|
+
as an unsupported profile.
|
|
49
|
+
|
|
50
|
+
Exit codes follow the CLI contract: 0 ``valid``; 1 ``incomplete`` (no
|
|
51
|
+
complete prefix, an unproven continuity, an unmet witness policy, an
|
|
52
|
+
unsupported profile) or an input file that does not verify (anyone can write
|
|
53
|
+
a bad file: that is no evidence against the log); 2 evidence: a log-signed
|
|
54
|
+
checkpoint and a valid inclusion proof of an entry AICS-1 forbids, or a
|
|
55
|
+
log-signed checkpoint that the authenticated prefix contradicts.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import base64
|
|
61
|
+
import binascii
|
|
62
|
+
import hashlib
|
|
63
|
+
from dataclasses import dataclass, field
|
|
64
|
+
from typing import Any, Callable, Iterable
|
|
65
|
+
|
|
66
|
+
from aics_identity import envelope as idenv
|
|
67
|
+
from aics_identity import keys as idkeys
|
|
68
|
+
from aics_identity import sshsig as idsshsig
|
|
69
|
+
from aics_identity.canonical import canonicalize
|
|
70
|
+
from aics_tlog import Bundle, MerkleTree, leaf_hash, verify_bundle, verify_inclusion
|
|
71
|
+
from aics_tlog.tiles import TILE_ENTRIES, entry_bundle_path, parse_entry_bundle
|
|
72
|
+
from cryptography.exceptions import InvalidSignature
|
|
73
|
+
|
|
74
|
+
from .card import card_hash, check_signatures, loads_strict
|
|
75
|
+
from .checkpoints import Cp, check_note, log_signed
|
|
76
|
+
from .encoding import b64e, escape_untrusted
|
|
77
|
+
from .policy import Policy
|
|
78
|
+
|
|
79
|
+
#: AICS-1 §14.3: a complete verification package has exactly these members.
|
|
80
|
+
PACKAGE_MEMBERS = ("bundle", "trust_bundle", "policy_cards", "entry_prefix", "checkpoint_notes")
|
|
81
|
+
#: The trust-bundle values a package must repeat exactly (AICS-1 §6.4).
|
|
82
|
+
TRUST_MEMBERS = ("origin", "log_vkey", "policy_kid", "owner_kid", "bootstrap_card_hash")
|
|
83
|
+
INPUT_MAX_BYTES = 512 * 1024 * 1024
|
|
84
|
+
ENTRY_V2_MEMBERS = frozenset({"v", "origin", "type", "actor_kid", "ts", "payload"})
|
|
85
|
+
BODY_COMMITMENTS = ("body_hash", "ct_hash", "document_hash")
|
|
86
|
+
BASE_TYPES = frozenset(idenv.ENTRY_TYPES)
|
|
87
|
+
SUP_TYPES = frozenset(idenv.SUP_ENTRY_TYPES)
|
|
88
|
+
SWARMS_TYPES = frozenset(idenv.SWARMS_ENTRY_TYPES)
|
|
89
|
+
#: The types this release replays: the base profile and the SUP-1 profile (AICS-1 §§5.3, 20.3).
|
|
90
|
+
KNOWN_TYPES = tuple(idenv.ENTRY_TYPES) + tuple(idenv.SUP_ENTRY_TYPES)
|
|
91
|
+
TEXT_VERBS = {**idenv.TEXT_VERBS, **idenv.SUP_TEXT_VERBS}
|
|
92
|
+
KEY_TIERS = ("verified", "third-party-generated")
|
|
93
|
+
#: Entries the policy key signs (AICS-1 §5.3, §10); ``report`` of kind ``owner_action`` is the owner's (§20.4).
|
|
94
|
+
SERVICE_TYPES = frozenset({"refusal", "tombstone", "policy", "release", "suspension", "freeze", "report"})
|
|
95
|
+
ROTATE_CONTEXT = b"AICS-1 key.rotate\n"
|
|
96
|
+
UPGRADE_CHECK_CONTEXT = b"AICS-1 upgrade-check\n"
|
|
97
|
+
RECOVERY_CONTEXT = b"AICS-1 recovery-declaration\n"
|
|
98
|
+
POLICY_CONTEXT = b"AICS-1 policy\n"
|
|
99
|
+
|
|
100
|
+
#: What an offline message verification can never establish (AICS-1 §14.3 step 7), always reported.
|
|
101
|
+
ALWAYS_UNPROVEN = (
|
|
102
|
+
"today's availability, current revocation state and a fresh witness quorum (an offline verification proves "
|
|
103
|
+
"historical properties only, AICS-1 §14.3 step 7)",
|
|
104
|
+
"the card active at the index and the retention policy it states (the card's policy leaf is not replayed)",
|
|
105
|
+
"decryption and the inner header of a DM or capsule (no private material is read, AICS-1 §14.3 step 6)",
|
|
106
|
+
)
|
|
107
|
+
NOT_REPLAYED = (
|
|
108
|
+
"the SUP-1 task board, owner-action market and balances (AICS-1 §20.9): their entries' signers and asset "
|
|
109
|
+
"hash are checked, their state is not",
|
|
110
|
+
"polls, votes, suspensions and freezes beyond the key that signed them",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class InputProblem(Exception):
|
|
115
|
+
"""The input file does not verify; no evidence against the log (exit 1)."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Evidence(Exception):
|
|
119
|
+
"""A log-signed statement AICS-1 forbids (exit 2); ``evidence`` lets anyone re-check it."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, message: str, evidence: dict[str, Any]) -> None:
|
|
122
|
+
super().__init__(message)
|
|
123
|
+
self.evidence = evidence
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ReplayProblem(Exception):
|
|
127
|
+
"""An entry of the authenticated prefix that AICS-1 forbids (a rule broken at ``index``)."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, index: int, rule: str, message: str) -> None:
|
|
130
|
+
super().__init__(message)
|
|
131
|
+
self.index = index
|
|
132
|
+
self.rule = rule
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Unsupported(Exception):
|
|
136
|
+
"""An entry of a profile this release does not implement (AICS-1 §19): the history is incomplete."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, index: int, entry_type: str) -> None:
|
|
139
|
+
super().__init__(f"leaf {index} is a {entry_type} entry of the swarms profile (AICS-1 §19), which this "
|
|
140
|
+
"release does not implement")
|
|
141
|
+
self.index = index
|
|
142
|
+
self.entry_type = entry_type
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def u64d(value: Any, what: str) -> bytes:
|
|
146
|
+
"""Canonical unpadded base64url (AICS-1 §2)."""
|
|
147
|
+
if not isinstance(value, str):
|
|
148
|
+
raise ValueError(f"{what} must be base64url text")
|
|
149
|
+
try:
|
|
150
|
+
raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
151
|
+
except (binascii.Error, ValueError):
|
|
152
|
+
raise ValueError(f"{what} is not base64url") from None
|
|
153
|
+
if base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") != value:
|
|
154
|
+
raise ValueError(f"{what} is not canonical base64url")
|
|
155
|
+
return raw
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def hexH(data: bytes) -> str:
|
|
159
|
+
return hashlib.sha256(data).hexdigest()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# input
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class MessageInput:
|
|
169
|
+
bundle: Bundle
|
|
170
|
+
package: dict[str, Any] | None = None
|
|
171
|
+
prefix: list[bytes] | None = None
|
|
172
|
+
notes: list[str] = field(default_factory=list)
|
|
173
|
+
cards: list[bytes] = field(default_factory=list)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_input(raw: bytes, policy: Policy) -> MessageInput:
|
|
177
|
+
"""A wire bundle or a §14.3 package, strictly parsed; :class:`InputProblem` otherwise."""
|
|
178
|
+
if len(raw) > INPUT_MAX_BYTES:
|
|
179
|
+
raise InputProblem(f"the input is larger than {INPUT_MAX_BYTES} bytes")
|
|
180
|
+
try:
|
|
181
|
+
doc = loads_strict(raw)
|
|
182
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
183
|
+
raise InputProblem(f"the input is not strict JSON: {exc}") from None
|
|
184
|
+
if not isinstance(doc, dict):
|
|
185
|
+
raise InputProblem("the input is not a JSON object")
|
|
186
|
+
package = None
|
|
187
|
+
if "bundle" in doc:
|
|
188
|
+
if set(doc) != set(PACKAGE_MEMBERS):
|
|
189
|
+
raise InputProblem("a verification package has exactly the members " + ", ".join(PACKAGE_MEMBERS)
|
|
190
|
+
+ " (AICS-1 §14.3)")
|
|
191
|
+
package = doc
|
|
192
|
+
wire = doc["bundle"]
|
|
193
|
+
else:
|
|
194
|
+
wire = doc
|
|
195
|
+
try:
|
|
196
|
+
bundle = Bundle.from_dict(wire)
|
|
197
|
+
except ValueError as exc:
|
|
198
|
+
raise InputProblem(f"not an AICS-1 §14.3 wire bundle: {exc}") from None
|
|
199
|
+
out = MessageInput(bundle=bundle, package=package)
|
|
200
|
+
if package is None:
|
|
201
|
+
return out
|
|
202
|
+
trust = package["trust_bundle"]
|
|
203
|
+
pinned = {k: getattr(policy, k) for k in TRUST_MEMBERS}
|
|
204
|
+
if not isinstance(trust, dict) or {k: trust.get(k) for k in TRUST_MEMBERS} != pinned \
|
|
205
|
+
or set(trust) - {"v", *TRUST_MEMBERS}:
|
|
206
|
+
raise InputProblem("the package's trust_bundle differs from your pinned trust values: a package never "
|
|
207
|
+
"supplies trust (AICS-1 §14.3); pin the right bundle first (aics policy init)")
|
|
208
|
+
for name in ("policy_cards", "entry_prefix", "checkpoint_notes"):
|
|
209
|
+
if not isinstance(package[name], list):
|
|
210
|
+
raise InputProblem(f"the package's {name} must be an array")
|
|
211
|
+
try:
|
|
212
|
+
out.prefix = [u64d(x, f"entry_prefix[{i}]") for i, x in enumerate(package["entry_prefix"])]
|
|
213
|
+
out.notes = [u64d(x, f"checkpoint_notes[{i}]").decode("utf-8")
|
|
214
|
+
for i, x in enumerate(package["checkpoint_notes"])]
|
|
215
|
+
out.cards = [u64d(x, f"policy_cards[{i}]") for i, x in enumerate(package["policy_cards"])]
|
|
216
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
217
|
+
raise InputProblem(f"the package does not decode: {exc}") from None
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def read_prefix(reader: Any, size: int) -> list[bytes]:
|
|
222
|
+
"""Entries ``[0, size)`` from the entry bundles of ``reader`` (an aics-tlog TileReader); unauthenticated until
|
|
223
|
+
their Merkle root is compared with a signed one. ``ValueError`` names what is missing."""
|
|
224
|
+
out: list[bytes] = []
|
|
225
|
+
full, rest = divmod(size, TILE_ENTRIES)
|
|
226
|
+
for n in range(full + (1 if rest else 0)):
|
|
227
|
+
width = rest if n == full else None
|
|
228
|
+
data = reader.read_entry_bundle(n, width)
|
|
229
|
+
if data is None and width is not None:
|
|
230
|
+
data = reader.read_entry_bundle(n, None) # the log grew: the full bundle starts with these entries
|
|
231
|
+
if data is None:
|
|
232
|
+
raise ValueError(f"missing entry bundle {entry_bundle_path(n, width)}")
|
|
233
|
+
entries = parse_entry_bundle(data)
|
|
234
|
+
want = width or TILE_ENTRIES
|
|
235
|
+
if len(entries) < want:
|
|
236
|
+
raise ValueError(f"entry bundle {entry_bundle_path(n, width)} holds {len(entries)} entries, not {want}")
|
|
237
|
+
out.extend(entries[:want])
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# one entry: the §5.1 shape and the §4 envelope
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def entry_problem(doc: Any, raw: bytes, origin: str) -> str | None:
|
|
247
|
+
"""Why ``raw`` is not an AICS-1 entry of this log (§5.1, §5.2, §14.3 step 1), or None."""
|
|
248
|
+
if not isinstance(doc, dict):
|
|
249
|
+
return "the entry is not a JSON object"
|
|
250
|
+
try:
|
|
251
|
+
if canonicalize(doc) != raw:
|
|
252
|
+
return "the entry bytes are not the JCS serialisation of the entry (AICS-1 §5.2)"
|
|
253
|
+
except (ValueError, TypeError):
|
|
254
|
+
return "the entry cannot be canonicalised (RFC 8785)"
|
|
255
|
+
if set(doc) != ENTRY_V2_MEMBERS or doc["v"] != 2:
|
|
256
|
+
return "the entry is not an entry v2 with exactly v, origin, type, actor_kid, ts, payload (AICS-1 §5.1)"
|
|
257
|
+
if doc["origin"] != origin:
|
|
258
|
+
return "the entry names another origin than the pinned one (AICS-1 §5.1)"
|
|
259
|
+
ts = doc["ts"]
|
|
260
|
+
if isinstance(ts, bool) or not isinstance(ts, int) or not 0 <= ts <= idenv.MAX_UINT:
|
|
261
|
+
return "ts is not uint seconds (AICS-1 §5.1)"
|
|
262
|
+
payload = doc["payload"]
|
|
263
|
+
env = payload.get("envelope") if isinstance(payload, dict) else None
|
|
264
|
+
if not isinstance(env, dict) or not isinstance(env.get("payload"), dict) or not isinstance(env.get("protected"), dict):
|
|
265
|
+
return "the payload does not embed an envelope with a protected header and a payload (AICS-1 §5.1)"
|
|
266
|
+
q, auth = env["payload"], env.get("auth")
|
|
267
|
+
allowed = {"envelope", *q}
|
|
268
|
+
if isinstance(auth, dict) and auth.get("scheme") == idenv.SHARED_SECRET_SCHEME:
|
|
269
|
+
allowed.add("attestation")
|
|
270
|
+
if set(payload) != allowed:
|
|
271
|
+
return "the entry payload is not the envelope plus an exact copy of the envelope payload (AICS-1 §5.1)"
|
|
272
|
+
for name, value in q.items():
|
|
273
|
+
if canonicalize(payload[name]) != canonicalize(value):
|
|
274
|
+
return f"payload member {escape_untrusted(str(name))!r} is not an exact copy of the envelope's (AICS-1 §5.1)"
|
|
275
|
+
p = env["protected"]
|
|
276
|
+
if p.get("origin") != doc["origin"] or p.get("type") != doc["type"] or p.get("kid") != doc["actor_kid"]:
|
|
277
|
+
return "the envelope's origin, type or kid differs from the entry's (AICS-1 §5.1)"
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def check_envelope(i: int, e: dict[str, Any], policy_kid: str) -> str:
|
|
282
|
+
"""Verify entry ``i``'s envelope by its scheme at the entry's ``ts`` (§4.3, §14.3 step 3); returns the scheme.
|
|
283
|
+
:class:`ReplayProblem` on failure."""
|
|
284
|
+
env, ts, origin, etype = e["payload"]["envelope"], e["ts"], e["origin"], e["type"]
|
|
285
|
+
scheme = env.get("auth", {}).get("scheme") if isinstance(env.get("auth"), dict) else None
|
|
286
|
+
try:
|
|
287
|
+
if scheme == idenv.NATIVE_SCHEME:
|
|
288
|
+
idenv.verify_envelope(env, idkeys.parse_did_key, now=ts, origin=origin, type=etype, types=KNOWN_TYPES)
|
|
289
|
+
elif scheme in idenv.TEXT_SCHEMES:
|
|
290
|
+
idenv.verify_text_envelope(env, idkeys.parse_did_key, now=ts, origin=origin, type=etype,
|
|
291
|
+
types=KNOWN_TYPES, verbs=TEXT_VERBS)
|
|
292
|
+
elif scheme == idenv.SHARED_SECRET_SCHEME:
|
|
293
|
+
if idenv.validate_envelope(env, types=KNOWN_TYPES) != idenv.SHARED_SECRET_SCHEME:
|
|
294
|
+
raise idenv.MalformedEnvelope("not a shared-secret envelope")
|
|
295
|
+
idenv.check_freshness(env["protected"], ts)
|
|
296
|
+
idenv.verify_secret_attestation(env, e["payload"].get("attestation"), policy_kid, ts=ts,
|
|
297
|
+
types=KNOWN_TYPES)
|
|
298
|
+
else:
|
|
299
|
+
raise idenv.UnsupportedScheme(f"auth.scheme {scheme!r} is not an AICS-1 scheme")
|
|
300
|
+
except idenv.EnvelopeError as exc:
|
|
301
|
+
rule = {"ExpiredEnvelope": "freshness", "NotYetValidEnvelope": "freshness"}.get(type(exc).__name__,
|
|
302
|
+
"envelope")
|
|
303
|
+
raise ReplayProblem(i, rule, f"leaf {i} ({etype}): its envelope does not verify at the entry's ts: "
|
|
304
|
+
f"{exc}") from None
|
|
305
|
+
except (ValueError, TypeError) as exc:
|
|
306
|
+
raise ReplayProblem(i, "envelope", f"leaf {i} ({etype}): its envelope is malformed: {exc}") from None
|
|
307
|
+
return scheme
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# the history through the message index (§14.3 step 2)
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _require(ok: Any, i: int, rule: str, message: str) -> None:
|
|
316
|
+
if not ok:
|
|
317
|
+
raise ReplayProblem(i, rule, f"leaf {i}: {message}")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _proof(proof: Any, public: Any, data: bytes) -> bool:
|
|
321
|
+
"""A §7 proof object ``{scheme: ed25519|sshsig, signature}`` over ``data`` by ``public``."""
|
|
322
|
+
if not isinstance(proof, dict) or set(proof) != {"scheme", "signature"}:
|
|
323
|
+
return False
|
|
324
|
+
try:
|
|
325
|
+
raw = u64d(proof["signature"], "signature")
|
|
326
|
+
if proof["scheme"] == "ed25519":
|
|
327
|
+
public.verify(raw, data)
|
|
328
|
+
elif proof["scheme"] == "sshsig":
|
|
329
|
+
idsshsig.verify_sshsig(raw, data, public, namespace="aics")
|
|
330
|
+
else:
|
|
331
|
+
return False
|
|
332
|
+
except (InvalidSignature, ValueError, idsshsig.SSHSigError):
|
|
333
|
+
return False
|
|
334
|
+
return True
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def sup_asset_hash(origin: str) -> str:
|
|
338
|
+
"""AICS-1 §20.2."""
|
|
339
|
+
return hexH(canonicalize({"asset": "SUP", "buys": ["owner_action", "hosting", "storage"], "decimals": 0,
|
|
340
|
+
"origin": origin, "refundable": False, "transferable": False}))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class Replay:
|
|
344
|
+
"""The state of names, keys, policy authority, sender chains, nonces and grants, in index order.
|
|
345
|
+
|
|
346
|
+
:meth:`apply` checks one entry against the state immediately before its
|
|
347
|
+
index and then updates it; the rules are AICS-1 §§3.3, 4.2-4.3, 5.3, 6.4,
|
|
348
|
+
7 and 8 as an agent-side verifier applies them.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
def __init__(self, policy: Policy) -> None:
|
|
352
|
+
self.origin = policy.origin
|
|
353
|
+
self.policy_kid = policy.policy_kid
|
|
354
|
+
self.owner_kid = policy.owner_kid
|
|
355
|
+
self.log_kid = idkeys.did_key(idkeys.public_key_from_bytes(policy.log_key().public_key))
|
|
356
|
+
self.service_kids = {self.policy_kid, self.owner_kid, self.log_kid}
|
|
357
|
+
self.names: dict[str, dict[str, Any]] = {}
|
|
358
|
+
self.bound: set[str] = set()
|
|
359
|
+
self.active: dict[str, dict[str, Any]] = {}
|
|
360
|
+
self.last: dict[str, dict[str, Any]] = {}
|
|
361
|
+
#: (kid, nonce) -> the first leaf that carries it (an accepted envelope or a refusal's attempt tuple)
|
|
362
|
+
self.nonces: dict[tuple[str, str], int] = {}
|
|
363
|
+
self.grants: dict[tuple[str, str], dict[str, Any]] = {}
|
|
364
|
+
self.used: dict[int, int] = {}
|
|
365
|
+
self.policy_events: list[int] = []
|
|
366
|
+
self.entries: list[dict[str, Any]] = []
|
|
367
|
+
self.schemes: list[str] = []
|
|
368
|
+
|
|
369
|
+
def apply(self, i: int, raw: bytes) -> dict[str, Any]:
|
|
370
|
+
try:
|
|
371
|
+
e = loads_strict(raw)
|
|
372
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
373
|
+
raise ReplayProblem(i, "entry", f"leaf {i} is not strict JSON: {exc}") from None
|
|
374
|
+
problem = entry_problem(e, raw, self.origin)
|
|
375
|
+
if problem is not None:
|
|
376
|
+
raise ReplayProblem(i, "entry", f"leaf {i}: {problem}")
|
|
377
|
+
etype = e["type"]
|
|
378
|
+
if etype in SWARMS_TYPES:
|
|
379
|
+
raise Unsupported(i, etype)
|
|
380
|
+
_require(etype in BASE_TYPES or etype in SUP_TYPES, i, "type",
|
|
381
|
+
f"type {escape_untrusted(str(etype))[:40]!r} is in no registry this log may use (AICS-1 §17.1)")
|
|
382
|
+
scheme = check_envelope(i, e, self.policy_kid)
|
|
383
|
+
try:
|
|
384
|
+
self._apply(i, e, scheme)
|
|
385
|
+
except (KeyError, TypeError, AttributeError) as exc:
|
|
386
|
+
raise ReplayProblem(i, "schema", f"leaf {i} ({etype}): a §5.3 member is missing or mistyped "
|
|
387
|
+
f"({type(exc).__name__}: {exc})") from None
|
|
388
|
+
self.entries.append(e)
|
|
389
|
+
self.schemes.append(scheme)
|
|
390
|
+
return e
|
|
391
|
+
|
|
392
|
+
def _apply(self, i: int, e: dict[str, Any], scheme: str) -> None:
|
|
393
|
+
env = e["payload"]["envelope"]
|
|
394
|
+
p, q, kid, typ, ts = env["protected"], env["payload"], env["protected"]["kid"], e["type"], e["ts"]
|
|
395
|
+
secret = scheme == idenv.SHARED_SECRET_SCHEME
|
|
396
|
+
if secret:
|
|
397
|
+
_require(typ in ("name.claim", "post"), i, "tier",
|
|
398
|
+
"the shared-secret tier authorises only name.claim and post (AICS-1 §3.3)")
|
|
399
|
+
# §4.2 the sender chain, §4.3 nonce uniqueness across the log
|
|
400
|
+
prior = self.last.get(kid)
|
|
401
|
+
_require(p["seq"] == (prior["protected"]["seq"] + 1 if prior else 0), i, "sequence",
|
|
402
|
+
f"seq {p['seq']} does not follow the sender's previous envelope (AICS-1 §4.2)")
|
|
403
|
+
_require(p["prev"] == (idenv.envelope_hash(prior) if prior else None), i, "predecessor",
|
|
404
|
+
"prev is not hexH(J(E)) of the sender's previous accepted envelope (AICS-1 §4.2)")
|
|
405
|
+
first = self.nonces.get((kid, p["nonce"]))
|
|
406
|
+
_require(first is None, i, "replay",
|
|
407
|
+
f"the (kid, nonce) leaf {first} already carries is accepted again; a nonce is unique per key "
|
|
408
|
+
"across the whole log, also after its record expires (AICS-1 §4.3)")
|
|
409
|
+
self.nonces[(kid, p["nonce"])] = i
|
|
410
|
+
self.last[kid] = env
|
|
411
|
+
# authority
|
|
412
|
+
if typ in SERVICE_TYPES:
|
|
413
|
+
expected = self.owner_kid if typ == "report" and q["kind"] == "owner_action" else self.policy_kid
|
|
414
|
+
_require(kid == expected, i, "authority", f"a {typ} entry signed by another key than the pinned "
|
|
415
|
+
f"{'owner' if expected == self.owner_kid else 'policy'} key")
|
|
416
|
+
elif typ == "sup.task" or (typ == "sup.credit" and q["source"] == "task"):
|
|
417
|
+
_require(kid == self.owner_kid, i, "authority", f"a {typ} entry signed by another key than the owner's "
|
|
418
|
+
"(AICS-1 §20.3)")
|
|
419
|
+
elif typ == "sup.credit":
|
|
420
|
+
_require(kid == self.policy_kid, i, "authority", "a contribution credit signed by another key than the "
|
|
421
|
+
"policy key (AICS-1 §20.3)")
|
|
422
|
+
elif typ != "name.claim" and not (typ == "key.rotate" and q["mode"] == "upgrade"):
|
|
423
|
+
_require(kid in self.active, i, "signer", "the signer is not the current key of any name at this "
|
|
424
|
+
"index (AICS-1 §7)")
|
|
425
|
+
handler = getattr(self, "_" + typ.replace(".", "_"), None)
|
|
426
|
+
if handler is not None:
|
|
427
|
+
handler(i, e, p, q, kid, ts, secret)
|
|
428
|
+
|
|
429
|
+
# -- names and keys (§3.3, §7) ------------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
def _name_claim(self, i, e, p, q, kid, ts, secret) -> None:
|
|
432
|
+
_require(q["name_norm"] not in self.names, i, "name_taken", f"name {q['name_norm']!r} is claimed again")
|
|
433
|
+
_require(kid not in self.bound, i, "key_reuse", "the claim's key is already bound (AICS-1 §7)")
|
|
434
|
+
if secret:
|
|
435
|
+
_require(q["tier"] == "unverified" and all(q[k] is None for k in ("ed25519", "x25519", "recovery_kid",
|
|
436
|
+
"recovery_pop", "initial_grant")),
|
|
437
|
+
i, "tier", "a shared-secret claim is unverified and names no key, recovery or grant (§3.3)")
|
|
438
|
+
else:
|
|
439
|
+
_require(idkeys.did_key(idkeys.public_key_from_bytes(u64d(q["ed25519"], "ed25519"))) == kid
|
|
440
|
+
and q["tier"] in KEY_TIERS and q["x25519"] is not None, i, "claim",
|
|
441
|
+
"the claim's ed25519 is not its kid's key, its tier is not a key tier, or it has no x25519")
|
|
442
|
+
_require(kid not in self.service_kids, i, "key_reuse", "a service key claims a name (AICS-1 §3.1)")
|
|
443
|
+
if q["recovery_kid"] is not None:
|
|
444
|
+
r = {k: q[k] for k in ("name_norm", "ed25519", "x25519", "recovery_kid")} | {"origin": e["origin"]}
|
|
445
|
+
_require(_proof(q["recovery_pop"], idkeys.parse_did_key(q["recovery_kid"]),
|
|
446
|
+
RECOVERY_CONTEXT + canonicalize(r)), i, "recovery",
|
|
447
|
+
"the recovery declaration's proof does not verify (AICS-1 §7)")
|
|
448
|
+
state = {"kid": kid, "x": q["x25519"], "key_index": i, "event": i, "revoked": False, "name": q["name_norm"],
|
|
449
|
+
"tier": q["tier"]}
|
|
450
|
+
self.names[q["name_norm"]] = state
|
|
451
|
+
self.active[kid] = state
|
|
452
|
+
self.bound.add(kid)
|
|
453
|
+
grant = q["initial_grant"]
|
|
454
|
+
if grant is not None:
|
|
455
|
+
_require(grant["max"] == 0 or grant["expires"] > ts, i, "grant", "the initial grant has expired")
|
|
456
|
+
self.grants[(kid, grant["sender_kid"])] = dict(grant, index=i, ts=ts)
|
|
457
|
+
|
|
458
|
+
def _key_event(self, i, q, kid, typ) -> dict[str, Any]:
|
|
459
|
+
state = self.names.get(q["name_norm"])
|
|
460
|
+
_require(state is not None, i, "name", f"a {typ} for an unclaimed name")
|
|
461
|
+
_require(not state["revoked"] and state["event"] == q["prev_event_index"], i, "key_fork",
|
|
462
|
+
"prev_event_index is not the name's latest key event, or the name is revoked (AICS-1 §7)")
|
|
463
|
+
return state
|
|
464
|
+
|
|
465
|
+
def _key_rotate(self, i, e, p, q, kid, ts, secret) -> None:
|
|
466
|
+
state = self._key_event(i, q, kid, "key.rotate")
|
|
467
|
+
upgrade = q["mode"] == "upgrade"
|
|
468
|
+
if not upgrade:
|
|
469
|
+
_require(state["kid"] == kid, i, "signer", "a rotation signed by another key than the name's current one")
|
|
470
|
+
r = {k: v for k, v in q.items() if k not in ("sig_old", "sig_new", "secret_check")} | {"origin": e["origin"]}
|
|
471
|
+
data = ROTATE_CONTEXT + canonicalize(r)
|
|
472
|
+
new = idkeys.public_key_from_bytes(u64d(q["new_ed25519"], "new_ed25519"))
|
|
473
|
+
new_kid = idkeys.did_key(new)
|
|
474
|
+
_require(q["new_tier"] in KEY_TIERS, i, "tier", "a rotation cannot move a name to the shared-secret tier")
|
|
475
|
+
if upgrade:
|
|
476
|
+
_require(state["kid"].startswith("unverified:") and q["sig_old"] is None and new_kid == kid
|
|
477
|
+
and p["seq"] == 0 and q["new_x25519"] is not None, i, "upgrade",
|
|
478
|
+
"an upgrade must move an unverified name to the signing key's first envelope (AICS-1 §7)")
|
|
479
|
+
try:
|
|
480
|
+
idkeys.parse_did_key(self.policy_kid).verify(u64d(q["secret_check"], "secret_check"),
|
|
481
|
+
UPGRADE_CHECK_CONTEXT + canonicalize(r))
|
|
482
|
+
except (InvalidSignature, ValueError):
|
|
483
|
+
raise ReplayProblem(i, "upgrade", f"leaf {i}: the policy key's secret_check does not verify") from None
|
|
484
|
+
else:
|
|
485
|
+
_require(_proof(q["sig_old"], idkeys.parse_did_key(kid), data), i, "rotation",
|
|
486
|
+
"sig_old does not verify under the name's current key (AICS-1 §7)")
|
|
487
|
+
_require(_proof(q["sig_new"], new, data), i, "rotation", "sig_new does not verify under the new key")
|
|
488
|
+
_require(new_kid not in self.bound, i, "key_reuse", "the new key is already bound (AICS-1 §7)")
|
|
489
|
+
self.active.pop(state["kid"], None)
|
|
490
|
+
state["kid"] = new_kid
|
|
491
|
+
self.active[new_kid] = state
|
|
492
|
+
self.bound.add(new_kid)
|
|
493
|
+
state["tier"] = q["new_tier"]
|
|
494
|
+
if q["new_x25519"] is not None:
|
|
495
|
+
state.update(x=q["new_x25519"], key_index=i)
|
|
496
|
+
state["event"] = i
|
|
497
|
+
|
|
498
|
+
def _key_revoke(self, i, e, p, q, kid, ts, secret) -> None:
|
|
499
|
+
state = self._key_event(i, q, kid, "key.revoke")
|
|
500
|
+
_require(state["kid"] == kid, i, "signer", "a revocation signed by another key than the name's current one")
|
|
501
|
+
state["revoked"] = True
|
|
502
|
+
self.active.pop(kid, None)
|
|
503
|
+
state["event"] = i
|
|
504
|
+
|
|
505
|
+
def _x25519_rotate(self, i, e, p, q, kid, ts, secret) -> None:
|
|
506
|
+
state = self._key_event(i, q, kid, "x25519.rotate")
|
|
507
|
+
_require(state["kid"] == kid, i, "signer", "an x25519 rotation signed by another key than the name's")
|
|
508
|
+
state.update(x=q["new_x25519"], key_index=i, event=i)
|
|
509
|
+
|
|
510
|
+
# -- consent and private content (§8, §9) --------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
def _grant(self, i, e, p, q, kid, ts, secret) -> None:
|
|
513
|
+
_require(q["owner_kid"] == kid, i, "grant", "a grant is signed by the inbox owner's key (AICS-1 §9)")
|
|
514
|
+
pair = (kid, q["sender_kid"])
|
|
515
|
+
prior = self.grants.get(pair)
|
|
516
|
+
_require(q["prev_grant_index"] == (prior["index"] if prior else None), i, "stale_grant",
|
|
517
|
+
"prev_grant_index is not the pair's latest grant")
|
|
518
|
+
_require(q["max"] == 0 or q["expires"] > ts, i, "grant", "the grant has expired")
|
|
519
|
+
self.grants[pair] = dict(q, index=i, ts=ts)
|
|
520
|
+
|
|
521
|
+
def _recipient(self, i, q, recipient) -> dict[str, Any]:
|
|
522
|
+
state = self.active.get(recipient)
|
|
523
|
+
_require(state is not None and state["key_index"] == q["to_key_leaf_index"], i, "stale_key",
|
|
524
|
+
"to_key_leaf_index is not the leaf that set the recipient's current encryption key (AICS-1 §8.1)")
|
|
525
|
+
return state
|
|
526
|
+
|
|
527
|
+
def _dm(self, i, e, p, q, kid, ts, secret) -> None:
|
|
528
|
+
self._recipient(i, q, q["to_kid"])
|
|
529
|
+
_require(q["seq"] == p["seq"] and q["prev"] == p["prev"], i, "dm", "the DM's seq/prev are not the envelope's")
|
|
530
|
+
grant = self.grants.get((q["to_kid"], kid), self.grants.get((q["to_kid"], "*")))
|
|
531
|
+
_require(grant is not None and grant["index"] == q["grant_index"], i, "no_consent",
|
|
532
|
+
"grant_index is not the recipient's live grant for this sender (AICS-1 §9)")
|
|
533
|
+
_require(grant["max"] > self.used.get(grant["index"], 0) and grant["expires"] > ts, i, "grant_exhausted",
|
|
534
|
+
"the grant is used up or expired at this index (AICS-1 §9)")
|
|
535
|
+
self.used[grant["index"]] = self.used.get(grant["index"], 0) + 1
|
|
536
|
+
|
|
537
|
+
def _capsule(self, i, e, p, q, kid, ts, secret) -> None:
|
|
538
|
+
self._recipient(i, q, q["owner_kid"])
|
|
539
|
+
_require(q["owner_kid"] == kid, i, "capsule", "a capsule is sealed to its own author (AICS-1 §8)")
|
|
540
|
+
|
|
541
|
+
# -- the service's own entries (§6.4, §10) -------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
def _tombstone(self, i, e, p, q, kid, ts, secret) -> None:
|
|
544
|
+
_require(q["target_index"] < i, i, "tombstone", "a tombstone targets an earlier leaf")
|
|
545
|
+
target = self.entries[q["target_index"]]
|
|
546
|
+
if target["type"] in ("dm", "capsule"):
|
|
547
|
+
_require(q["reason_code"] == "order", i, "tombstone", "a ciphertext is tombstoned only on an order "
|
|
548
|
+
"(AICS-1 §10.3)")
|
|
549
|
+
|
|
550
|
+
def _refusal(self, i, e, p, q, kid, ts, secret) -> None:
|
|
551
|
+
if q["attempt_kid"] is not None:
|
|
552
|
+
_require(q["attempt_nonce"] is not None and q["attempt_expires"] is not None, i, "refusal",
|
|
553
|
+
"an authenticated refusal records the whole attempt tuple (AICS-1 §10.2)")
|
|
554
|
+
self.nonces.setdefault((q["attempt_kid"], q["attempt_nonce"]), i) # used for good, as an envelope's
|
|
555
|
+
|
|
556
|
+
def _policy(self, i, e, p, q, kid, ts, secret) -> None:
|
|
557
|
+
_require(q["log_kid"] != q["policy_kid"] and q["owner_kid"] not in (q["log_kid"], q["policy_kid"]), i,
|
|
558
|
+
"policy", "the service keys of a policy event are not distinct (AICS-1 §3.1)")
|
|
559
|
+
_require((q["log_kid"], q["policy_kid"], q["owner_kid"]) == (self.log_kid, self.policy_kid, self.owner_kid),
|
|
560
|
+
i, "policy", "a policy event names other service keys than the pinned ones (AICS-1 §6.4)")
|
|
561
|
+
_require(q["prev_policy_index"] == (self.policy_events[-1] if self.policy_events else None), i, "policy",
|
|
562
|
+
"prev_policy_index is not the previous policy event")
|
|
563
|
+
if not self.policy_events:
|
|
564
|
+
_require(i == 0 and q["kind"] == "charter" and q["effective_after"] == 0, i, "policy",
|
|
565
|
+
"the first policy event is the charter at leaf 0 (AICS-1 §6.4)")
|
|
566
|
+
data = POLICY_CONTEXT + canonicalize({k: v for k, v in q.items() if k != "log_sig"})
|
|
567
|
+
try:
|
|
568
|
+
idkeys.parse_did_key(q["log_kid"]).verify(u64d(q["log_sig"], "log_sig"), data)
|
|
569
|
+
except (InvalidSignature, ValueError):
|
|
570
|
+
raise ReplayProblem(i, "policy", f"leaf {i}: the log key's countersignature of the charter does not "
|
|
571
|
+
"verify (AICS-1 §6.4)") from None
|
|
572
|
+
else:
|
|
573
|
+
_require(q["log_sig"] is None, i, "policy", "only the charter carries the log key's countersignature")
|
|
574
|
+
self.policy_events.append(i)
|
|
575
|
+
|
|
576
|
+
# -- SUP-1 (§20.3): signers above, the asset here ------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
def _sup_asset(self, i, e, q) -> None:
|
|
579
|
+
_require(q["asset_hash"] == sup_asset_hash(e["origin"]), i, "asset",
|
|
580
|
+
"asset_hash is not the SUP asset of this origin (AICS-1 §20.2)")
|
|
581
|
+
|
|
582
|
+
def _sup_task(self, i, e, p, q, kid, ts, secret) -> None:
|
|
583
|
+
self._sup_asset(i, e, q)
|
|
584
|
+
|
|
585
|
+
def _sup_credit(self, i, e, p, q, kid, ts, secret) -> None:
|
|
586
|
+
self._sup_asset(i, e, q)
|
|
587
|
+
|
|
588
|
+
def _sup_spend(self, i, e, p, q, kid, ts, secret) -> None:
|
|
589
|
+
self._sup_asset(i, e, q)
|
|
590
|
+
|
|
591
|
+
# -- what the message's report says -------------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
def signer_of(self, kid: str) -> dict[str, Any]:
|
|
594
|
+
"""Who ``kid`` was immediately after the replay: a service key or a name's current key."""
|
|
595
|
+
if kid == self.policy_kid:
|
|
596
|
+
return {"role": "policy key (pinned)"}
|
|
597
|
+
if kid == self.owner_kid:
|
|
598
|
+
return {"role": "owner key (pinned)"}
|
|
599
|
+
state = self.active.get(kid)
|
|
600
|
+
if state is not None:
|
|
601
|
+
return {"role": "name", "name": state["name"], "tier": state["tier"]}
|
|
602
|
+
return {"role": "unknown"}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# ---------------------------------------------------------------------------
|
|
606
|
+
# the whole check
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@dataclass
|
|
611
|
+
class Outcome:
|
|
612
|
+
"""What :func:`verify` found; ``result`` is valid, incomplete or invalid."""
|
|
613
|
+
|
|
614
|
+
result: str
|
|
615
|
+
data: dict[str, Any]
|
|
616
|
+
text: list[str]
|
|
617
|
+
message: str = ""
|
|
618
|
+
evidence: dict[str, Any] | None = None
|
|
619
|
+
input_problem: bool = False
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _step(steps: list[dict[str, Any]], name: str, status: str, detail: str) -> None:
|
|
623
|
+
steps.append({"step": name, "status": status, "detail": detail})
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def verify(
|
|
627
|
+
inp: MessageInput,
|
|
628
|
+
policy: Policy,
|
|
629
|
+
*,
|
|
630
|
+
now: int,
|
|
631
|
+
prefix_source: Callable[[int], tuple[list[bytes] | None, str]] | None = None,
|
|
632
|
+
saved: Iterable[tuple[str, str]] = (),
|
|
633
|
+
larger_head: Callable[[Cp, Cp], tuple[str, str]] | None = None,
|
|
634
|
+
) -> Outcome:
|
|
635
|
+
"""Run the §14.3 steps (see the module docstring).
|
|
636
|
+
|
|
637
|
+
``prefix_source(size)`` returns ``(entries or None, label)`` when the input carries no prefix. ``saved`` is
|
|
638
|
+
``(note_text, source)`` of the checkpoints the user saved. ``larger_head(bundle_cp, saved_cp)`` relates the
|
|
639
|
+
bundle's checkpoint to a larger saved head and returns ``(status, detail)`` with status consistent,
|
|
640
|
+
split-proven or unproven; None means no consistency proof can be had (offline).
|
|
641
|
+
"""
|
|
642
|
+
b = inp.bundle
|
|
643
|
+
steps: list[dict[str, Any]] = []
|
|
644
|
+
unproven = list(ALWAYS_UNPROVEN)
|
|
645
|
+
incomplete: list[str] = []
|
|
646
|
+
data: dict[str, Any] = {"index": b.index, "steps": steps, "unproven": unproven, "not_replayed": list(NOT_REPLAYED)}
|
|
647
|
+
|
|
648
|
+
def failed_input(message: str) -> Outcome:
|
|
649
|
+
_step(steps, "input", "invalid", message)
|
|
650
|
+
data["result"] = "invalid-input"
|
|
651
|
+
return Outcome("invalid", data, [f"input {message}"], message=message, input_problem=True)
|
|
652
|
+
|
|
653
|
+
def invalid(step: str, detail: str, message: str, evidence: dict[str, Any]) -> Outcome:
|
|
654
|
+
_step(steps, step, "invalid", detail)
|
|
655
|
+
data["result"] = "invalid"
|
|
656
|
+
return Outcome("invalid", data, [f"{step:<10} INVALID: {detail}"], message=message, evidence=evidence)
|
|
657
|
+
|
|
658
|
+
# 1. checkpoint, inclusion, entry -------------------------------------------------------------------------
|
|
659
|
+
chk = check_note(b.checkpoint_note, policy, source="bundle", now=now, require_fresh=False)
|
|
660
|
+
if chk.cp is None:
|
|
661
|
+
return failed_input(f"the bundle's checkpoint is not signed by the pinned log key for {policy.origin}: "
|
|
662
|
+
f"{chk.error}")
|
|
663
|
+
cp = chk.cp
|
|
664
|
+
data["checkpoint"] = {**cp.brief(), "witness_policy_met": chk.ok, "witnesses": chk.witnesses}
|
|
665
|
+
if chk.ok:
|
|
666
|
+
_step(steps, "checkpoint", "valid", f"log-signed under the pinned key, size {cp.size}; witness policy "
|
|
667
|
+
f"quorum {policy.quorum} met")
|
|
668
|
+
else:
|
|
669
|
+
_step(steps, "checkpoint", "incomplete", f"log-signed, but the pinned witness policy is not met: {chk.error}")
|
|
670
|
+
incomplete.append(f"the bundle's checkpoint does not meet the pinned witness policy ({chk.error})")
|
|
671
|
+
if not (b.index < cp.size and verify_inclusion(leaf_hash(b.entry_bytes), b.index, cp.size,
|
|
672
|
+
list(b.inclusion_proof), cp.root)):
|
|
673
|
+
return failed_input(f"the inclusion proof of leaf {b.index} does not verify against the checkpoint of size "
|
|
674
|
+
f"{cp.size}")
|
|
675
|
+
_step(steps, "inclusion", "valid", f"leaf {b.index} is in the tree of size {cp.size} (RFC 9162)")
|
|
676
|
+
base_evidence = {"type": "aics-message-evidence/v1", "origin": policy.origin, "log_vkey": policy.log_vkey,
|
|
677
|
+
"checkpoint": cp.evidence(), "bundle": b.to_dict()}
|
|
678
|
+
try:
|
|
679
|
+
doc = loads_strict(b.entry_bytes)
|
|
680
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
681
|
+
doc, problem = None, f"the entry bytes are not strict JSON ({exc})"
|
|
682
|
+
else:
|
|
683
|
+
problem = entry_problem(doc, b.entry_bytes, policy.origin)
|
|
684
|
+
if problem is not None:
|
|
685
|
+
return invalid("entry", problem, f"the log included leaf {b.index}, which is not an AICS-1 entry: {problem}",
|
|
686
|
+
{**base_evidence, "reason": "invalid-entry", "problem": problem})
|
|
687
|
+
res = verify_bundle(b, policy.log_key(), expected_origin=policy.origin)
|
|
688
|
+
if not res.ok:
|
|
689
|
+
return failed_input(f"the bundle does not bind to its entry: {res.error}")
|
|
690
|
+
_step(steps, "entry", "valid", "JCS entry v2; the payload copies the envelope's, and the bundle's envelope is the "
|
|
691
|
+
"embedded one (AICS-1 §5.1, §14.3 step 1)")
|
|
692
|
+
data["entry"] = {"type": doc["type"], "actor_kid": doc["actor_kid"], "ts": doc["ts"],
|
|
693
|
+
"scheme": doc["payload"]["envelope"].get("auth", {}).get("scheme"),
|
|
694
|
+
"leaf_hash": b64e(leaf_hash(b.entry_bytes))}
|
|
695
|
+
|
|
696
|
+
# 2. the prefix and continuity ------------------------------------------------------------------------------
|
|
697
|
+
prefix, label = (inp.prefix, "the package's entry_prefix") if inp.prefix is not None else (None, "")
|
|
698
|
+
if prefix is None and prefix_source is not None:
|
|
699
|
+
prefix, label = prefix_source(cp.size)
|
|
700
|
+
tree = None
|
|
701
|
+
if prefix is None:
|
|
702
|
+
_step(steps, "history", "incomplete", label or "no entry prefix: give a §14.3 package, --tiles DIR, or run "
|
|
703
|
+
"without --offline to read the pinned tiles")
|
|
704
|
+
incomplete.append("the name, key and sequence history through the index is not checked: no complete entry "
|
|
705
|
+
"prefix (AICS-1 §14.3 step 2)")
|
|
706
|
+
else:
|
|
707
|
+
if len(prefix) != cp.size:
|
|
708
|
+
return failed_input(f"{label} has {len(prefix)} entries, not the checkpoint's {cp.size}")
|
|
709
|
+
tree = MerkleTree(prefix)
|
|
710
|
+
if tree.root() != cp.root:
|
|
711
|
+
return failed_input(f"{label} does not reconstruct the checkpoint root (AICS-1 §14.3)")
|
|
712
|
+
if prefix[b.index] != b.entry_bytes:
|
|
713
|
+
return failed_input(f"{label} holds other bytes at leaf {b.index} than the bundle")
|
|
714
|
+
continuity = data.setdefault("continuity", [])
|
|
715
|
+
if tree is not None:
|
|
716
|
+
notes: list[tuple[Cp, str]] = []
|
|
717
|
+
for text, src in [(text, src) for text, src in saved] + [(t, "the package") for t in inp.notes]:
|
|
718
|
+
other, error, _ = log_signed(text, origin=policy.origin, log_vkey=policy.log_vkey, source=src)
|
|
719
|
+
if other is None:
|
|
720
|
+
if src == "the package":
|
|
721
|
+
return failed_input(f"a checkpoint note of the package is not log-signed: {error}")
|
|
722
|
+
continuity.append({"source": src, "status": "skipped", "detail": f"not log-signed: {error}"})
|
|
723
|
+
continue
|
|
724
|
+
notes.append((other, src))
|
|
725
|
+
# Saved heads larger than the bundle's checkpoint: the largest one (the saved head) needs a proof; every
|
|
726
|
+
# smaller saved note is checked against the authenticated prefix itself.
|
|
727
|
+
larger = [(o, src) for o, src in notes if o.size > cp.size and src != "the package"]
|
|
728
|
+
largest = max(larger, key=lambda t: t[0].size, default=None)
|
|
729
|
+
notes = [(o, src) for o, src in notes if o.size <= cp.size or src == "the package"] + (
|
|
730
|
+
[largest] if largest is not None else [])
|
|
731
|
+
for other, src in notes:
|
|
732
|
+
if other.size <= cp.size:
|
|
733
|
+
if tree.root(other.size) != other.root:
|
|
734
|
+
message = (f"split view: the log signed a checkpoint of size {other.size} ({src}) whose root is "
|
|
735
|
+
f"not the root at that size of the history the bundle's checkpoint authenticates")
|
|
736
|
+
return invalid("continuity", message, message, {
|
|
737
|
+
**base_evidence, "reason": "split-proven", "other_checkpoint": other.evidence(),
|
|
738
|
+
"consistency_proof": [b64e(h) for h in tree.consistency_proof(other.size)]
|
|
739
|
+
if other.size else [],
|
|
740
|
+
"authenticated_prefix_root": b64e(tree.root(other.size))})
|
|
741
|
+
continuity.append({"source": src, "size": other.size, "status": "consistent"})
|
|
742
|
+
elif src == "the package":
|
|
743
|
+
incomplete.append(f"a checkpoint note of the package (size {other.size}) is beyond its prefix")
|
|
744
|
+
continuity.append({"source": src, "size": other.size, "status": "unproven"})
|
|
745
|
+
else:
|
|
746
|
+
status, detail = larger_head(cp, other) if larger_head is not None else (
|
|
747
|
+
"unproven", "needs a consistency proof from the tiles (not with --offline)")
|
|
748
|
+
if status == "split-proven":
|
|
749
|
+
message = f"split view: {detail}"
|
|
750
|
+
return invalid("continuity", message, message, {
|
|
751
|
+
**base_evidence, "reason": "split-proven", "other_checkpoint": other.evidence()})
|
|
752
|
+
continuity.append({"source": src, "size": other.size, "status": status, "detail": detail})
|
|
753
|
+
if status != "consistent":
|
|
754
|
+
incomplete.append(f"continuity with your saved checkpoint of size {other.size} is unproven "
|
|
755
|
+
f"({detail})")
|
|
756
|
+
_step(steps, "continuity", "valid" if all(c["status"] in ("consistent", "skipped") for c in continuity)
|
|
757
|
+
else "incomplete", f"{sum(c['status'] == 'consistent' for c in continuity)} saved or packaged "
|
|
758
|
+
"checkpoint(s) consistent with the authenticated prefix" if continuity else
|
|
759
|
+
"no saved checkpoint of this origin to compare")
|
|
760
|
+
|
|
761
|
+
# 2-3, 5. the replay ----------------------------------------------------------------------------------------
|
|
762
|
+
if tree is not None:
|
|
763
|
+
replay = Replay(policy)
|
|
764
|
+
before: dict[str, Any] = {"role": "unknown"}
|
|
765
|
+
try:
|
|
766
|
+
for i in range(b.index + 1):
|
|
767
|
+
if i == b.index:
|
|
768
|
+
before = replay.signer_of(doc["actor_kid"])
|
|
769
|
+
replay.apply(i, prefix[i])
|
|
770
|
+
except Unsupported as exc:
|
|
771
|
+
_step(steps, "history", "incomplete", str(exc))
|
|
772
|
+
incomplete.append(f"unsupported profile: {exc}; AICS-1 §19 requires a client without the profile to "
|
|
773
|
+
"report it rather than interpret its entries")
|
|
774
|
+
except ReplayProblem as exc:
|
|
775
|
+
j = exc.index
|
|
776
|
+
return invalid("history", str(exc), f"the log accepted leaf {j}, which AICS-1 forbids ({exc.rule}): {exc}",
|
|
777
|
+
{**base_evidence, "reason": "invalid-history", "rule": exc.rule, "problem": str(exc),
|
|
778
|
+
"leaf": {"index": j,
|
|
779
|
+
"entry_bytes": base64.urlsafe_b64encode(prefix[j]).rstrip(b"=").decode(),
|
|
780
|
+
"inclusion_proof": [b64e(h) for h in tree.inclusion_proof(j)]}})
|
|
781
|
+
else:
|
|
782
|
+
e = replay.entries[b.index]
|
|
783
|
+
scheme = replay.schemes[b.index]
|
|
784
|
+
# the signer's standing at the index: before the entry, or what a claim or an upgrade made it
|
|
785
|
+
signer = before if before["role"] != "unknown" else replay.signer_of(e["actor_kid"])
|
|
786
|
+
_step(steps, "history", "valid", f"leaves 0-{b.index} replayed: names, keys, revocations, policy "
|
|
787
|
+
"authority, sender sequences, nonces and grants (AICS-1 §14.3 step 2)")
|
|
788
|
+
if scheme == idenv.SHARED_SECRET_SCHEME:
|
|
789
|
+
_step(steps, "envelope", "unproven", "shared-secret tier: the pinned policy key attests that the service "
|
|
790
|
+
"checked the secret; authorship is unproven (AICS-1 §14.3 step 3)")
|
|
791
|
+
unproven.append("authorship of a shared-secret entry (only the policy key's attestation exists)")
|
|
792
|
+
else:
|
|
793
|
+
_step(steps, "envelope", "valid", f"{scheme} signature by {e['actor_kid']}, fresh at ts {e['ts']}, "
|
|
794
|
+
f"seq {e['payload']['envelope']['protected']['seq']} in its chain")
|
|
795
|
+
data["signer"] = signer
|
|
796
|
+
if e["type"] == "dm":
|
|
797
|
+
q = e["payload"]["envelope"]["payload"]
|
|
798
|
+
_step(steps, "dm", "valid", f"grant at leaf {q['grant_index']} admits it; the recipient's encryption "
|
|
799
|
+
f"key is the one leaf {q['to_key_leaf_index']} set (AICS-1 §14.3 step 5)")
|
|
800
|
+
tombstones = []
|
|
801
|
+
for j in range(b.index + 1, len(prefix)):
|
|
802
|
+
try:
|
|
803
|
+
later = loads_strict(prefix[j])
|
|
804
|
+
except (ValueError, UnicodeDecodeError):
|
|
805
|
+
continue
|
|
806
|
+
if isinstance(later, dict) and later.get("type") == "tombstone":
|
|
807
|
+
tq = later.get("payload", {}).get("envelope", {}).get("payload", {})
|
|
808
|
+
if isinstance(tq, dict) and tq.get("target_index") == b.index:
|
|
809
|
+
tombstones.append({"index": j, "reason_code": tq.get("reason_code")})
|
|
810
|
+
data["tombstones"] = tombstones
|
|
811
|
+
else:
|
|
812
|
+
try:
|
|
813
|
+
scheme = check_envelope(b.index, doc, policy.policy_kid)
|
|
814
|
+
except ReplayProblem as exc:
|
|
815
|
+
return invalid("envelope", str(exc), f"the log accepted leaf {b.index}, whose envelope does not verify: "
|
|
816
|
+
f"{exc}", {**base_evidence, "reason": "invalid-envelope",
|
|
817
|
+
"problem": str(exc)})
|
|
818
|
+
if scheme == idenv.SHARED_SECRET_SCHEME:
|
|
819
|
+
_step(steps, "envelope", "unproven", "shared-secret tier: the policy key's attestation verifies; "
|
|
820
|
+
"authorship is unproven")
|
|
821
|
+
else:
|
|
822
|
+
_step(steps, "envelope", "valid", f"{scheme} signature by the kid it names, fresh at ts {doc['ts']}; whether "
|
|
823
|
+
"that kid was its name's current key needs the history")
|
|
824
|
+
|
|
825
|
+
# 4. the body ---------------------------------------------------------------------------------------------
|
|
826
|
+
if b.body_or_ct is not None:
|
|
827
|
+
_step(steps, "body", "valid", f"body_or_ct hashes to {res.body_bound_at} and has its size (AICS-1 §14.3 step 4)")
|
|
828
|
+
else:
|
|
829
|
+
_step(steps, "body", "not-applicable", "the bundle carries no body: the metadata is verified, the content is "
|
|
830
|
+
"not available here")
|
|
831
|
+
if data.get("tombstones"):
|
|
832
|
+
t = data["tombstones"][0]
|
|
833
|
+
_step(steps, "tombstone", "valid", f"the body is hidden by the tombstone at leaf {t['index']} "
|
|
834
|
+
f"({t['reason_code']}); the leaf stays")
|
|
835
|
+
|
|
836
|
+
# policy cards of a package ----------------------------------------------------------------------------
|
|
837
|
+
if inp.cards:
|
|
838
|
+
logged = set()
|
|
839
|
+
if tree is not None and prefix is not None:
|
|
840
|
+
for raw in prefix:
|
|
841
|
+
try:
|
|
842
|
+
pe = loads_strict(raw)
|
|
843
|
+
except (ValueError, UnicodeDecodeError):
|
|
844
|
+
continue
|
|
845
|
+
pq = pe.get("payload", {}).get("envelope", {}).get("payload", {}) if isinstance(pe, dict) else {}
|
|
846
|
+
if isinstance(pe, dict) and pe.get("type") == "policy" and isinstance(pq, dict) and pq.get("kind") == "card":
|
|
847
|
+
logged.add(pq.get("document_hash"))
|
|
848
|
+
cards = []
|
|
849
|
+
for n, raw in enumerate(inp.cards):
|
|
850
|
+
try:
|
|
851
|
+
card = loads_strict(raw)
|
|
852
|
+
canonical = isinstance(card, dict) and canonicalize(card) == raw
|
|
853
|
+
except (ValueError, UnicodeDecodeError):
|
|
854
|
+
card, canonical = None, False
|
|
855
|
+
if not canonical:
|
|
856
|
+
return failed_input(f"policy_cards[{n}] is not a JCS card")
|
|
857
|
+
signed = any(c.status == "valid" for c in check_signatures(card, policy.policy_keys()))
|
|
858
|
+
h = card_hash(card)
|
|
859
|
+
cards.append({"card_hash": h, "signed": signed, "logged": h in logged})
|
|
860
|
+
if not signed:
|
|
861
|
+
return failed_input(f"policy_cards[{n}] carries no valid signature of the pinned policy key")
|
|
862
|
+
if tree is not None and h not in logged:
|
|
863
|
+
return failed_input(f"policy_cards[{n}] is named by no card policy leaf of the prefix")
|
|
864
|
+
data["policy_cards"] = cards
|
|
865
|
+
|
|
866
|
+
data["result"] = "incomplete" if incomplete else "valid"
|
|
867
|
+
data["incomplete"] = incomplete
|
|
868
|
+
text = [f"message: leaf {b.index} of {policy.origin} ({data['entry']['type']})"]
|
|
869
|
+
text += [f"{s['step']:<10} {s['status']}: {s['detail']}" for s in steps]
|
|
870
|
+
text += [f"unproven {u}" for u in unproven]
|
|
871
|
+
text += [f"not replayed {n}" for n in NOT_REPLAYED]
|
|
872
|
+
if incomplete:
|
|
873
|
+
message = "incomplete: " + "; ".join(incomplete)
|
|
874
|
+
return Outcome("incomplete", data, text, message=message)
|
|
875
|
+
return Outcome("valid", data, text)
|