@passioncode-ai/passioncode 0.2.40 → 0.2.42

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +4 -1
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -2
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +22 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +44 -6
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +46 -8
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -4
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +7 -2
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +30 -15
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +5 -0
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +17 -9
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate-rules.mjs +606 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +48 -525
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +43 -6
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -4
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +48 -15
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +4 -3
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +39 -16
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +8 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -11
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +86 -642
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate_rules.py +766 -0
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +70 -12
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +59 -8
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +56 -16
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +4 -2
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +220 -21
  37. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  38. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +4 -1
  39. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -0
  40. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +69 -34
  41. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +27 -15
  42. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +13 -7
  43. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +8 -0
  44. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -9
  45. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +60 -1
  46. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +10 -3
  47. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +39 -4
  48. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -7,35 +7,37 @@ revision names its parent's digest and is signed by a passkey assertion (WebAuth
7
7
  derived from its own digest. This is the contract's `src/estate-record-rules.ts`, `src/webauthn.ts` and `src/p256.ts`
8
8
  at the pin, ported answer for answer and held to the contract's own vectors (`test/fixtures/contract/estate-vectors.json`):
9
9
 
10
- - ``verify_estate_record(record, at=..., parent=... | authorities=...)`` — the verdict a machine applies a revision on,
10
+ - ``verify_estate_record(record, at=..., parent=... | authorities=...)``: the verdict a machine applies a revision on,
11
11
  after it has checked the revision against `estate-record.schema.json` (the verifier does not check the schema);
12
- - ``estate_record_rules(kind, value)`` — FAC-SEM-072…076 for `estate-record`, `estate-record-revision` and
13
- `estate-placement`, with FAC-SEM-134 on each machine's profile (DEC-0106);
14
- - ``machine_profile(record, machine_id)`` — a machine's profile (DEC-0106): `full`, `member`, `view`, or None;
15
- - ``delta_anchor_of(record)`` — the revocation delta's head a revision vouches for (DEC-0107), or None; a revision
12
+ - ``estate_record_rules(kind, value)``: FAC-SEM-072…076 for `estate-record`, `estate-record-revision` and
13
+ `estate-placement`, with FAC-SEM-134 on each machine's profile (DEC-0106) and FAC-SEM-147 on the organizations a
14
+ record pins (DEC-0127, DEC-0128);
15
+ - ``machine_profile(record, machine_id)``: a machine's profile (DEC-0106): `full`, `member`, `view`, or None;
16
+ - ``delta_anchor_of(record)``: the revocation delta's head a revision vouches for (DEC-0107), or None; a revision
16
17
  never moves it back (FAC-SEM-137, in the revision rules and the verdict);
17
- - ``hub_key_certified(record, kid, x, at)`` — DL-14: a delegation verifier that reads the estate record trusts a token's
18
- `kid` only while a current revision certifies it for a machine (``fabric_delegation`` calls it).
18
+ - ``hub_key_certified(record, kid, x, at, context=...)``: DL-14: a delegation verifier that reads the estate record
19
+ trusts a token's `kid` only while a current revision certifies it for a machine, and in an organization's context
20
+ only under that context's hub key while the record pins it (DL-14a, DEC-0128; ``fabric_delegation`` calls it).
21
+
22
+ The record's own rules (FAC-SEM-072…076 and the rest) live in ``fabric_estate_rules`` since 0.52.0 (decision D8); every
23
+ name there is re-exported here, so callers keep ``import fabric_estate``.
19
24
 
20
25
  Standard library only. ES256 is verified here in pure Python (P-256, FIPS 186-5 / SEC 1); EdDSA by the Ed25519 verifier
21
26
  in ``fabric_delegation``.
