@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.
- package/CHANGELOG.md +16 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +22 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +44 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +46 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +7 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +30 -15
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +5 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +17 -9
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate-rules.mjs +606 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +48 -525
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +43 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +48 -15
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +4 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +39 -16
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +8 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +86 -642
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate_rules.py +766 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +70 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +59 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +56 -16
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +220 -21
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +69 -34
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +27 -15
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +13 -7
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +8 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -9
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +60 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +10 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +39 -4
- 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=...)
|
|
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)
|
|
13
|
-
`estate-placement`, with FAC-SEM-134 on each machine's profile (DEC-0106)
|
|
14
|
-
|
|
15
|
-
- ``
|
|
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)
|
|
18
|
-
`kid` only while a current revision certifies it for a machine
|
|
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
|
|
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
|
-
|
|
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,
|
|
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).
|
|
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
|
-
|
|
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
|
|
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
|