@passioncode-ai/fabric-agent-adapter 0.35.4 → 0.36.0
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 +34 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +45 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +37 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +67 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +252 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +109 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +194 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +82 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +356 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +10 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/share-registration.schema.json +218 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The estate ceremonies' headless join (contract DEC-0084), the Python kit; the Node twin is fabric-ceremonies.mjs.
|
|
3
|
+
Standard library only.
|
|
4
|
+
|
|
5
|
+
An ``enroll-token/1`` lets a machine with no screen join an estate: Fabric issues it under the person's signing passkey
|
|
6
|
+
for at most 15 minutes and one use, the machine reads it from stdin or a 0600 file (never argv, a URL or a log) and
|
|
7
|
+
presents it with its ``#sig`` key, and the hub binds its nonce to that key on the first use. The token gives the machine
|
|
8
|
+
no trust; only a passkey-signed revision that lists it does. Its ``genesis`` authenticates the estate to the machine.
|
|
9
|
+
|
|
10
|
+
- ``verify_enroll_token(token, record=, genesis=, at=)``: the reference verifier, the hub's and the node's.
|
|
11
|
+
- ``enroll_join_input(token)``: the ``enroll-join/1`` statement the joining machine signs with its ``#sig`` key.
|
|
12
|
+
- ``enroll_token_rules("enroll-token", token)``: FAC-SEM-113.
|
|
13
|
+
|
|
14
|
+
Every function answers and never raises on malformed input. Normative source: the contract's src/enroll-token.ts,
|
|
15
|
+
src/signing-challenges.ts and docs/specification/ceremonies.md.
|
|
16
|
+
"""
|
|
17
|
+
# #region ceremonies-kit, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md#the-estate-ceremonies
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
28
|
+
import fabric_delegation as _delegation # noqa: E402 - beside this file: RFC 8785
|
|
29
|
+
import fabric_estate as _estate # noqa: E402 - beside this file: passkey assertions, times, show
|
|
30
|
+
|
|
31
|
+
ENROLL_TOKEN_SCHEMA = "enroll-token/1"
|
|
32
|
+
ENROLL_TOKEN_TYPE = "enroll-token/1"
|
|
33
|
+
ENROLL_JOIN_TYPE = "enroll-join/1"
|
|
34
|
+
ENROLL_PURPOSE = "agent-host"
|
|
35
|
+
MAX_ENROLL_TOKEN_SECONDS = 900
|
|
36
|
+
#: The register's fields of enroll-token/1 beside ``type`` (DEC-0084), one list for good.
|
|
37
|
+
ENROLL_TOKEN_FIELDS = ("scope", "genesis", "purpose", "ceiling", "notBefore", "expiresAt", "nonce", "uses")
|
|
38
|
+
_SHA256 = re.compile(r"sha256:[a-f0-9]{64}\Z")
|
|
39
|
+
_ESTATE_SCOPE = re.compile(r"estate:[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}\Z")
|
|
40
|
+
#: 32 random bytes as unpadded base64url.
|
|
41
|
+
_NONCE32 = re.compile(r"[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]\Z")
|
|
42
|
+
_ASSERTION_MEMBERS = "alg,authenticatorData,clientDataJSON,credentialId,format,sig"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _matches(pattern: Any, value: Any) -> bool:
|
|
46
|
+
return isinstance(value, str) and pattern.match(value) is not None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_one(value: Any) -> bool:
|
|
50
|
+
"""JavaScript's ``value === 1`` on parsed JSON: true is not 1."""
|
|
51
|
+
return not isinstance(value, bool) and isinstance(value, (int, float)) and value == 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _finite(value: float) -> bool:
|
|
55
|
+
return value == value and value not in (float("inf"), float("-inf"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _register_ok(kind: str, fields: Dict[str, Any]) -> bool:
|
|
59
|
+
if kind == ENROLL_TOKEN_TYPE:
|
|
60
|
+
return (_matches(_ESTATE_SCOPE, fields["scope"]) and _matches(_SHA256, fields["genesis"]) and fields["purpose"] == ENROLL_PURPOSE
|
|
61
|
+
and (fields["ceiling"] is None or _matches(_SHA256, fields["ceiling"])) and _finite(_estate.estate_time(fields["notBefore"]))
|
|
62
|
+
and _finite(_estate.estate_time(fields["expiresAt"])) and _matches(_NONCE32, fields["nonce"]) and _is_one(fields["uses"]))
|
|
63
|
+
return _matches(_SHA256, fields["digest"]) and _matches(_NONCE32, fields["nonce"])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def signing_statement(kind: str, fields: Any) -> Optional[bytes]:
|
|
67
|
+
"""The bytes of a registered statement (DEC-0063): the UTF-8 RFC 8785 form of ``{type, ...fields}``, for the two types
|
|
68
|
+
this kit uses (``enroll-token/1``, ``enroll-join/1``); None when the fields are not exactly the type's or a value is
|
|
69
|
+
outside what the register admits."""
|
|
70
|
+
names = {ENROLL_TOKEN_TYPE: ENROLL_TOKEN_FIELDS, ENROLL_JOIN_TYPE: ("digest", "nonce")}.get(kind)
|
|
71
|
+
if names is None or not isinstance(fields, dict) or len(fields) != len(names) or any(name not in fields for name in names):
|
|
72
|
+
return None
|
|
73
|
+
if not _register_ok(kind, fields):
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
return _delegation.jcs(dict(fields, type=kind)).encode("utf-8")
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _statement_fields(token: Any) -> Optional[Dict[str, Any]]:
|
|
82
|
+
if not isinstance(token, dict) or token.get("schema") != ENROLL_TOKEN_SCHEMA or not isinstance(token.get("statement"), dict):
|
|
83
|
+
return None
|
|
84
|
+
if token["statement"].get("type") != ENROLL_TOKEN_TYPE:
|
|
85
|
+
return None
|
|
86
|
+
# The token holds exactly the schema's members: nothing unsigned rides beside the statement (DEC-0084).
|
|
87
|
+
if sorted(token) != ["schema", "signature", "statement"]:
|
|
88
|
+
return None
|
|
89
|
+
fields = {name: value for name, value in token["statement"].items() if name != "type"}
|
|
90
|
+
return fields if signing_statement(ENROLL_TOKEN_TYPE, fields) is not None else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def verify_enroll_token(token: Any, *, record: Any, genesis: Any, at: Any) -> Dict[str, Any]:
|
|
94
|
+
"""The reference verifier of an enroll-token/1, for the hub that admits a join and the node that pins the estate.
|
|
95
|
+
``record`` is the current estate revision the verifier applied, ``genesis`` the digest of its revision 1, ``at`` the
|
|
96
|
+
clock (an aware datetime or epoch seconds). The first failure answers: no-clock, malformed, wrong-estate, the passkey
|
|
97
|
+
(no-trusted-authority, revoked-authority, duplicate-authority, unknown-authority), the assertion, then the window
|
|
98
|
+
(too-long-lived, not-yet-valid, expired). Success: ``{"ok": True, "nonce", "expiresAt", "signedBy"}``."""
|
|
99
|
+
now = _estate._clock(at)
|
|
100
|
+
if not _finite(now):
|
|
101
|
+
return {"ok": False, "problem": "no-clock"}
|
|
102
|
+
fields = _statement_fields(token)
|
|
103
|
+
if fields is None or not isinstance(record, dict) or not isinstance(record.get("estate"), str):
|
|
104
|
+
return {"ok": False, "problem": "malformed"}
|
|
105
|
+
signature = token.get("signature")
|
|
106
|
+
if (not isinstance(signature, dict) or ",".join(sorted(signature)) != _ASSERTION_MEMBERS
|
|
107
|
+
or signature.get("format") != "webauthn-assertion" or not isinstance(signature.get("credentialId"), str)):
|
|
108
|
+
return {"ok": False, "problem": "malformed"}
|
|
109
|
+
if fields["scope"] != "estate:%s" % record["estate"] or not isinstance(genesis, str) or fields["genesis"] != genesis:
|
|
110
|
+
return {"ok": False, "problem": "wrong-estate"}
|
|
111
|
+
authorities = _estate._list(record.get("authorities"))
|
|
112
|
+
if not authorities:
|
|
113
|
+
return {"ok": False, "problem": "no-trusted-authority"}
|
|
114
|
+
credential_id = signature["credentialId"]
|
|
115
|
+
if any(entry.get("kind") == "authority" and _estate._same(entry.get("ref", _estate._MISSING), credential_id) for entry in _estate._list(record.get("revocations"))):
|
|
116
|
+
return {"ok": False, "problem": "revoked-authority"}
|
|
117
|
+
matches = [entry for entry in authorities if _estate._same(entry.get("credentialId", _estate._MISSING), credential_id)]
|
|
118
|
+
if len(matches) > 1:
|
|
119
|
+
return {"ok": False, "problem": "duplicate-authority"}
|
|
120
|
+
if not matches:
|
|
121
|
+
return {"ok": False, "problem": "unknown-authority"}
|
|
122
|
+
authority = matches[0]
|
|
123
|
+
origins = authority.get("origins")
|
|
124
|
+
rp_id = _estate._show(authority.get("rpId", _estate._MISSING))
|
|
125
|
+
if not _estate.is_signing_rp_id(rp_id):
|
|
126
|
+
return {"ok": False, "problem": "wrong-rp"}
|
|
127
|
+
credential = {"credentialId": credential_id, "alg": _estate._show(authority.get("alg", _estate._MISSING)),
|
|
128
|
+
"publicKey": authority["publicKey"] if isinstance(authority.get("publicKey"), dict) else {}, "rpId": rp_id,
|
|
129
|
+
"origins": [o for o in origins if isinstance(o, str) and _estate.origin_within(o, rp_id)] if isinstance(origins, list) else []}
|
|
130
|
+
challenge = hashlib.sha256(signing_statement(ENROLL_TOKEN_TYPE, fields)).digest()
|
|
131
|
+
assertion = _estate.verify_assertion(signature, credential, challenge)
|
|
132
|
+
if not assertion["ok"]:
|
|
133
|
+
return {"ok": False, "problem": assertion["problem"]}
|
|
134
|
+
not_before = _estate.estate_time(fields["notBefore"])
|
|
135
|
+
expires_at = _estate.estate_time(fields["expiresAt"])
|
|
136
|
+
if expires_at <= not_before or expires_at - not_before > MAX_ENROLL_TOKEN_SECONDS * 1000:
|
|
137
|
+
return {"ok": False, "problem": "too-long-lived"}
|
|
138
|
+
if now < not_before - _estate.CLOCK_SKEW_SECONDS * 1000:
|
|
139
|
+
return {"ok": False, "problem": "not-yet-valid"}
|
|
140
|
+
if now > expires_at:
|
|
141
|
+
return {"ok": False, "problem": "expired"}
|
|
142
|
+
return {"ok": True, "nonce": fields["nonce"], "expiresAt": fields["expiresAt"], "signedBy": credential_id}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def enroll_join_input(token: Any) -> Optional[str]:
|
|
146
|
+
"""What the joining machine signs with the #sig key it presents (DEC-0084): the enroll-join/1 statement
|
|
147
|
+
``{type, digest, nonce}``, ``digest`` the SHA-256 of the token's statement bytes; None without a register statement."""
|
|
148
|
+
fields = _statement_fields(token)
|
|
149
|
+
statement = signing_statement(ENROLL_TOKEN_TYPE, fields) if fields is not None else None
|
|
150
|
+
if statement is None:
|
|
151
|
+
return None
|
|
152
|
+
joined = signing_statement(ENROLL_JOIN_TYPE, {"digest": "sha256:" + hashlib.sha256(statement).hexdigest(), "nonce": fields["nonce"]})
|
|
153
|
+
return joined.decode("utf-8") if joined is not None else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _token_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
157
|
+
findings: List[Dict[str, str]] = []
|
|
158
|
+
|
|
159
|
+
def flag(at: str, message: str) -> None:
|
|
160
|
+
findings.append({"code": "FAC-SEM-113", "instancePath": at, "message": message})
|
|
161
|
+
|
|
162
|
+
statement = value.get("statement") if isinstance(value.get("statement"), dict) else None
|
|
163
|
+
if statement is None:
|
|
164
|
+
flag("/statement", "an enroll token carries its signed statement")
|
|
165
|
+
return findings
|
|
166
|
+
if statement.get("type") != ENROLL_TOKEN_TYPE:
|
|
167
|
+
flag("/statement/type", "the statement's type is enroll-token/1, the register's (DEC-0063)")
|
|
168
|
+
extra = [name for name in statement if name != "type" and name not in ENROLL_TOKEN_FIELDS]
|
|
169
|
+
missing = [name for name in ENROLL_TOKEN_FIELDS if name not in statement]
|
|
170
|
+
if extra or missing:
|
|
171
|
+
flag("/statement", "the statement has exactly the register's fields: %s" % ", ".join(ENROLL_TOKEN_FIELDS))
|
|
172
|
+
if statement.get("purpose") != ENROLL_PURPOSE:
|
|
173
|
+
flag("/statement/purpose", "an enroll token's purpose is agent-host")
|
|
174
|
+
if not _is_one(statement.get("uses")):
|
|
175
|
+
flag("/statement/uses", "an enroll token is used once: uses is 1, and the hub binds its nonce on the first use")
|
|
176
|
+
not_before = _estate.estate_time(statement.get("notBefore"))
|
|
177
|
+
expires_at = _estate.estate_time(statement.get("expiresAt"))
|
|
178
|
+
rest = {name: v for name, v in statement.items() if name != "type"}
|
|
179
|
+
named = not extra and not missing and statement.get("purpose") == ENROLL_PURPOSE and _is_one(statement.get("uses")) and _finite(not_before) and _finite(expires_at)
|
|
180
|
+
if named and signing_statement(ENROLL_TOKEN_TYPE, rest) is None:
|
|
181
|
+
flag("/statement", "scope is estate:<id>, genesis a sha256: digest, nonce 32 bytes in base64url, and ceiling null or a digest")
|
|
182
|
+
if not _finite(not_before) or not _finite(expires_at):
|
|
183
|
+
flag("/statement/expiresAt" if _finite(not_before) else "/statement/notBefore", "notBefore and expiresAt are RFC 3339 date-times with an offset, on days that exist")
|
|
184
|
+
elif expires_at <= not_before or expires_at - not_before > MAX_ENROLL_TOKEN_SECONDS * 1000:
|
|
185
|
+
flag("/statement/expiresAt", "an enroll token is valid after notBefore and for at most 15 minutes (900 s)")
|
|
186
|
+
return findings
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def enroll_token_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
|
|
190
|
+
"""FAC-SEM-113 (kind ``enroll-token``); None for another kind."""
|
|
191
|
+
if kind == "enroll-token":
|
|
192
|
+
return _token_findings(value if isinstance(value, dict) else {})
|
|
193
|
+
return None
|
|
194
|
+
# #endregion ceremonies-kit
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py
CHANGED
|
@@ -715,7 +715,9 @@ def verify_estate_record(record: Any, *, at: Any, parent: Any = _MISSING, author
|
|
|
715
715
|
if now > valid_until:
|
|
716
716
|
return {"ok": False, "problem": "expired"}
|
|
717
717
|
codes: List[str] = []
|
|
718
|
-
|
|
718
|
+
# With a parent, what the revision drops it revokes (FAC-SEM-114, DEC-0084): a removed machine, a replaced key.
|
|
719
|
+
dropped = _dropped_findings(parent, record) if isinstance(parent, dict) else []
|
|
720
|
+
for finding in _record_findings(record) + dropped:
|
|
719
721
|
if finding["code"] not in codes:
|
|
720
722
|
codes.append(finding["code"])
|
|
721
723
|
if codes:
|
|
@@ -851,6 +853,32 @@ def _revision_chain(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
851
853
|
return findings
|
|
852
854
|
|
|
853
855
|
|
|
856
|
+
#: Standard base64 with its padding, the one form DEC-0053 records a device key's octets in.
|
|
857
|
+
_BASE64 = re.compile(r"(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\Z")
|
|
858
|
+
#: DEC-0084: a key reference, a device-key kid, ``@sha256:`` and 64 lower-case hex digits.
|
|
859
|
+
KEY_REFERENCE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}#(sig|kem)@sha256:[a-f0-9]{64}\Z")
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def device_key_fingerprint(key: Any) -> Optional[str]:
|
|
863
|
+
"""A device key's fingerprint (DEC-0084): ``sha256:`` and the hex SHA-256 of its octets, ``publicKey`` read as
|
|
864
|
+
canonical padded base64; None for any other form, so two readers never disagree on which key it names."""
|
|
865
|
+
text = key.get("publicKey") if isinstance(key, dict) else None
|
|
866
|
+
if not isinstance(text, str) or not text or not _BASE64.match(text):
|
|
867
|
+
return None
|
|
868
|
+
raw = base64.b64decode(text)
|
|
869
|
+
if base64.b64encode(raw).decode("ascii") != text:
|
|
870
|
+
return None
|
|
871
|
+
return "sha256:" + hashlib.sha256(raw).hexdigest()
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def device_key_ref(key: Any) -> Optional[str]:
|
|
875
|
+
"""The ``device-key`` revocation reference of one key (DEC-0084): ``<kid>@<fingerprint>``; None without a string kid
|
|
876
|
+
or a fingerprint. A replacement revokes the old key by it and leaves the next key under the same kid usable."""
|
|
877
|
+
fingerprint = device_key_fingerprint(key)
|
|
878
|
+
kid = key.get("kid") if isinstance(key, dict) else None
|
|
879
|
+
return "%s@%s" % (kid, fingerprint) if fingerprint is not None and isinstance(kid, str) else None
|
|
880
|
+
|
|
881
|
+
|
|
854
882
|
def _valid_device_key(key: Dict[str, Any]) -> bool:
|
|
855
883
|
public = _get(key, "publicKey")
|
|
856
884
|
raw = _node_base64("" if public is _MISSING or public is None else _show(public))
|
|
@@ -877,7 +905,10 @@ def _keys(value: Dict[str, Any], flag) -> None:
|
|
|
877
905
|
for key, k in _indexed(_get(machine, "deviceKeys")):
|
|
878
906
|
if not _same(_get(key, "kid"), "%s#%s" % (_show(_get(machine, "id")), _show(_get(key, "use")))):
|
|
879
907
|
flag("%s/deviceKeys/%d/kid" % (at, k), "a device key is listed as <machine id>#sig or <machine id>#kem, by its role")
|
|
880
|
-
|
|
908
|
+
# A key with no canonical fingerprint could be read as two keys by two readers, and no key reference names it (DEC-0084).
|
|
909
|
+
if device_key_fingerprint(key) is None:
|
|
910
|
+
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)")
|
|
911
|
+
elif not _valid_device_key(key):
|
|
881
912
|
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)")
|
|
882
913
|
if _contains(seen, _get(key, "publicKey")):
|
|
883
914
|
flag("%s/deviceKeys/%d/publicKey" % (at, k), "one key is never used for the other role")
|
|
@@ -966,7 +997,9 @@ def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
|
|
|
966
997
|
listed = {
|
|
967
998
|
"authority": [_get(e, "credentialId") for e in _list(_get(value, "authorities"))],
|
|
968
999
|
"machine": [_get(m, "id") for m in machines],
|
|
969
|
-
|
|
1000
|
+
# A kid, and each listed key's own reference (DEC-0084): revoking either while listing it is refused.
|
|
1001
|
+
"device-key": [ref for m in machines for k in _list(_get(m, "deviceKeys"))
|
|
1002
|
+
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 [])],
|
|
970
1003
|
"hub-key": [_get(m["hubKey"], "kid") for m in machines if isinstance(_get(m, "hubKey"), dict)],
|
|
971
1004
|
"publisher-key": [_get(k, "kid") for k in _list(_get(value, "publisherKeys"))],
|
|
972
1005
|
"package": [_get(p, "digest") for m in machines for p in _list(_get(m, "packages"))],
|
|
@@ -979,6 +1012,9 @@ def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
|
|
|
979
1012
|
key = "%s %s" % (kind, _show(ref))
|
|
980
1013
|
if key in seen:
|
|
981
1014
|
flag("/revocations/%d" % index, "the %s %s is revoked twice" % (kind, _show(_get(revocation, "ref"))))
|
|
1015
|
+
raw_ref = _get(revocation, "ref")
|
|
1016
|
+
if _get(revocation, "kind") == "device-key" and isinstance(raw_ref, str) and "@" in raw_ref and not KEY_REFERENCE.match(raw_ref):
|
|
1017
|
+
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)")
|
|
982
1018
|
seen.append(key)
|
|
983
1019
|
if _contains(listed.get(kind, []), ref):
|
|
984
1020
|
flag("/revocations/%d/ref" % index, "the record still lists the %s it revokes: revocation outranks addition" % kind)
|
|
@@ -1029,6 +1065,46 @@ def _placement_grants(value: Dict[str, Any], flag) -> None:
|
|
|
1029
1065
|
flag("/machines/%d/packages/%d%s" % (m, p, suffix), message)
|
|
1030
1066
|
|
|
1031
1067
|
|
|
1068
|
+
def _dropped_findings(parent: Dict[str, Any], nxt: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
1069
|
+
"""FAC-SEM-114 across two revisions (DEC-0084): what a revision drops, it revokes in the same revision. A removed
|
|
1070
|
+
machine as ``machine <id>`` and its hub key as ``hub-key <kid>``; a replaced or dropped device key of a kept machine
|
|
1071
|
+
as ``device-key`` by its reference or its kid; a hub key no longer certified as it was as ``hub-key <old kid>``."""
|
|
1072
|
+
findings: List[Dict[str, str]] = []
|
|
1073
|
+
flag = _finding(findings, "FAC-SEM-114")
|
|
1074
|
+
revocations = _list(_get(nxt, "revocations"))
|
|
1075
|
+
|
|
1076
|
+
def revoked(kind: str, *refs: Any) -> bool:
|
|
1077
|
+
return any(_get(entry, "kind") == kind and any(ref is not _MISSING and _same(_get(entry, "ref"), ref) for ref in refs)
|
|
1078
|
+
for entry in revocations)
|
|
1079
|
+
|
|
1080
|
+
kept_machines: List[Tuple[Any, Dict[str, Any]]] = []
|
|
1081
|
+
for machine in _list(_get(nxt, "machines")):
|
|
1082
|
+
if not any(_same(ident, _get(machine, "id")) for ident, _ in kept_machines):
|
|
1083
|
+
kept_machines.append((_get(machine, "id"), machine))
|
|
1084
|
+
for machine, m in _indexed(_get(parent, "machines")):
|
|
1085
|
+
at = "/parent/machines/%d" % m
|
|
1086
|
+
hub = _get(machine, "hubKey") if isinstance(_get(machine, "hubKey"), dict) else None
|
|
1087
|
+
kept = next((found for ident, found in kept_machines if _same(ident, _get(machine, "id"))), None)
|
|
1088
|
+
if kept is None:
|
|
1089
|
+
if not revoked("machine", _get(machine, "id")):
|
|
1090
|
+
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"))))
|
|
1091
|
+
if hub is not None and not revoked("hub-key", _get(hub, "kid")):
|
|
1092
|
+
flag("%s/hubKey/kid" % at, "the hub key %s of a removed machine is revoked in the same revision" % _show(_get(hub, "kid")))
|
|
1093
|
+
continue
|
|
1094
|
+
listed = {ref for ref in (device_key_ref(key) for key in _list(_get(kept, "deviceKeys"))) if ref is not None}
|
|
1095
|
+
for key, k in _indexed(_get(machine, "deviceKeys")):
|
|
1096
|
+
ref = device_key_ref(key)
|
|
1097
|
+
if ref is None or ref in listed:
|
|
1098
|
+
continue
|
|
1099
|
+
if not revoked("device-key", ref, _get(key, "kid")):
|
|
1100
|
+
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))
|
|
1101
|
+
next_hub = _get(kept, "hubKey") if isinstance(_get(kept, "hubKey"), dict) else None
|
|
1102
|
+
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)
|
|
1103
|
+
if hub is not None and not same_hub and not revoked("hub-key", _get(hub, "kid")):
|
|
1104
|
+
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"))))
|
|
1105
|
+
return findings
|
|
1106
|
+
|
|
1107
|
+
|
|
1032
1108
|
def _record_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
1033
1109
|
findings: List[Dict[str, str]] = []
|
|
1034
1110
|
_revision_whole(value, _finding(findings, "FAC-SEM-072"))
|
|
@@ -1095,7 +1171,9 @@ def estate_record_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]
|
|
|
1095
1171
|
if kind == "estate-record":
|
|
1096
1172
|
return _record_findings(value)
|
|
1097
1173
|
if kind == "estate-record-revision":
|
|
1098
|
-
|
|
1174
|
+
parent = value["parent"] if isinstance(_get(value, "parent"), dict) else {}
|
|
1175
|
+
nxt = value["next"] if isinstance(_get(value, "next"), dict) else {}
|
|
1176
|
+
return _revision_chain(value) + _dropped_findings(parent, nxt)
|
|
1099
1177
|
if kind == "estate-placement":
|
|
1100
1178
|
return _installation(value)
|
|
1101
1179
|
return None
|