22
27
  """
23
28
  from __future__ import annotations
24
29
 
25
- import base64
26
- import hashlib
27
30
  import json
28
31
  import math
29
32
  import re
30
33
  import sys
31
34
  from pathlib import Path
32
- from typing import Any, Dict, List, Optional, Tuple
35
+ from typing import Any, Dict, List
33
36
 
34
37
  sys.path.insert(0, str(Path(__file__).resolve().parent))
35
- from fabric_crypto import ed25519_verify, is_small_order_key # noqa: E402 - beside this file (FAA-65)
36
- from fabric_jsvalue import jcs # noqa: E402 - beside this file (FAA-65)
37
- import fabric_secrets as _secrets # noqa: E402 - beside this file: the contract's credential detector
38
- from fabric_crypto import ( # noqa: E402 - the core (FAA-65)
38
+ from fabric_crypto import ed25519_verify, is_small_order_key # noqa: E402,F401 - beside this file (FAA-65)
39
+ from fabric_jsvalue import jcs # noqa: E402,F401 - beside this file (FAA-65)
40
+ from fabric_crypto import ( # noqa: E402,F401 - the core (FAA-65); the private names stay reachable here
39
41
  _P256_B as _B,
40
42
  _P256_N as _N,
41
43
  _P256_P as _P,
@@ -47,105 +49,65 @@ from fabric_crypto import ( # noqa: E402 - the core (FAA-65)
47
49
  node_base64 as _node_base64,
48
50
  sha256 as _sha256,
49
51
  )
50
- from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
52
+ from fabric_jsvalue import ( # noqa: E402,F401 - the core (FAA-65)
51
53
  MISSING as _MISSING,
52
54
  get as _get,
53
55
  get_string as _s,
54
- indexed_objects as _indexed,
55
- js_string as _js_string,
56
56
  js_to_number as _js_to_number,
57
- key_of as _key_of,
58
57
  objects as _list,
59
58
  safe_integer as _safe_integer,
60
59
  same as _same,
61
- set_has as _set_has, # every membership the contract keeps in a Set or tests with includes (FAA-113)
60
+ set_has as _set_has,
62
61
  show as _show,
63
62
  )
64
- from fabric_time import ( # noqa: E402 - the core (FAA-65)
63
+ from fabric_time import ( # noqa: E402,F401 - the core (FAA-65)
65
64
  clock_ms as _clock,
66
65
  parse_rfc3339_ms as estate_time,
67
66
  )
67
+ # The record's rules (D8): every public name and the private ones callers reached through this module.
68
+ from fabric_estate_rules import ( # noqa: E402,F401 - beside this file
69
+ ASSERTION_ALGORITHMS,
70
+ ESTATE_CHALLENGE_TYPE,
71
+ ESTATE_RECORD_SCHEMA,
72
+ KEY_REFERENCE,
73
+ MACHINE_PROFILES,
74
+ MAX_VALIDITY_SECONDS,
75
+ ORG_CONTEXT_PREFIX,
76
+ PERSONAL_CONTEXT,
77
+ _B64URL,
78
+ _anchor_findings,
79
+ _context_of,
80
+ _dropped_findings,
81
+ _lt,
82
+ _record_findings,
83
+ _revocation_key,
84
+ _scope_admits,
85
+ _valid_device_key,
86
+ credential_key,
87
+ delta_anchor_of,
88
+ device_key_fingerprint,
89
+ device_key_ref,
90
+ estate_challenge,
91
+ estate_challenge_input,
92
+ estate_record_digest,
93
+ estate_record_rules,
94
+ grant_context_findings,
95
+ is_signing_rp_id,
96
+ machine_profile,
97
+ origin_within,
98
+ parse_context,
99
+ placement_grant_findings,
100
+ )
68
101
 
69
- ESTATE_RECORD_SCHEMA = "estate-record/1"
70
- ESTATE_CHALLENGE_TYPE = "estate-revision/1"
71
- MAX_VALIDITY_SECONDS = 30 * 24 * 60 * 60
72
102
  #: How far ahead of the verifier's clock a signed `issuedAt` may be before it is `not-yet-valid` (DEC-0066): the one
73
103
  #: skew of the relay family's and the estate record's times (`CLOCK_SKEW_SECONDS` in the contract's `src/rfc3339.ts`).
74
104
  CLOCK_SKEW_SECONDS = 60
75
- ASSERTION_ALGORITHMS = ("ES256", "EdDSA")
76
105
  FLAGS = {"UP": 0x01, "UV": 0x04, "BE": 0x08, "BS": 0x10, "AT": 0x40, "ED": 0x80}
77
- _B64URL = re.compile(r"[A-Za-z0-9_-]+")
78
106
  _SURROGATE = re.compile("[\ud800-\udfff]")
79
107
 
80
108
 
81
- # --- JavaScript's view of JSON values, which the contract's answers are written in ---------------------------------
82
-
83
-
84
- #: DEC-0082: the personal context, and an organization's context ``org:<orgId>`` (the contract's ``context-route.ts``).
85
- PERSONAL_CONTEXT = "personal"
86
- ORG_CONTEXT_PREFIX = "org:"
87
- _ORG_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}")
88
-
89
-
90
- def parse_context(value: Any) -> Optional[Dict[str, str]]:
91
- """The context ``value`` names, or None when it names none: ``personal`` exactly, or ``org:`` and an organization id
92
- (``[A-Za-z0-9][A-Za-z0-9_-]{0,127}``). Compared as written: ``Personal``, ``ORG:x`` or a trailing newline name no
93
- context. Never raises."""
94
- if not isinstance(value, str):
95
- return None
96
- if value == PERSONAL_CONTEXT:
97
- return {"kind": "personal"}
98
- if not value.startswith(ORG_CONTEXT_PREFIX):
99
- return None
100
- org = value[len(ORG_CONTEXT_PREFIX):]
101
- return {"kind": "org", "org": org} if _ORG_ID.fullmatch(org) else None
102
-
103
-
104
- def _context_of(entry: Any) -> Any:
105
- """A placement's or a secret placement's context: its ``context``, ``personal`` when absent (DEC-0082)."""
106
- context = _get(entry, "context")
107
- return PERSONAL_CONTEXT if context is _MISSING else context
108
-
109
-
110
- # --- P-256 (DEC-0053; SEC 1 v2, FIPS 186-5) ------------------------------------------------------------------------
111
-
112
-
113
109
  # --- WebAuthn Level 3 §7.2, offline (src/webauthn.ts) ----------------------------------------------------------------
114
110
 
115
- def _canonical_b64url(text: str) -> bool:
116
- try:
117
- return _b64url(_b64url_bytes(text)) == text
118
- except ValueError:
119
- return False
120
-
121
-
122
- def credential_key(public_key: Any, alg: str) -> Dict[str, Any]:
123
- """The credential's public key, validated before use: ``{"key": ...}`` or ``{"problem": "unusable-key" |
124
- "small-order-key"}``. A P-256 point valid by SEC 1 for ES256; an Ed25519 key of full order for EdDSA."""
125
- if alg not in ASSERTION_ALGORITHMS:
126
- return {"problem": "unusable-key"}
127
- if (not isinstance(public_key, dict) or "d" in public_key or not isinstance(public_key.get("x"), str)
128
- or not _B64URL.fullmatch(public_key["x"]) or not _canonical_b64url(public_key["x"])):
129
- return {"problem": "unusable-key"}
130
- if alg == "ES256":
131
- y = public_key.get("y")
132
- if (public_key.get("kty") != "EC" or public_key.get("crv") != "P-256" or not isinstance(y, str)
133
- or not _B64URL.fullmatch(y) or not _canonical_b64url(y)):
134
- return {"problem": "unusable-key"}
135
- xb, yb = _b64url_bytes(public_key["x"]), _b64url_bytes(y)
136
- if len(xb) != 32 or len(yb) != 32 or not is_valid_p256_key(b"\x04" + xb + yb):
137
- return {"problem": "unusable-key"}
138
- return {"key": ("ES256", xb, yb)}
139
- if public_key.get("kty") != "OKP" or public_key.get("crv") != "Ed25519":
140
- return {"problem": "unusable-key"}
141
- raw = _b64url_bytes(public_key["x"])
142
- if len(raw) != 32:
143
- return {"problem": "unusable-key"}
144
- if is_small_order_key(raw):
145
- return {"problem": "small-order-key"} # DEC-0045 (CT-29): refused before any use
146
- return {"key": ("EdDSA", raw)}
147
-
148
-
149
111
  def _no_constant(name: str) -> Any:
150
112
  raise ValueError("%s is not JSON" % name)
151
113
 
@@ -220,61 +182,6 @@ def verify_assertion(assertion: Any, credential: Dict[str, Any], challenge: byte
220
182
 
221
183
  # --- the record (src/estate-record-rules.ts) ---------------------------------------------------------------------
222
184
 
223
- def estate_record_digest(record: Dict[str, Any]) -> str:
224
- """`sha256:` and the hex SHA-256 of the UTF-8 RFC 8785 form of the record without `digest` and `signature`.
225
- Raises TypeError for a value RFC 8785 has no form for."""
226
- covered = {k: v for k, v in record.items() if k not in ("digest", "signature")}
227
- return "sha256:" + hashlib.sha256(jcs(covered).encode("utf-8")).hexdigest()
228
-
229
-
230
- def estate_challenge_input(digest: str) -> bytes:
231
- return jcs({"type": ESTATE_CHALLENGE_TYPE, "digest": digest}).encode("utf-8")
232
-
233
-
234
- def estate_challenge(digest: str) -> bytes:
235
- """The WebAuthn challenge a signer passes to navigator.credentials.get(): SHA-256 of estate_challenge_input."""
236
- return _sha256(estate_challenge_input(digest))
237
-
238
-
239
- _LABEL = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
240
- #: `sign.` and at least two more labels, lower case: a signing RP ID is never a bare `sign.<tld>` (DEC-0063).
241
- _SIGNING_RP = re.compile(r"sign(?:\.%s){2,}" % _LABEL, re.ASCII)
242
- _HTTPS_HOST = re.compile(r"https://([a-z0-9.-]{1,253})", re.ASCII)
243
-
244
-
245
- def is_signing_rp_id(rp_id: Any) -> bool:
246
- """Whether an RP ID is a dedicated signing RP (v4 §7, DEC-0063): first label `sign`, then the organization's or
247
- PassionCode's domain, lower case. A credential under any other RP ID is a sign-in credential and never signs."""
248
- return isinstance(rp_id, str) and len(rp_id) <= 253 and _SIGNING_RP.fullmatch(rp_id) is not None
249
-
250
-
251
- def origin_within(origin: Any, rp_id: str) -> bool:
252
- """Whether a pinned origin may run a signing ceremony for ``rp_id``: `https://`, no port, no path, its host the RP
253
- ID or a subdomain of it, lower case. Related origins are never used for signing."""
254
- match = _HTTPS_HOST.fullmatch(origin) if isinstance(origin, str) else None
255
- return match is not None and (match.group(1) == rp_id or match.group(1).endswith("." + rp_id))
256
-
257
-
258
- def _signing_credential(credential: Any, path: str, flag) -> None:
259
- """FAC-SEM-092 on one signing credential: a signing RP ID, and every pinned origin within it."""
260
- value = credential if isinstance(credential, dict) else {}
261
- if not is_signing_rp_id(_get(value, "rpId")):
262
- flag("%s/rpId" % path, "a passkey signs only under a dedicated signing RP, sign.<domain>, never under the RP ID it signs in with")
263
- return
264
- origins = _get(value, "origins")
265
- for index, origin in enumerate(origins if isinstance(origins, list) else []):
266
- if not origin_within(origin, value["rpId"]):
267
- flag("%s/origins/%d" % (path, index), "a signing ceremony runs only on %s or a host under it, over https without a port" % value["rpId"])
268
-
269
-
270
- def _revocation_key(entry: Dict[str, Any]) -> str:
271
- return "%s %s" % (_show(_get(entry, "kind")), _show(_get(entry, "ref")))
272
-
273
-
274
- def _lt(a: float, b: float) -> bool:
275
- return not (math.isnan(a) or math.isnan(b)) and a < b
276
-
277
-
278
185
  def verify_estate_record(record: Any, *, at: Any, parent: Any = _MISSING, authorities: Any = None) -> Dict[str, Any]:
279
186
  """The reference verifier, in the order of estate-record.md's ER table: the clock, shape, digest, the chain to the
280
187
  parent, the signing passkey among the trusted ones, the assertion, the validity window, then the record's own rules.
@@ -380,13 +287,6 @@ def placement_trust(record: Dict[str, Any], machine_id: str, agent_ref: str, con
380
287
  return {"publisher": [k for k in _list(_get(record, "publisherKeys")) if _same(_get(k, "kid"), _get(placement, "publisherKid"))]}
381
288
 
382
289
 
383
- def _scope_admits(record: Any, kid: Any, context: Any) -> bool:
384
- """DEC-0090: a key no `publisherScopes[]` entry names is trusted in any context; a partner's key, only in a context
385
- one of its scopes lists."""
386
- scopes = [s for s in _list(_get(record, "publisherScopes")) if _same(_get(s, "kid"), kid)]
387
- return not scopes or any(isinstance(_get(s, "contexts"), list) and _set_has(s["contexts"], context) for s in scopes)
388
-
389
-
390
290
  #: v4 §7, Freshness: an estate revision's window is at most 30 days (DEC-0055 item 2).
391
291
  _MAX_WINDOW_MS = MAX_VALIDITY_SECONDS * 1000
392
292
 
@@ -399,13 +299,22 @@ def _revision_number(value: Any) -> bool:
399
299
  return _safe_integer(value) and value >= 1
400
300
 
401
301
 
302
+ #: An organization's context id (DEC-0082), as the contract's hub-key rules read it.
303
+ _ORG_CONTEXT = re.compile(r"org:[A-Za-z0-9][A-Za-z0-9_-]{0,127}\Z")
304
+
305
+
402
306
  def hub_key_certified(record: Any, kid: Any, x: Any, at: Any, applied: Any = _MISSING,
403
- extra_revocations: Any = _MISSING) -> Dict[str, Any]:
307
+ extra_revocations: Any = _MISSING, context: Any = _MISSING) -> Dict[str, Any]:
404
308
  """DL-14 (DEC-0055, DEC-0060): whether ``record`` certifies the delegation key ``kid`` with the published key's ``x``
405
309
  at ``at`` (an aware datetime or epoch seconds), for a verifier whose machine applied ``applied``
406
310
  (``{"revision", "digest"?}``; ``_MISSING``, the default, when none is known; an explicit None is no revision and
407
311
  fails closed as ``estate-unreadable``, as Node's null does, decision D3). ``extra_revocations`` are the revocation
408
- delta's entries applied since the revision (DEC-0054). The contract's ``hubKeyCertified``, in its order; never raises. Answers ``{"certified": True, "machine",
312
+ delta's entries applied since the revision (DEC-0054). ``context`` is the context the token is in (DEC-0128): omitted or
313
+ ``personal`` reads each machine's personal ``hubKey``; an organization's ``org:<orgId>`` reads only that context's
314
+ hub keys (``machines[].contextHubKeys``) and only while the record pins the organization, so a context key never
315
+ verifies a personal token and the personal key never an organization's; any other context certifies nothing
316
+ (``kid-not-certified``), so a misconfigured callee fails closed. The contract's ``hubKeyCertified``, in its order;
317
+ never raises. Answers ``{"certified": True, "machine",
409
318
  "revision"}`` or ``{"certified": False, "fault": "token"|"estate"|"clock", "detail"}`` with detail
410
319
  ``kid-not-certified`` (the token's fault), ``estate-unreadable``, ``estate-outside-window``, ``estate-rolled-back``,
411
320
  ``estate-forked`` (the trust source's) or ``no-clock``."""
@@ -445,497 +354,32 @@ def hub_key_certified(record: Any, kid: Any, x: Any, at: Any, applied: Any = _MI
445
354
 
446
355
  if not isinstance(kid, str) or revokes("hub-key", kid):
447
356
  return _refused("token", "kid-not-certified")
448
- holders = [m for m in record["machines"] if isinstance(m, dict) and isinstance(_get(m, "hubKey"), dict) and _same(_get(m["hubKey"], "kid"), kid)]
357
+ in_org = isinstance(context, str) and _ORG_CONTEXT.match(context) is not None
358
+ # A context that is neither personal nor an organization's names no key: a misconfigured callee fails closed.
359
+ if context is not _MISSING and context != "personal" and not in_org:
360
+ return _refused("token", "kid-not-certified")
361
+ # DEC-0128: an organization's token verifies only under that context's key, and only while the record pins it.
362
+ if in_org:
363
+ pins = _get(record, "organizations")
364
+ if not any(isinstance(pin, dict) and _same(_get(pin, "org"), context) for pin in (pins if isinstance(pins, list) else [])):
365
+ return _refused("token", "kid-not-certified")
366
+
367
+ def key_of(machine: Dict[str, Any]) -> Any:
368
+ if not in_org:
369
+ hub = _get(machine, "hubKey")
370
+ return hub if isinstance(hub, dict) else None
371
+ entries = _get(machine, "contextHubKeys")
372
+ entry = next((e for e in (entries if isinstance(entries, list) else [])
373
+ if isinstance(e, dict) and _same(_get(e, "context"), context) and isinstance(_get(e, "key"), dict)
374
+ and _same(_get(e["key"], "kid"), kid)), None)
375
+ return entry["key"] if entry is not None else None
376
+
377
+ holders = [m for m in record["machines"] if isinstance(m, dict) and key_of(m) is not None and _same(_get(key_of(m), "kid"), kid)]
449
378
  if len(holders) != 1: # two machines under one kid break FAC-SEM-073; such a revision certifies neither
450
379
  return _refused("token", "kid-not-certified")
451
380
  machine = holders[0]
452
- if not isinstance(x, str) or not _same(_get(machine["hubKey"], "x"), x): # a missing x never equals a missing x
381
+ if not isinstance(x, str) or not _same(_get(key_of(machine), "x"), x): # a missing x never equals a missing x
453
382
  return _refused("token", "kid-not-certified")
454
383
  if revokes("machine", _get(machine, "id")):
455
384
  return _refused("token", "kid-not-certified")
456
385
  return {"certified": True, "machine": _s(machine, "id"), "revision": record["revision"]}
457
-
458
-
459
- # --- FAC-SEM-072…076 ------------------------------------------------------------------------------------------------
460
-
461
- def _finding(findings: List[Dict[str, str]], code: str):
462
- def flag(path: str, message: str) -> None:
463
- findings.append({"code": code, "instancePath": path, "message": message})
464
- return flag
465
-
466
-
467
- def _revision_whole(value: Dict[str, Any], flag) -> None:
468
- try:
469
- digest = estate_record_digest(value)
470
- if not _same(digest, _get(value, "digest")):
471
- flag("/digest", "the digest is %s: the record changed after it was drafted, or was hashed over another form than RFC 8785" % digest)
472
- except TypeError:
473
- flag("/digest", "the record has no RFC 8785 form, so it has no digest")
474
- issued_at = estate_time(_get(value, "issuedAt"))
475
- valid_until = estate_time(_get(value, "validUntil"))
476
- if not math.isfinite(issued_at) or not math.isfinite(valid_until):
477
- flag("/validUntil" if math.isfinite(issued_at) else "/issuedAt", "issuedAt and validUntil are RFC 3339 date-times with an offset, on days that exist")
478
- elif valid_until <= issued_at or valid_until - issued_at > MAX_VALIDITY_SECONDS * 1000:
479
- flag("/validUntil", "an estate record is valid after it is issued and for at most 30 days")
480
-
481
-
482
- def _revision_chain(value: Dict[str, Any]) -> List[Dict[str, str]]:
483
- findings: List[Dict[str, str]] = []
484
- flag = _finding(findings, "FAC-SEM-072")
485
- parent = value["parent"] if isinstance(_get(value, "parent"), dict) else {}
486
- nxt = value["next"] if isinstance(_get(value, "next"), dict) else {}
487
- if not _same(_get(nxt, "estate"), _get(parent, "estate")):
488
- flag("/next/estate", "a revision continues its parent's estate")
489
- expected = _js_to_number(_get(parent, "revision")) + 1
490
- if not _same(_get(nxt, "revision"), expected):
491
- flag("/next/revision", "the revision after %s is %s: a revision never skips or repeats one" % (_show(_get(parent, "revision")), _js_string(expected)))
492
- try:
493
- parent_digest: Any = estate_record_digest(parent)
494
- except TypeError:
495
- parent_digest = _MISSING
496
- if not _same(_get(nxt, "parent"), parent_digest):
497
- flag("/next/parent", "parent names the digest of the revision before it")
498
- if _lt(estate_time(_get(nxt, "issuedAt")), estate_time(_get(parent, "issuedAt"))):
499
- flag("/next/issuedAt", "a revision is never issued before its parent")
500
- kept = {_revocation_key(e) for e in _list(_get(nxt, "revocations"))}
501
- for entry, index in _indexed(_get(parent, "revocations")):
502
- if _revocation_key(entry) not in kept:
503
- flag("/parent/revocations/%d" % index, "the %s revocation of %s is dropped: a revision keeps every revocation before it" % (_show(_get(entry, "kind")), _show(_get(entry, "ref"))))
504
- return findings
505
-
506
-
507
- #: Standard base64 with its padding, the one form DEC-0053 records a device key's octets in.
508
- _BASE64 = re.compile(r"(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\Z")
509
- #: DEC-0084: a key reference, a device-key kid, ``@sha256:`` and 64 lower-case hex digits.
510
- KEY_REFERENCE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}#(sig|kem)@sha256:[a-f0-9]{64}\Z")
511
-
512
-
513
- def device_key_fingerprint(key: Any) -> Optional[str]:
514
- """A device key's fingerprint (DEC-0084): ``sha256:`` and the hex SHA-256 of its octets, ``publicKey`` read as
515
- canonical padded base64; None for any other form, so two readers never disagree on which key it names."""
516
- text = key.get("publicKey") if isinstance(key, dict) else None
517
- if not isinstance(text, str) or not text or not _BASE64.match(text):
518
- return None
519
- raw = base64.b64decode(text)
520
- if base64.b64encode(raw).decode("ascii") != text:
521
- return None
522
- return "sha256:" + hashlib.sha256(raw).hexdigest()
523
-
524
-
525
- def device_key_ref(key: Any) -> Optional[str]:
526
- """The ``device-key`` revocation reference of one key (DEC-0084): ``<kid>@<fingerprint>``; None without a string kid
527
- or a fingerprint. A replacement revokes the old key by it and leaves the next key under the same kid usable."""
528
- fingerprint = device_key_fingerprint(key)
529
- kid = key.get("kid") if isinstance(key, dict) else None
530
- return "%s@%s" % (kid, fingerprint) if fingerprint is not None and isinstance(kid, str) else None
531
-
532
-
533
- def _valid_device_key(key: Dict[str, Any]) -> bool:
534
- public = _get(key, "publicKey")
535
- raw = _node_base64("" if public is _MISSING or public is None else _show(public))
536
- if _get(key, "alg") == "ed25519":
537
- return len(raw) == 32 and not is_small_order_key(raw)
538
- return is_valid_p256_key(raw)
539
-
540
-
541
- def _keys(value: Dict[str, Any], flag) -> None:
542
- ids: List[Any] = []
543
- hub_kids: List[Any] = []
544
- hub_keys: List[Any] = []
545
- for machine, m in _indexed(_get(value, "machines")):
546
- at = "/machines/%d" % m
547
- if _set_has(ids, _get(machine, "id")):
548
- flag("%s/id" % at, "machine %s is listed twice" % _show(_get(machine, "id")))
549
- ids.append(_get(machine, "id"))
550
- device_keys = _list(_get(machine, "deviceKeys"))
551
- if len([k for k in device_keys if _get(k, "use") == "sig"]) != 1:
552
- flag("%s/deviceKeys" % at, "a machine has exactly one signing device key, <id>#sig")
553
- if len([k for k in device_keys if _get(k, "use") == "kem"]) > 1:
554
- flag("%s/deviceKeys" % at, "a machine has at most one key-agreement device key, <id>#kem")
555
- seen: List[Any] = []
556
- for key, k in _indexed(_get(machine, "deviceKeys")):
557
- if not _same(_get(key, "kid"), "%s#%s" % (_show(_get(machine, "id")), _show(_get(key, "use")))):
558
- flag("%s/deviceKeys/%d/kid" % (at, k), "a device key is listed as <machine id>#sig or <machine id>#kem, by its role")
559
- # A key with no canonical fingerprint could be read as two keys by two readers, and no key reference names it (DEC-0084).
560
- if device_key_fingerprint(key) is None:
561
- flag("%s/deviceKeys/%d/publicKey" % (at, k), "a device key's octets are written in canonical padded base64, the one form a key reference can name (DEC-0084)")
562
- elif not _valid_device_key(key):
563
- flag("%s/deviceKeys/%d/publicKey" % (at, k), "an Ed25519 key is never of small order (DEC-0045) and a P-256 key is a valid point (SEC 1 §3.2.2)")
564
- if _set_has(seen, _get(key, "publicKey")):
565
- flag("%s/deviceKeys/%d/publicKey" % (at, k), "one key is never used for the other role")
566
- seen.append(_get(key, "publicKey"))
567
- hub = _get(machine, "hubKey")
568
- if isinstance(hub, dict):
569
- if _set_has(hub_kids, _get(hub, "kid")):
570
- flag("%s/hubKey/kid" % at, "hub key %s is certified for two machines: one kid per machine" % _show(_get(hub, "kid")))
571
- hub_kids.append(_get(hub, "kid"))
572
- if _set_has(hub_keys, _get(hub, "x")):
573
- flag("%s/hubKey/x" % at, "one hub public key is certified for two machines under two kids: one key per machine")
574
- hub_keys.append(_get(hub, "x"))
575
- x = _get(hub, "x")
576
- raw = _node_base64("" if x is _MISSING or x is None else _js_string(x))
577
- if is_small_order_key(raw):
578
- flag("%s/hubKey/x" % at, "a hub key of small order verifies forged tokens and is never certified (DEC-0045)")
579
- credentials: List[Any] = []
580
- for authority, index in _indexed(_get(value, "authorities")):
581
- if _set_has(credentials, _get(authority, "credentialId")):
582
- flag("/authorities/%d/credentialId" % index, "a passkey is listed once")
583
- credentials.append(_get(authority, "credentialId"))
584
- if "problem" in credential_key(_get(authority, "publicKey"), _show(_get(authority, "alg"))):
585
- flag("/authorities/%d/publicKey" % index, "a passkey's public key is a valid P-256 point for ES256 or an Ed25519 key of full order for EdDSA")
586
-
587
-
588
- def _placements(value: Dict[str, Any], flag) -> None:
589
- kids: List[Any] = []
590
- for key, index in _indexed(_get(value, "publisherKeys")):
591
- if _set_has(kids, _get(key, "kid")):
592
- flag("/publisherKeys/%d/kid" % index, "publisher key %s is pinned twice" % _show(_get(key, "kid")))
593
- kids.append(_get(key, "kid"))
594
- for machine, m in _indexed(_get(value, "machines")):
595
- agents: List[str] = []
596
- for placement, p in _indexed(_get(machine, "packages")):
597
- at = "/machines/%d/packages/%d" % (m, p)
598
- key = _key_of(_get(placement, "agentRef"), _context_of(placement))
599
- if key in agents:
600
- flag("%s/agentRef" % at, "%s is placed twice on one machine in the context %s"
601
- % (_show(_get(placement, "agentRef")), _show(_context_of(placement))))
602
- agents.append(key)
603
- if not _set_has(kids, _get(placement, "publisherKid")):
604
- flag("%s/publisherKid" % at, "publisher key %s is not in publisherKeys, so the machine pins no key to verify the package under" % _show(_get(placement, "publisherKid")))
605
-
606
-
607
- def _partner_scopes(value: Dict[str, Any], flag) -> None:
608
- """FAC-SEM-122 on one record (DEC-0090): a partner's publisher key is scoped once, to a key the record pins, and a
609
- placement under it is in one of the organization contexts its scope names, never ``personal`` or another."""
610
- pinned = [_get(k, "kid") for k in _list(_get(value, "publisherKeys"))]
611
- scoped: List[Any] = []
612
- for scope, index in _indexed(_get(value, "publisherScopes")):
613
- if _set_has(scoped, _get(scope, "kid")):
614
- flag("/publisherScopes/%d/kid" % index, "publisher key %s is scoped twice" % _show(_get(scope, "kid")))
615
- scoped.append(_get(scope, "kid"))
616
- if not _set_has(pinned, _get(scope, "kid")):
617
- flag("/publisherScopes/%d/kid" % index, "publisher key %s is not in publisherKeys, so the scope limits no key" % _show(_get(scope, "kid")))
618
- for machine, m in _indexed(_get(value, "machines")):
619
- for placement, p in _indexed(_get(machine, "packages")):
620
- if not _scope_admits(value, _get(placement, "publisherKid"), _context_of(placement)):
621
- flag("/machines/%d/packages/%d/publisherKid" % (m, p),
622
- "publisher key %s is a partner's, trusted only in the contexts its scope names, not in %s"
623
- % (_show(_get(placement, "publisherKid")), _show(_context_of(placement))))
624
-
625
-
626
- def _secret_references(value: Dict[str, Any], flag) -> None:
627
- machine_ids = [_get(m, "id") for m in _list(_get(value, "machines"))]
628
- for machine, m in _indexed(_get(value, "machines")):
629
- placed = [_key_of(_get(p, "agentRef"), _context_of(p)) for p in _list(_get(machine, "packages"))]
630
- slots: List[str] = []
631
- for secret, s in _indexed(_get(machine, "secrets")):
632
- at = "/machines/%d/secrets/%d" % (m, s)
633
- # The message never repeats the value, and no message of this rule repeats a secret placement's context.
634
- for field in ("slot", "from", "agentRef", "context"):
635
- text = _get(secret, field)
636
- if isinstance(text, str) and _secrets.secret_value(text):
637
- flag("%s/%s" % (at, field), "a secret placement names a slot, never a value: this looks like a credential")
638
- if _key_of(_get(secret, "agentRef"), _context_of(secret)) not in placed:
639
- flag("%s/agentRef" % at, "a secret goes only where a placed package declares it (v4 Path 2): this agent is not placed on this machine in the secret's context")
640
- if not _set_has(machine_ids, _get(secret, "from")):
641
- flag("%s/from" % at, "the slot is filled from a machine of this estate")
642
- elif not _same(_context_of(secret), PERSONAL_CONTEXT) and not _same(_get(secret, "from"), _get(machine, "id")):
643
- # An envelope names no context (DEC-0082): an organization's instance fills its slot on its own machine.
644
- flag("%s/from" % at, "an organization's instance fills its slot from its own machine's vault, never by a secret envelope from another machine")
645
- key = _key_of(_get(secret, "agentRef"), _get(secret, "slot"), _context_of(secret))
646
- if key in slots:
647
- flag("%s/slot" % at, "slot %s is placed twice for one agent in one context on one machine" % _show(_get(secret, "slot")))
648
- slots.append(key)
649
-
650
-
651
- #: DEC-0106: what a machine of the estate may be. Absent is `full`, and a writer omits `full`.
652
- MACHINE_PROFILES = ("full", "member", "view")
653
-
654
-
655
- def _is_profile(value: Any) -> bool:
656
- return isinstance(value, str) and value in MACHINE_PROFILES
657
-
658
-
659
- def machine_profile(record: Any, machine_id: Any) -> Optional[str]:
660
- """A machine's profile in a record (DEC-0106): ``full`` when the machine is listed once with no ``profile``, its value
661
- when that is one of the three, None otherwise (not listed, listed twice, an unreadable record or id, an unknown
662
- value). The contract's ``machineProfile``; never raises."""
663
- if not isinstance(record, dict) or not isinstance(machine_id, str):
664
- return None
665
- found = [m for m in _list(_get(record, "machines")) if _same(_get(m, "id"), machine_id)]
666
- if len(found) != 1:
667
- return None
668
- profile = _get(found[0], "profile")
669
- if profile is _MISSING:
670
- return "full"
671
- return profile if _is_profile(profile) else None
672
-
673
-
674
- def _machine_profiles(value: Dict[str, Any], flag) -> None:
675
- """FAC-SEM-134 (DEC-0106): a profile, when present, is `full`, `member` or `view`; only a `full` machine carries a
676
- `hubKey`; a `view` machine has no placement and no secret placement, and no secret placement names it as `from`."""
677
- views: List[Any] = []
678
- for machine, m in _indexed(_get(value, "machines")):
679
- profile = _get(machine, "profile")
680
- if profile is _MISSING:
681
- continue
682
- if not _is_profile(profile):
683
- flag("/machines/%d/profile" % m, "profile %s is none of full, member, view" % _show(profile))
684
- continue
685
- if profile != "full" and _get(machine, "hubKey") is not _MISSING:
686
- flag("/machines/%d/hubKey" % m, "a %s machine is never the hub's: only a full machine carries a hub key" % profile)
687
- if profile == "view":
688
- views.append(_get(machine, "id"))
689
- if _list(_get(machine, "packages")):
690
- flag("/machines/%d/packages" % m, "a view machine runs no agent: it has no placement")
691
- if _list(_get(machine, "secrets")):
692
- flag("/machines/%d/secrets" % m, "a view machine holds none of the estate's secrets")
693
- for machine, m in _indexed(_get(value, "machines")):
694
- for secret, s in _indexed(_get(machine, "secrets")):
695
- if _set_has(views, _get(secret, "from")):
696
- flag("/machines/%d/secrets/%d/from" % (m, s), "machine %s is a view machine: it is the source of no secret" % _show(_get(secret, "from")))
697
-
698
-
699
- def _grant_key(ident: Any) -> Any:
700
- """A grant's id is a URI, so grants and their revocations are keyed by its normal form (DEC-0076)."""
701
- import fabric_identifiers # noqa: E402 - beside this file
702
- return fabric_identifiers.normal_identifier(ident) if isinstance(ident, str) else ident
703
-
704
-
705
- def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
706
- grant_ids: List[Any] = []
707
- for grant, index in _indexed(_get(value, "shareGrants")):
708
- if _get(grant, "principalKind") != "external":
709
- flag("/shareGrants/%d/principalKind" % index, "a share grant is to a principal outside the estate: principalKind external")
710
- ident = _get(_get(grant, "meta"), "id") if isinstance(_get(grant, "meta"), dict) else _MISSING
711
- if _set_has(grant_ids, _grant_key(ident)):
712
- flag("/shareGrants/%d/meta/id" % index, "grant %s is listed twice" % _show(ident))
713
- grant_ids.append(_grant_key(ident))
714
- machines = _list(_get(value, "machines"))
715
- listed = {
716
- "authority": [_get(e, "credentialId") for e in _list(_get(value, "authorities"))],
717
- "machine": [_get(m, "id") for m in machines],
718
- # A kid, and each listed key's own reference (DEC-0084): revoking either while listing it is refused.
719
- "device-key": [ref for m in machines for k in _list(_get(m, "deviceKeys"))
720
- for ref in ([_get(k, "kid")] if _get(k, "kid") is not _MISSING else []) + ([device_key_ref(k)] if device_key_ref(k) is not None else [])],
721
- "hub-key": [_get(m["hubKey"], "kid") for m in machines if isinstance(_get(m, "hubKey"), dict)],
722
- "publisher-key": [_get(k, "kid") for k in _list(_get(value, "publisherKeys"))],
723
- "package": [_get(p, "digest") for m in machines for p in _list(_get(m, "packages"))],
724
- "grant": grant_ids,
725
- }
726
- seen: List[str] = []
727
- for revocation, index in _indexed(_get(value, "revocations")):
728
- ref = _grant_key(_get(revocation, "ref")) if _get(revocation, "kind") == "grant" else _get(revocation, "ref")
729
- kind = _show(_get(revocation, "kind"))
730
- key = "%s %s" % (kind, _show(ref))
731
- if key in seen:
732
- flag("/revocations/%d" % index, "the %s %s is revoked twice" % (kind, _show(_get(revocation, "ref"))))
733
- raw_ref = _get(revocation, "ref")
734
- if _get(revocation, "kind") == "device-key" and isinstance(raw_ref, str) and "@" in raw_ref and not KEY_REFERENCE.match(raw_ref):
735
- flag("/revocations/%d/ref" % index, "a device-key revocation names a kid or a key reference <kid>@sha256:<64 lower-case hex digits> (DEC-0084)")
736
- seen.append(key)
737
- if _set_has(listed.get(kind, []), ref):
738
- flag("/revocations/%d/ref" % index, "the record still lists the %s it revokes: revocation outranks addition" % kind)
739
-
740
-
741
- def grant_context_findings(grant: Any, at: str) -> List[Dict[str, str]]:
742
- """FAC-SEM-110 (DEC-0082): a grant to a principal outside the estate (``principalKind: external``, a share grant) is
743
- used in that principal's organization, so its ``context`` is never ``personal``; absent means not stated."""
744
- if not isinstance(grant, dict) or _get(grant, "principalKind") != "external" or _get(grant, "context") != PERSONAL_CONTEXT:
745
- return []
746
- return [{"code": "FAC-SEM-110", "instancePath": "%s/context" % at,
747
- "message": "a grant to a principal outside the estate is used in that principal's organization, never in the personal context: "
748
- "name the organization's org:<orgId>, or leave context out"}]
749
-
750
-
751
- def _placement_grant_problems(record: Dict[str, Any], placement: Dict[str, Any]) -> List[Tuple[str, str]]:
752
- """FAC-SEM-111 (DEC-0082) for one placement: an instance in an organization's context names, in ``grant``, a share
753
- grant of the same record (matched by the normal form of its id, DEC-0076) whose ``context`` is the placement's, as
754
- written, and that the record does not revoke; a personal instance names none. Any other context needs a grant, and
755
- only an ``org:<orgId>`` context can match one, so a malformed context fails closed."""
756
- import fabric_identifiers # noqa: E402 - beside this file
757
- context = _context_of(placement)
758
- grant = _get(placement, "grant")
759
- if _same(context, PERSONAL_CONTEXT):
760
- return [] if grant is _MISSING else [("/grant", "a personal instance runs under no share grant: only a placement in an organization's context names one")]
761
- if not isinstance(grant, str):
762
- return [("/grant", "an instance placed in the context %s names the share grant it runs under, by its id" % _show(context))]
763
- ident = fabric_identifiers.normal_identifier(grant)
764
- grants = [g for g in _list(_get(record, "shareGrants")) if isinstance(_get(g, "meta"), dict)
765
- and isinstance(_get(g["meta"], "id"), str) and fabric_identifiers.normal_identifier(g["meta"]["id"]) == ident]
766
- problems: List[Tuple[str, str]] = []
767
- if not grants:
768
- problems.append(("/grant", "the record has no share grant %s" % grant))
769
- elif (parse_context(context) or {}).get("kind") != "org":
770
- problems.append(("/context", "%s is not an organization's context id (org:<orgId>), so no share grant matches it" % _show(context)))
771
- elif not any(_same(_get(g, "context"), context) for g in grants):
772
- problems.append(("/grant", "the share grant %s is not for the context %s: its context must be the placement's, as written" % (grant, _show(context))))
773
- if any(_get(e, "kind") == "grant" and isinstance(_get(e, "ref"), str) and fabric_identifiers.normal_identifier(e["ref"]) == ident
774
- for e in _list(_get(record, "revocations"))):
775
- problems.append(("/grant", "the record revokes the share grant %s, so no instance runs under it" % grant))
776
- return problems
777
-
778
-
779
- def placement_grant_findings(record: Any, placement: Any) -> List[Dict[str, str]]:
780
- """FAC-SEM-111 for one placement of ``record``, as findings (``instancePath`` relative to the placement). Answers,
781
- never raises: a record or placement that is no object reads as an empty one (FAA-125)."""
782
- record, placement = (v if isinstance(v, dict) else {} for v in (record, placement))
783
- return [{"code": "FAC-SEM-111", "instancePath": at, "message": m} for at, m in _placement_grant_problems(record, placement)]
784
-
785
-
786
- def _placement_grants(value: Dict[str, Any], flag) -> None:
787
- for machine, m in _indexed(_get(value, "machines")):
788
- for placement, p in _indexed(_get(machine, "packages")):
789
- for suffix, message in _placement_grant_problems(value, placement):
790
- flag("/machines/%d/packages/%d%s" % (m, p, suffix), message)
791
-
792
-
793
- _ANCHOR_HASH = re.compile(r"[a-f0-9]{64}")
794
-
795
-
796
- def delta_anchor_of(record: Any) -> Optional[Dict[str, Any]]:
797
- """The anchor a record vouches for (DEC-0107), or None when it names none or its shape is not the schema's. The
798
- contract's ``deltaAnchorOf``."""
799
- if not isinstance(record, dict) or not isinstance(_get(record, "deltaAnchor"), dict):
800
- return None
801
- sequence, sha = _get(record["deltaAnchor"], "sequence"), _get(record["deltaAnchor"], "sha256")
802
- if _safe_integer(sequence) and sequence >= 1 and isinstance(sha, str) and _ANCHOR_HASH.fullmatch(sha):
803
- return {"sequence": sequence, "sha256": sha}
804
- return None
805
-
806
-
807
- def _anchor_findings(parent: Dict[str, Any], nxt: Dict[str, Any]) -> List[Dict[str, str]]:
808
- """FAC-SEM-137 across two revisions (DEC-0107): the delta anchor never moves back. A revision whose parent anchors
809
- the delta anchors it too, at the parent's sequence or later, with the parent's hash at the same sequence."""
810
- findings: List[Dict[str, str]] = []
811
- flag = _finding(findings, "FAC-SEM-137")
812
- before = delta_anchor_of(parent)
813
- if before is None:
814
- return findings
815
- after = delta_anchor_of(nxt)
816
- if after is None:
817
- flag("/next/deltaAnchor", "the parent anchors the delta at %s: a revision keeps an anchor once one is set" % _js_string(before["sequence"]))
818
- elif after["sequence"] < before["sequence"]:
819
- flag("/next/deltaAnchor/sequence", "the anchor moves back from %s to %s" % (_js_string(before["sequence"]), _js_string(after["sequence"])))
820
- elif after["sequence"] == before["sequence"] and after["sha256"] != before["sha256"]:
821
- flag("/next/deltaAnchor/sha256", "another hash at sequence %s: two delta histories" % _js_string(after["sequence"]))
822
- return findings
823
-
824
-
825
- def _dropped_findings(parent: Dict[str, Any], nxt: Dict[str, Any]) -> List[Dict[str, str]]:
826
- """FAC-SEM-114 across two revisions (DEC-0084): what a revision drops, it revokes in the same revision. A removed
827
- machine as ``machine <id>`` and its hub key as ``hub-key <kid>``; a replaced or dropped device key of a kept machine
828
- as ``device-key`` by its reference or its kid; a hub key no longer certified as it was as ``hub-key <old kid>``."""
829
- findings: List[Dict[str, str]] = []
830
- flag = _finding(findings, "FAC-SEM-114")
831
- revocations = _list(_get(nxt, "revocations"))
832
-
833
- def revoked(kind: str, *refs: Any) -> bool:
834
- return any(_get(entry, "kind") == kind and any(ref is not _MISSING and _same(_get(entry, "ref"), ref) for ref in refs)
835
- for entry in revocations)
836
-
837
- kept_machines: List[Tuple[Any, Dict[str, Any]]] = []
838
- for machine in _list(_get(nxt, "machines")):
839
- if not any(_same(ident, _get(machine, "id")) for ident, _ in kept_machines):
840
- kept_machines.append((_get(machine, "id"), machine))
841
- for machine, m in _indexed(_get(parent, "machines")):
842
- at = "/parent/machines/%d" % m
843
- hub = _get(machine, "hubKey") if isinstance(_get(machine, "hubKey"), dict) else None
844
- kept = next((found for ident, found in kept_machines if _same(ident, _get(machine, "id"))), None)
845
- if kept is None:
846
- if not revoked("machine", _get(machine, "id")):
847
- flag("%s/id" % at, "machine %s is removed without its revocation: a revision that drops a machine lists machine %s in revocations" % (_show(_get(machine, "id")), _show(_get(machine, "id"))))
848
- if hub is not None and not revoked("hub-key", _get(hub, "kid")):
849
- flag("%s/hubKey/kid" % at, "the hub key %s of a removed machine is revoked in the same revision" % _show(_get(hub, "kid")))
850
- continue
851
- listed = {ref for ref in (device_key_ref(key) for key in _list(_get(kept, "deviceKeys"))) if ref is not None}
852
- for key, k in _indexed(_get(machine, "deviceKeys")):
853
- ref = device_key_ref(key)
854
- if ref is None or ref in listed:
855
- continue
856
- if not revoked("device-key", ref, _get(key, "kid")):
857
- flag("%s/deviceKeys/%d" % (at, k), "the device key %s is replaced or dropped without its revocation: the same revision lists device-key %s" % (_show(_get(key, "kid")), ref))
858
- next_hub = _get(kept, "hubKey") if isinstance(_get(kept, "hubKey"), dict) else None
859
- same_hub = next_hub is not None and _same(_get(next_hub, "kid"), _get(hub, "kid") if hub else _MISSING) and _same(_get(next_hub, "x"), _get(hub, "x") if hub else _MISSING)
860
- if hub is not None and not same_hub and not revoked("hub-key", _get(hub, "kid")):
861
- flag("%s/hubKey/kid" % at, "the hub key %s is no longer certified as it was: the same revision revokes hub-key %s, and a re-certified hub key takes a new kid" % (_show(_get(hub, "kid")), _show(_get(hub, "kid"))))
862
- return findings
863
-
864
-
865
- def _record_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
866
- findings: List[Dict[str, str]] = []
867
- _revision_whole(value, _finding(findings, "FAC-SEM-072"))
868
- _keys(value, _finding(findings, "FAC-SEM-073"))
869
- _placements(value, _finding(findings, "FAC-SEM-074"))
870
- _partner_scopes(value, _finding(findings, "FAC-SEM-122"))
871
- _secret_references(value, _finding(findings, "FAC-SEM-075"))
872
- _grants_and_revocations(value, _finding(findings, "FAC-SEM-076"))
873
- for grant, index in _indexed(_get(value, "shareGrants")): # FAC-SEM-110 (DEC-0082)
874
- findings.extend(grant_context_findings(grant, "/shareGrants/%d" % index))
875
- _placement_grants(value, _finding(findings, "FAC-SEM-111"))
876
- _machine_profiles(value, _finding(findings, "FAC-SEM-134"))
877
- for authority, index in _indexed(_get(value, "authorities")): # FAC-SEM-092 (DEC-0063)
878
- _signing_credential(authority, "/authorities/%d" % index, _finding(findings, "FAC-SEM-092"))
879
- return findings
880
-
881
-
882
- def _package_digest(pkg: Dict[str, Any]) -> str:
883
- covered = {k: v for k, v in pkg.items() if k not in ("digest", "signatures")}
884
- return "sha256:" + hashlib.sha256(jcs(covered).encode("utf-8")).hexdigest()
885
-
886
-
887
- def _installation(value: Dict[str, Any]) -> List[Dict[str, str]]:
888
- findings: List[Dict[str, str]] = []
889
- record = value["record"] if isinstance(_get(value, "record"), dict) else {}
890
- pkg = value["package"] if isinstance(_get(value, "package"), dict) else {}
891
- machines = _list(_get(record, "machines"))
892
- machine_index = next((i for m, i in _indexed(_get(record, "machines")) if _same(_get(m, "id"), _get(value, "machine"))), -1)
893
- flag074 = _finding(findings, "FAC-SEM-074")
894
- if machine_index < 0:
895
- flag074("/machine", "machine %s is not in the estate record" % _show(_get(value, "machine")))
896
- return findings
897
- machine = record["machines"][machine_index]
898
- try:
899
- digest: Any = _package_digest(pkg)
900
- except TypeError:
901
- digest = _MISSING
902
- if digest is _MISSING or not _same(digest, _get(pkg, "digest")):
903
- flag074("/package/digest", "the package is not the one its digest names: it changed after it was packed")
904
- context = _context_of(value)
905
- placement, placement_index = next(((p, i) for p, i in _indexed(_get(machine, "packages"))
906
- if _same(_get(p, "agentRef"), _get(pkg, "agentRef")) and _same(_context_of(p), context)), (None, -1))
907
- if placement is None:
908
- flag074("/package/agentRef", "the record places no %s on machine %s in the context %s"
909
- % (_show(_get(pkg, "agentRef")), _show(_get(value, "machine")), _show(context)))
910
- elif not _same(_get(placement, "digest"), digest):
911
- flag074("/package", "the record places %s of this agent on this machine in this context, not this package" % _show(_get(placement, "digest")))
912
- declared = [_get(slot, "name") for slot in _list(_get(pkg, "secrets"))]
913
- flag075 = _finding(findings, "FAC-SEM-075")
914
- for secret, index in _indexed(_get(machine, "secrets")):
915
- if _same(_get(secret, "agentRef"), _get(pkg, "agentRef")) and _same(_context_of(secret), context) and not _set_has(declared, _get(secret, "slot")):
916
- flag075("/record/machines/%d/secrets/%d/slot" % (machine_index, index),
917
- "the package declares no slot %s: a secret goes only where a placed package declares it" % _show(_get(secret, "slot")))
918
- if placement is not None and not _scope_admits(record, _get(placement, "publisherKid"), context):
919
- _finding(findings, "FAC-SEM-122")("/record/machines/%d/packages/%d/publisherKid" % (machine_index, placement_index),
920
- "publisher key %s is a partner's, not trusted in %s" % (_show(_get(placement, "publisherKid")), _show(context)))
921
- if placement is not None:
922
- flag111 = _finding(findings, "FAC-SEM-111")
923
- for suffix, message in _placement_grant_problems(record, placement):
924
- flag111("/record/machines/%d/packages/%d%s" % (machine_index, placement_index, suffix), message)
925
- return findings
926
-
927
-
928
- def estate_record_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
929
- """FAC-SEM-072…076: findings for an `estate-record`, an `estate-record-revision` `{parent, next}` or an
930
- `estate-placement` `{record, machine, package}`; None for another kind."""
931
- if not isinstance(value, dict):
932
- value = {}
933
- if kind == "estate-record":
934
- return _record_findings(value)
935
- if kind == "estate-record-revision":
936
- parent = value["parent"] if isinstance(_get(value, "parent"), dict) else {}
937
- nxt = value["next"] if isinstance(_get(value, "next"), dict) else {}
938
- return _revision_chain(value) + _dropped_findings(parent, nxt) + _anchor_findings(parent, nxt)
939
- if kind == "estate-placement":
940
- return _installation(value)
941
- return None