@passioncode-ai/fabric-agent-adapter 0.19.0 → 0.21.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.
Files changed (20) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +3 -3
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +14 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +90 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +23 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +26 -4
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +443 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +140 -6
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -2
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +32 -6
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +826 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +217 -8
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +7 -1
  20. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
@@ -0,0 +1,826 @@
1
+ #!/usr/bin/env python3
2
+ """`estate-record/1` (Fabric Agent Contract DEC-0055): verify an estate revision and check its rules.
3
+
4
+ One revision of one person's estate: the passkeys that sign it, the machines with their device keys and hub-key
5
+ certificates, the packages placed per machine, secret placements by reference, share grants and revocations. A
6
+ revision names its parent's digest and is signed by a passkey assertion (WebAuthn Level 3, offline) over a challenge
7
+ derived from its own digest. This is the contract's `src/estate-record-rules.ts`, `src/webauthn.ts` and `src/p256.ts`
8
+ at the pin, ported answer for answer and held to the contract's own vectors (`test/fixtures/contract/estate-vectors.json`):
9
+
10
+ - ``verify_estate_record(record, at=..., parent=... | authorities=...)`` — the verdict a machine applies a revision on,
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`;
14
+ - ``hub_key_certified(record, kid, x, at)`` — DL-14: a delegation verifier that reads the estate record trusts a token's
15
+ `kid` only while a current revision certifies it for a machine (``fabric_delegation`` calls it).
16
+
17
+ Standard library only. ES256 is verified here in pure Python (P-256, FIPS 186-5 / SEC 1); EdDSA by the Ed25519 verifier
18
+ in ``fabric_delegation``.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import datetime as _dt
24
+ import hashlib
25
+ import json
26
+ import math
27
+ import re
28
+ import sys
29
+ from pathlib import Path
30
+ from typing import Any, Dict, List, Optional, Tuple
31
+
32
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
33
+ import fabric_delegation as _delegation # noqa: E402 - beside this file: RFC 8785, Ed25519, small-order keys
34
+ import fabric_secrets as _secrets # noqa: E402 - beside this file: the contract's credential detector
35
+
36
+ ESTATE_RECORD_SCHEMA = "estate-record/1"
37
+ ESTATE_CHALLENGE_TYPE = "estate-revision/1"
38
+ MAX_VALIDITY_SECONDS = 30 * 24 * 60 * 60
39
+ ASSERTION_ALGORITHMS = ("ES256", "EdDSA")
40
+ FLAGS = {"UP": 0x01, "UV": 0x04, "BE": 0x08, "BS": 0x10, "AT": 0x40, "ED": 0x80}
41
+ _MISSING = object()
42
+ _B64URL = re.compile(r"[A-Za-z0-9_-]+")
43
+ _SURROGATE = re.compile("[\ud800-\udfff]")
44
+
45
+
46
+ # --- JavaScript's view of JSON values, which the contract's answers are written in ---------------------------------
47
+
48
+ def _js_string(value: Any) -> str:
49
+ """String(value) for a JSON value."""
50
+ if value is _MISSING:
51
+ return "undefined"
52
+ if value is None:
53
+ return "null"
54
+ if value is True:
55
+ return "true"
56
+ if value is False:
57
+ return "false"
58
+ if isinstance(value, float):
59
+ if math.isnan(value):
60
+ return "NaN"
61
+ if math.isinf(value):
62
+ return "Infinity" if value > 0 else "-Infinity"
63
+ return _delegation._number(value)
64
+ if isinstance(value, int):
65
+ if abs(value) < 2 ** 53:
66
+ return str(value)
67
+ try:
68
+ return _delegation._number(float(value))
69
+ except OverflowError: # a JSON number beyond a double is Infinity in JavaScript
70
+ return "Infinity" if value > 0 else "-Infinity"
71
+ if isinstance(value, dict):
72
+ return "[object Object]"
73
+ if isinstance(value, list):
74
+ return ",".join("" if item is None else _js_string(item) for item in value)
75
+ return str(value)
76
+
77
+
78
+ def _get(value: Any, key: str) -> Any:
79
+ return value.get(key, _MISSING) if isinstance(value, dict) else _MISSING
80
+
81
+
82
+ def _s(value: Any, key: str) -> str:
83
+ return _js_string(_get(value, key))
84
+
85
+
86
+ def _number(value: Any) -> bool:
87
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
88
+
89
+
90
+ def _same(a: Any, b: Any) -> bool:
91
+ """JavaScript's === (and a Set's SameValueZero) on parsed JSON: an object equals only itself."""
92
+ if a is _MISSING or b is _MISSING:
93
+ return a is b
94
+ if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
95
+ return a is b
96
+ if isinstance(a, bool) or isinstance(b, bool):
97
+ return isinstance(a, bool) and isinstance(b, bool) and a == b
98
+ if _number(a) and _number(b):
99
+ return a == b
100
+ return type(a) is type(b) and a == b
101
+
102
+
103
+ def _contains(items: List[Any], value: Any) -> bool:
104
+ return any(_same(item, value) for item in items)
105
+
106
+
107
+ #: The characters JavaScript's StringToNumber trims: WhiteSpace and LineTerminator (ECMA-262 §12.2, §12.3).
108
+ _JS_SPACE = "\t\n\v\f\r              

   "
109
+ _JS_DECIMAL = re.compile(r"[+-]?(?:(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|Infinity)", re.ASCII)
110
+ _JS_RADIX = re.compile(r"0(?:[xX](?P<x>[0-9a-fA-F]+)|[oO](?P<o>[0-7]+)|[bB](?P<b>[01]+))", re.ASCII)
111
+
112
+
113
+ def _js_to_number(value: Any) -> float:
114
+ """Number(value) for a JSON value, as JavaScript reads it; NaN for what it cannot read as a number."""
115
+ if value is _MISSING:
116
+ return math.nan
117
+ if value is None:
118
+ return 0.0
119
+ if isinstance(value, bool):
120
+ return 1.0 if value else 0.0
121
+ if _number(value):
122
+ try:
123
+ return float(value)
124
+ except OverflowError:
125
+ return math.inf if value > 0 else -math.inf
126
+ if isinstance(value, list):
127
+ return _js_to_number(_js_string(value)) # Number([2]) is Number("2"); Number([]) is 0
128
+ if isinstance(value, str):
129
+ text = value.strip(_JS_SPACE)
130
+ if text == "":
131
+ return 0.0
132
+ if _JS_DECIMAL.fullmatch(text):
133
+ return float(text.replace("Infinity", "inf"))
134
+ radix = _JS_RADIX.fullmatch(text)
135
+ if radix:
136
+ digits, base = next((radix.group(k), b) for k, b in (("x", 16), ("o", 8), ("b", 2)) if radix.group(k))
137
+ return float(int(digits, base))
138
+ return math.nan
139
+
140
+
141
+ def _safe_integer(value: Any) -> bool:
142
+ if isinstance(value, int) and not isinstance(value, bool):
143
+ return abs(value) <= 2 ** 53 - 1 # checked before any float: a 400-digit integer would overflow
144
+ return isinstance(value, float) and math.isfinite(value) and value.is_integer() and abs(value) <= 2 ** 53 - 1
145
+
146
+
147
+ def _list(value: Any) -> List[Dict[str, Any]]:
148
+ return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
149
+
150
+
151
+ def _b64url_bytes(text: str) -> bytes:
152
+ """Buffer.from(text, "base64url") for text of the base64url alphabet: a dangling sixth bit group is ignored."""
153
+ return _delegation._b64decode(text)
154
+
155
+
156
+ def _b64url(data: bytes) -> str:
157
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
158
+
159
+
160
+ def _node_base64(text: str) -> bytes:
161
+ """Buffer.from(text, "base64"): either alphabet, other characters skipped, stops at the first "=", a dangling
162
+ sixth bit group ignored."""
163
+ kept = []
164
+ for ch in text:
165
+ if ch == "=":
166
+ break
167
+ if ch.isascii() and (ch.isalnum() or ch in "+/-_"):
168
+ kept.append({"-": "+", "_": "/"}.get(ch, ch))
169
+ body = "".join(kept)
170
+ if len(body) % 4 == 1:
171
+ body = body[:-1]
172
+ return base64.b64decode(body + "=" * (-len(body) % 4))
173
+
174
+
175
+ def _sha256(data: bytes) -> bytes:
176
+ return hashlib.sha256(data).digest()
177
+
178
+
179
+ # --- P-256 (DEC-0053; SEC 1 v2, FIPS 186-5) ------------------------------------------------------------------------
180
+
181
+ _P = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
182
+ _A = _P - 3
183
+ _B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
184
+ _N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
185
+ _GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
186
+ _GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5
187
+
188
+
189
+ def is_valid_p256_key(raw: bytes) -> bool:
190
+ """SEC 1 §3.2.2 for an uncompressed point: 0x04 ‖ X ‖ Y, X and Y below p, on y² = x³ − 3x + b. The cofactor is 1."""
191
+ if len(raw) != 65 or raw[0] != 0x04:
192
+ return False
193
+ x = int.from_bytes(raw[1:33], "big")
194
+ y = int.from_bytes(raw[33:], "big")
195
+ if x >= _P or y >= _P:
196
+ return False
197
+ return (y * y - (x * x * x + _A * x + _B)) % _P == 0
198
+
199
+
200
+ def _jacobian_double(p: Tuple[int, int, int]) -> Tuple[int, int, int]:
201
+ x, y, z = p
202
+ if y == 0 or z == 0:
203
+ return (0, 1, 0)
204
+ ysq = y * y % _P
205
+ s = 4 * x * ysq % _P
206
+ zsq = z * z % _P
207
+ m = 3 * (x - zsq) * (x + zsq) % _P # a = −3
208
+ nx = (m * m - 2 * s) % _P
209
+ ny = (m * (s - nx) - 8 * ysq * ysq) % _P
210
+ nz = 2 * y * z % _P
211
+ return (nx, ny, nz)
212
+
213
+
214
+ def _jacobian_add(p: Tuple[int, int, int], q: Tuple[int, int, int]) -> Tuple[int, int, int]:
215
+ if p[2] == 0:
216
+ return q
217
+ if q[2] == 0:
218
+ return p
219
+ z1z1 = p[2] * p[2] % _P
220
+ z2z2 = q[2] * q[2] % _P
221
+ u1 = p[0] * z2z2 % _P
222
+ u2 = q[0] * z1z1 % _P
223
+ s1 = p[1] * q[2] * z2z2 % _P
224
+ s2 = q[1] * p[2] * z1z1 % _P
225
+ if u1 == u2:
226
+ return _jacobian_double(p) if s1 == s2 else (0, 1, 0)
227
+ h = (u2 - u1) % _P
228
+ r = (s2 - s1) % _P
229
+ hh = h * h % _P
230
+ hhh = h * hh % _P
231
+ v = u1 * hh % _P
232
+ nx = (r * r - hhh - 2 * v) % _P
233
+ ny = (r * (v - nx) - s1 * hhh) % _P
234
+ nz = h * p[2] * q[2] % _P
235
+ return (nx, ny, nz)
236
+
237
+
238
+ def _shamir(u1: int, u2: int, q: Tuple[int, int]) -> Optional[Tuple[int, int]]:
239
+ """u1·G + u2·Q in affine form, or None at infinity."""
240
+ g = (_GX, _GY, 1)
241
+ qj = (q[0], q[1], 1)
242
+ both = _jacobian_add(g, qj)
243
+ out = (0, 1, 0)
244
+ for bit in range(max(u1.bit_length(), u2.bit_length()) - 1, -1, -1):
245
+ out = _jacobian_double(out)
246
+ a, b = (u1 >> bit) & 1, (u2 >> bit) & 1
247
+ if a and b:
248
+ out = _jacobian_add(out, both)
249
+ elif a:
250
+ out = _jacobian_add(out, g)
251
+ elif b:
252
+ out = _jacobian_add(out, qj)
253
+ if out[2] == 0:
254
+ return None
255
+ zinv = pow(out[2], -1, _P)
256
+ return (out[0] * zinv * zinv % _P, out[1] * zinv * zinv * zinv % _P)
257
+
258
+
259
+ def _der_integer(data: bytes, at: int) -> Tuple[Optional[int], int]:
260
+ if at + 2 > len(data) or data[at] != 0x02:
261
+ return None, at
262
+ length = data[at + 1]
263
+ start = at + 2
264
+ if length == 0 or length > 33 or start + length > len(data):
265
+ return None, at
266
+ body = data[start:start + length]
267
+ if body[0] & 0x80:
268
+ return None, at # negative
269
+ if length > 1 and body[0] == 0 and not body[1] & 0x80:
270
+ return None, at # not minimal
271
+ return int.from_bytes(body, "big"), start + length
272
+
273
+
274
+ def _der_signature(signature: bytes) -> Optional[Tuple[int, int]]:
275
+ """An ASN.1 DER Ecdsa-Sig-Value (RFC 3279 §2.2.3), strictly: one SEQUENCE of two minimal positive INTEGERs."""
276
+ if len(signature) < 8 or signature[0] != 0x30:
277
+ return None
278
+ if signature[1] & 0x80:
279
+ return None # a P-256 signature fits a short-form length
280
+ if signature[1] != len(signature) - 2:
281
+ return None
282
+ r, at = _der_integer(signature, 2)
283
+ if r is None:
284
+ return None
285
+ s, at = _der_integer(signature, at)
286
+ if s is None or at != len(signature):
287
+ return None
288
+ return r, s
289
+
290
+
291
+ def es256_verify(x: bytes, y: bytes, message: bytes, signature: bytes) -> bool:
292
+ """ECDSA P-256 with SHA-256 over `message`, the signature in DER (FIPS 186-5 §6.4.2). False, never an exception."""
293
+ if not is_valid_p256_key(b"\x04" + x + y):
294
+ return False
295
+ parsed = _der_signature(signature)
296
+ if parsed is None:
297
+ return False
298
+ r, s = parsed
299
+ if not (1 <= r < _N and 1 <= s < _N):
300
+ return False
301
+ e = int.from_bytes(_sha256(message), "big")
302
+ w = pow(s, -1, _N)
303
+ point = _shamir(e * w % _N, r * w % _N, (int.from_bytes(x, "big"), int.from_bytes(y, "big")))
304
+ return point is not None and point[0] % _N == r
305
+
306
+
307
+ # --- WebAuthn Level 3 §7.2, offline (src/webauthn.ts) ----------------------------------------------------------------
308
+
309
+ def _canonical_b64url(text: str) -> bool:
310
+ try:
311
+ return _b64url(_b64url_bytes(text)) == text
312
+ except ValueError:
313
+ return False
314
+
315
+
316
+ def credential_key(public_key: Any, alg: str) -> Dict[str, Any]:
317
+ """The credential's public key, validated before use: ``{"key": ...}`` or ``{"problem": "unusable-key" |
318
+ "small-order-key"}``. A P-256 point valid by SEC 1 for ES256; an Ed25519 key of full order for EdDSA."""
319
+ if alg not in ASSERTION_ALGORITHMS:
320
+ return {"problem": "unusable-key"}
321
+ if (not isinstance(public_key, dict) or "d" in public_key or not isinstance(public_key.get("x"), str)
322
+ or not _B64URL.fullmatch(public_key["x"]) or not _canonical_b64url(public_key["x"])):
323
+ return {"problem": "unusable-key"}
324
+ if alg == "ES256":
325
+ y = public_key.get("y")
326
+ if (public_key.get("kty") != "EC" or public_key.get("crv") != "P-256" or not isinstance(y, str)
327
+ or not _B64URL.fullmatch(y) or not _canonical_b64url(y)):
328
+ return {"problem": "unusable-key"}
329
+ xb, yb = _b64url_bytes(public_key["x"]), _b64url_bytes(y)
330
+ if len(xb) != 32 or len(yb) != 32 or not is_valid_p256_key(b"\x04" + xb + yb):
331
+ return {"problem": "unusable-key"}
332
+ return {"key": ("ES256", xb, yb)}
333
+ if public_key.get("kty") != "OKP" or public_key.get("crv") != "Ed25519":
334
+ return {"problem": "unusable-key"}
335
+ raw = _b64url_bytes(public_key["x"])
336
+ if len(raw) != 32:
337
+ return {"problem": "unusable-key"}
338
+ if _delegation.is_small_order_key(raw):
339
+ return {"problem": "small-order-key"} # DEC-0045 (CT-29): refused before any use
340
+ return {"key": ("EdDSA", raw)}
341
+
342
+
343
+ def _no_constant(name: str) -> Any:
344
+ raise ValueError("%s is not JSON" % name)
345
+
346
+
347
+ def verify_assertion(assertion: Any, credential: Dict[str, Any], challenge: bytes) -> Dict[str, Any]:
348
+ """Verify a passkey assertion under a credential the caller pinned (`credentialId`, `alg`, `publicKey`, `rpId`,
349
+ `origins`), for the expected challenge bytes, in the contract's order: shape, algorithm, key, client data, authenticator
350
+ data, signature. ``{"ok": True, "flags": ..., "signCount": ...}`` or ``{"ok": False, "problem": ...}``."""
351
+ def fail(problem: str) -> Dict[str, Any]:
352
+ return {"ok": False, "problem": problem}
353
+
354
+ if not isinstance(assertion, dict):
355
+ return fail("malformed")
356
+ fields = [_get(assertion, k) for k in ("credentialId", "alg", "authenticatorData", "clientDataJSON", "sig")]
357
+ if not all(isinstance(f, str) for f in fields):
358
+ return fail("malformed")
359
+ credential_id, alg, authenticator_data, client_data_json, sig = fields
360
+ if not all(_B64URL.fullmatch(f) for f in (credential_id, authenticator_data, client_data_json, sig)):
361
+ return fail("malformed")
362
+ if credential_id != credential.get("credentialId"):
363
+ return fail("malformed")
364
+ if alg not in ASSERTION_ALGORITHMS:
365
+ return fail("unknown-algorithm")
366
+ if alg != credential.get("alg"):
367
+ return fail("key-algorithm-mismatch")
368
+ found = credential_key(credential.get("publicKey"), alg)
369
+ if "problem" in found:
370
+ return fail(found["problem"])
371
+
372
+ c_data = _b64url_bytes(client_data_json)
373
+ try:
374
+ text = c_data.decode("utf-8")
375
+ if text.startswith(""):
376
+ text = text[1:] # §7.2: a leading byte order mark is stripped
377
+ client = json.loads(text, parse_constant=_no_constant)
378
+ except (ValueError, RecursionError):
379
+ return fail("malformed")
380
+ if not isinstance(client, dict):
381
+ return fail("malformed")
382
+ if _get(client, "type") != "webauthn.get":
383
+ return fail("wrong-type")
384
+ if not _same(_get(client, "challenge"), _b64url(challenge)):
385
+ return fail("wrong-challenge")
386
+ origin = _get(client, "origin")
387
+ if not isinstance(origin, str) or origin not in [o for o in credential.get("origins") or [] if isinstance(o, str)]:
388
+ return fail("wrong-origin")
389
+ if _get(client, "crossOrigin") is True or "topOrigin" in client:
390
+ return fail("cross-origin")
391
+
392
+ auth_data = _b64url_bytes(authenticator_data)
393
+ if len(auth_data) < 37:
394
+ return fail("malformed")
395
+ if auth_data[:32] != _sha256(_SURROGATE.sub("�", str(credential.get("rpId"))).encode("utf-8")):
396
+ return fail("wrong-rp")
397
+ flags = auth_data[32]
398
+ if flags & FLAGS["AT"] or (flags & FLAGS["BS"] and not flags & FLAGS["BE"]):
399
+ return fail("bad-flags")
400
+ if not flags & FLAGS["UP"]:
401
+ return fail("no-user-presence")
402
+ if not flags & FLAGS["UV"]:
403
+ return fail("no-user-verification")
404
+
405
+ signed = auth_data + _sha256(c_data)
406
+ signature = _b64url_bytes(sig)
407
+ key = found["key"]
408
+ good = (es256_verify(key[1], key[2], signed, signature) if key[0] == "ES256"
409
+ else len(signature) == 64 and _delegation.ed25519_verify(key[1], signed, signature))
410
+ if not good:
411
+ return fail("bad-signature")
412
+ return {"ok": True, "flags": flags, "signCount": int.from_bytes(auth_data[33:37], "big")}
413
+
414
+
415
+ # --- the record (src/estate-record-rules.ts) ---------------------------------------------------------------------
416
+
417
+ def estate_record_digest(record: Dict[str, Any]) -> str:
418
+ """`sha256:` and the hex SHA-256 of the UTF-8 RFC 8785 form of the record without `digest` and `signature`.
419
+ Raises TypeError for a value RFC 8785 has no form for."""
420
+ covered = {k: v for k, v in record.items() if k not in ("digest", "signature")}
421
+ return "sha256:" + hashlib.sha256(_delegation.jcs(covered).encode("utf-8")).hexdigest()
422
+
423
+
424
+ def estate_challenge_input(digest: str) -> bytes:
425
+ return _delegation.jcs({"type": ESTATE_CHALLENGE_TYPE, "digest": digest}).encode("utf-8")
426
+
427
+
428
+ def estate_challenge(digest: str) -> bytes:
429
+ """The WebAuthn challenge a signer passes to navigator.credentials.get(): SHA-256 of estate_challenge_input."""
430
+ return _sha256(estate_challenge_input(digest))
431
+
432
+
433
+ # ASCII digits only: Python's \d also matches other scripts' digits, which Date.parse never reads.
434
+ _RFC3339 = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|([+-])([0-9]{2}):([0-9]{2}))", re.ASCII)
435
+
436
+
437
+ def _days_from_civil(year: int, month: int, day: int) -> int:
438
+ year -= month <= 2
439
+ era = (year if year >= 0 else year - 399) // 400
440
+ yoe = year - era * 400
441
+ doy = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
442
+ doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
443
+ return era * 146097 + doe - 719468
444
+
445
+
446
+ def estate_time(value: Any) -> float:
447
+ """An RFC 3339 date-time as milliseconds since the epoch, or NaN: `T`, an offset and a day that exists are required;
448
+ a space and a leap second `:60` are refused (ER-10). Fractions count to the millisecond, as Date.parse reads them."""
449
+ match = _RFC3339.fullmatch(value) if isinstance(value, str) else None
450
+ if not match:
451
+ return math.nan
452
+ year, month, day, hour, minute, second = (int(match.group(i)) for i in range(1, 7))
453
+ leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
454
+ days_in = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
455
+ if not (1 <= month <= 12 and 1 <= day <= days_in[month - 1]) or hour > 23 or minute > 59 or second > 59:
456
+ return math.nan
457
+ if match.group(9):
458
+ oh, om = int(match.group(10)), int(match.group(11))
459
+ if oh > 23 or om > 59:
460
+ return math.nan
461
+ offset = (oh * 60 + om) * (1 if match.group(9) == "+" else -1)
462
+ else:
463
+ offset = 0
464
+ millis = int((match.group(7) or ".")[1:4].ljust(3, "0"))
465
+ seconds = _days_from_civil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second - offset * 60
466
+ return float(seconds * 1000 + millis)
467
+
468
+
469
+ def _clock(at: Any) -> float:
470
+ """The verifier's clock as milliseconds: a timezone-aware datetime or epoch seconds; NaN for anything else."""
471
+ if isinstance(at, _dt.datetime) and at.tzinfo is not None:
472
+ return at.timestamp() * 1000.0
473
+ if _number(at):
474
+ try:
475
+ seconds = float(at)
476
+ except OverflowError:
477
+ return math.nan
478
+ return seconds * 1000.0 if math.isfinite(seconds) else math.nan
479
+ return math.nan
480
+
481
+
482
+ def _revocation_key(entry: Dict[str, Any]) -> str:
483
+ return "%s %s" % (_s(entry, "kind"), _s(entry, "ref"))
484
+
485
+
486
+ def _lt(a: float, b: float) -> bool:
487
+ return not (math.isnan(a) or math.isnan(b)) and a < b
488
+
489
+
490
+ def verify_estate_record(record: Any, *, at: Any, parent: Any = _MISSING, authorities: Any = None) -> Dict[str, Any]:
491
+ """The reference verifier, in the order of estate-record.md's ER table: the clock, shape, digest, the chain to the
492
+ parent, the signing passkey among the trusted ones, the assertion, the validity window, then the record's own rules.
493
+ ``at`` is the verifier's clock (an aware datetime or epoch seconds). Pass the record's verified ``parent``, or for
494
+ revision 1 the ``authorities`` the machine pinned at enrollment. Returns ``{"ok": True, "digest", "revision",
495
+ "signedBy", "signCount"}`` or ``{"ok": False, "problem", "codes"?}``."""
496
+ now = _clock(at)
497
+ if not math.isfinite(now):
498
+ return {"ok": False, "problem": "no-clock"} # an unreadable clock would accept any window
499
+ if (not isinstance(record, dict) or not isinstance(_get(record, "digest"), str) or not _safe_integer(_get(record, "revision"))
500
+ or not isinstance(_get(record, "signature"), dict)):
501
+ return {"ok": False, "problem": "malformed"}
502
+ signature = record["signature"]
503
+ if _get(signature, "format") != "webauthn-assertion" or not isinstance(_get(signature, "credentialId"), str):
504
+ return {"ok": False, "problem": "malformed"}
505
+ try:
506
+ digest = estate_record_digest(record)
507
+ except TypeError:
508
+ return {"ok": False, "problem": "malformed"}
509
+ if digest != record["digest"]:
510
+ return {"ok": False, "problem": "digest-mismatch"}
511
+
512
+ revoked: List[Any] = []
513
+ if parent is not _MISSING:
514
+ if not isinstance(parent, dict):
515
+ return {"ok": False, "problem": "broken-chain"}
516
+ try:
517
+ parent_digest = estate_record_digest(parent)
518
+ except TypeError:
519
+ return {"ok": False, "problem": "broken-chain"}
520
+ if (not _same(_get(parent, "estate"), _get(record, "estate"))
521
+ or not _same(_get(record, "revision"), _js_to_number(_get(parent, "revision")) + 1)
522
+ or not _same(_get(record, "parent"), parent_digest)):
523
+ return {"ok": False, "problem": "broken-chain"}
524
+ if _lt(estate_time(_get(record, "issuedAt")), estate_time(_get(parent, "issuedAt"))):
525
+ return {"ok": False, "problem": "broken-chain"}
526
+ kept = {_revocation_key(e) for e in _list(_get(record, "revocations"))}
527
+ if any(_revocation_key(e) not in kept for e in _list(_get(parent, "revocations"))):
528
+ return {"ok": False, "problem": "broken-chain"}
529
+ # A passkey the parent revokes signs nothing after it; a revision may still revoke its own signer (rotation).
530
+ revoked = [_get(e, "ref") for e in _list(_get(parent, "revocations")) if _get(e, "kind") == "authority"]
531
+ trusted = _get(parent, "authorities")
532
+ trusted = _list(trusted)
533
+ else:
534
+ if not _same(_get(record, "revision"), 1) or _get(record, "parent") is not _MISSING:
535
+ return {"ok": False, "problem": "broken-chain"}
536
+ trusted = list(authorities) if isinstance(authorities, (list, tuple)) else []
537
+ if len(trusted) == 0:
538
+ return {"ok": False, "problem": "no-trusted-authority"}
539
+ matches = [e for e in trusted if isinstance(e, dict) and _same(_get(e, "credentialId"), signature["credentialId"])]
540
+ if len(matches) > 1:
541
+ return {"ok": False, "problem": "duplicate-authority"}
542
+ if not matches:
543
+ return {"ok": False, "problem": "unknown-authority"}
544
+ authority = matches[0]
545
+ if _contains(revoked, _get(authority, "credentialId")):
546
+ return {"ok": False, "problem": "revoked-authority"}
547
+ origins = _get(authority, "origins")
548
+ credential = {"credentialId": _s(authority, "credentialId"), "alg": _s(authority, "alg"),
549
+ "publicKey": authority["publicKey"] if isinstance(_get(authority, "publicKey"), dict) else {},
550
+ "rpId": _s(authority, "rpId"), "origins": [o for o in origins if isinstance(o, str)] if isinstance(origins, list) else []}
551
+ assertion = verify_assertion(signature, credential, estate_challenge(digest))
552
+ if not assertion["ok"]:
553
+ return {"ok": False, "problem": assertion["problem"]}
554
+
555
+ issued_at = estate_time(_get(record, "issuedAt"))
556
+ valid_until = estate_time(_get(record, "validUntil"))
557
+ if (not math.isfinite(issued_at) or not math.isfinite(valid_until) or valid_until <= issued_at
558
+ or valid_until - issued_at > MAX_VALIDITY_SECONDS * 1000):
559
+ return {"ok": False, "problem": "too-long-lived"}
560
+ if now < issued_at:
561
+ return {"ok": False, "problem": "not-yet-valid"}
562
+ if now > valid_until:
563
+ return {"ok": False, "problem": "expired"}
564
+ codes: List[str] = []
565
+ for finding in _record_findings(record):
566
+ if finding["code"] not in codes:
567
+ codes.append(finding["code"])
568
+ if codes:
569
+ return {"ok": False, "problem": "rule-violation", "codes": codes}
570
+ return {"ok": True, "digest": digest, "revision": record["revision"], "signedBy": credential["credentialId"], "signCount": assertion["signCount"]}
571
+
572
+
573
+ def placement_trust(record: Dict[str, Any], machine_id: str, agent_ref: str) -> Optional[Dict[str, Any]]:
574
+ """What a package verifier (DEC-0051) trusts for a package placed on a machine: the publisher keys the placement
575
+ names, from the record's `publisherKeys`. None when the record places no such agent on that machine."""
576
+ machine = next((m for m in _list(_get(record, "machines")) if _same(_get(m, "id"), machine_id)), None)
577
+ placement = next((p for p in _list(_get(machine, "packages")) if _same(_get(p, "agentRef"), agent_ref)), None)
578
+ if placement is None:
579
+ return None
580
+ return {"publisher": [k for k in _list(_get(record, "publisherKeys")) if _same(_get(k, "kid"), _get(placement, "publisherKid"))]}
581
+
582
+
583
+ def hub_key_certified(record: Any, kid: str, x: str, at: Any) -> bool:
584
+ """DL-14 with DEC-0055: a delegation verifier that reads the estate record trusts a token's `kid` only while a
585
+ current revision certifies it for a machine and does not revoke it. ``record`` is a revision the caller verified
586
+ (verify_estate_record); ``x`` is the key the published key set gives for ``kid``; ``at`` is the clock."""
587
+ now = _clock(at)
588
+ if not isinstance(record, dict) or not math.isfinite(now):
589
+ return False
590
+ issued_at, valid_until = estate_time(_get(record, "issuedAt")), estate_time(_get(record, "validUntil"))
591
+ if not (math.isfinite(issued_at) and math.isfinite(valid_until) and issued_at <= now <= valid_until):
592
+ return False
593
+ revoked = {_revocation_key(e) for e in _list(_get(record, "revocations"))}
594
+ for machine in _list(_get(record, "machines")):
595
+ hub = _get(machine, "hubKey")
596
+ if (isinstance(hub, dict) and _same(_get(hub, "kid"), kid) and _same(_get(hub, "x"), x)
597
+ and "hub-key %s" % kid not in revoked and "machine %s" % _s(machine, "id") not in revoked):
598
+ return True
599
+ return False
600
+
601
+
602
+ # --- FAC-SEM-072…076 ------------------------------------------------------------------------------------------------
603
+
604
+ def _finding(findings: List[Dict[str, str]], code: str):
605
+ def flag(path: str, message: str) -> None:
606
+ findings.append({"code": code, "instancePath": path, "message": message})
607
+ return flag
608
+
609
+
610
+ def _revision_whole(value: Dict[str, Any], flag) -> None:
611
+ try:
612
+ digest = estate_record_digest(value)
613
+ if not _same(digest, _get(value, "digest")):
614
+ flag("/digest", "the digest is %s: the record changed after it was drafted, or was hashed over another form than RFC 8785" % digest)
615
+ except TypeError:
616
+ flag("/digest", "the record has no RFC 8785 form, so it has no digest")
617
+ issued_at = estate_time(_get(value, "issuedAt"))
618
+ valid_until = estate_time(_get(value, "validUntil"))
619
+ if not math.isfinite(issued_at) or not math.isfinite(valid_until):
620
+ flag("/validUntil" if math.isfinite(issued_at) else "/issuedAt", "issuedAt and validUntil are RFC 3339 date-times with an offset, on days that exist")
621
+ elif valid_until <= issued_at or valid_until - issued_at > MAX_VALIDITY_SECONDS * 1000:
622
+ flag("/validUntil", "an estate record is valid after it is issued and for at most 30 days")
623
+
624
+
625
+ def _revision_chain(value: Dict[str, Any]) -> List[Dict[str, str]]:
626
+ findings: List[Dict[str, str]] = []
627
+ flag = _finding(findings, "FAC-SEM-072")
628
+ parent = value["parent"] if isinstance(_get(value, "parent"), dict) else {}
629
+ nxt = value["next"] if isinstance(_get(value, "next"), dict) else {}
630
+ if not _same(_get(nxt, "estate"), _get(parent, "estate")):
631
+ flag("/next/estate", "a revision continues its parent's estate")
632
+ expected = _js_to_number(_get(parent, "revision")) + 1
633
+ if not _same(_get(nxt, "revision"), expected):
634
+ flag("/next/revision", "the revision after %s is %s: a revision never skips or repeats one" % (_s(parent, "revision"), _js_string(expected)))
635
+ try:
636
+ parent_digest: Any = estate_record_digest(parent)
637
+ except TypeError:
638
+ parent_digest = _MISSING
639
+ if not _same(_get(nxt, "parent"), parent_digest):
640
+ flag("/next/parent", "parent names the digest of the revision before it")
641
+ if _lt(estate_time(_get(nxt, "issuedAt")), estate_time(_get(parent, "issuedAt"))):
642
+ flag("/next/issuedAt", "a revision is never issued before its parent")
643
+ kept = {_revocation_key(e) for e in _list(_get(nxt, "revocations"))}
644
+ for index, entry in enumerate(_list(_get(parent, "revocations"))):
645
+ if _revocation_key(entry) not in kept:
646
+ flag("/parent/revocations/%d" % index, "the %s revocation of %s is dropped: a revision keeps every revocation before it" % (_s(entry, "kind"), _s(entry, "ref")))
647
+ return findings
648
+
649
+
650
+ def _valid_device_key(key: Dict[str, Any]) -> bool:
651
+ public = _get(key, "publicKey")
652
+ raw = _node_base64("" if public is _MISSING or public is None else _js_string(public))
653
+ if _get(key, "alg") == "ed25519":
654
+ return len(raw) == 32 and not _delegation.is_small_order_key(raw)
655
+ return is_valid_p256_key(raw)
656
+
657
+
658
+ def _keys(value: Dict[str, Any], flag) -> None:
659
+ ids: List[Any] = []
660
+ hub_kids: List[Any] = []
661
+ hub_keys: List[Any] = []
662
+ for m, machine in enumerate(_list(_get(value, "machines"))):
663
+ at = "/machines/%d" % m
664
+ if _contains(ids, _get(machine, "id")):
665
+ flag("%s/id" % at, "machine %s is listed twice" % _s(machine, "id"))
666
+ ids.append(_get(machine, "id"))
667
+ device_keys = _list(_get(machine, "deviceKeys"))
668
+ if len([k for k in device_keys if _get(k, "use") == "sig"]) != 1:
669
+ flag("%s/deviceKeys" % at, "a machine has exactly one signing device key, <id>#sig")
670
+ if len([k for k in device_keys if _get(k, "use") == "kem"]) > 1:
671
+ flag("%s/deviceKeys" % at, "a machine has at most one key-agreement device key, <id>#kem")
672
+ seen: List[Any] = []
673
+ for k, key in enumerate(device_keys):
674
+ if not _same(_get(key, "kid"), "%s#%s" % (_s(machine, "id"), _s(key, "use"))):
675
+ flag("%s/deviceKeys/%d/kid" % (at, k), "a device key is listed as <machine id>#sig or <machine id>#kem, by its role")
676
+ if not _valid_device_key(key):
677
+ 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)")
678
+ if _contains(seen, _get(key, "publicKey")):
679
+ flag("%s/deviceKeys/%d/publicKey" % (at, k), "one key is never used for the other role")
680
+ seen.append(_get(key, "publicKey"))
681
+ hub = _get(machine, "hubKey")
682
+ if isinstance(hub, dict):
683
+ if _contains(hub_kids, _get(hub, "kid")):
684
+ flag("%s/hubKey/kid" % at, "hub key %s is certified for two machines: one kid per machine" % _s(hub, "kid"))
685
+ hub_kids.append(_get(hub, "kid"))
686
+ if _contains(hub_keys, _get(hub, "x")):
687
+ flag("%s/hubKey/x" % at, "one hub public key is certified for two machines under two kids: one key per machine")
688
+ hub_keys.append(_get(hub, "x"))
689
+ x = _get(hub, "x")
690
+ raw = _node_base64("" if x is _MISSING or x is None else _js_string(x))
691
+ if _delegation.is_small_order_key(raw):
692
+ flag("%s/hubKey/x" % at, "a hub key of small order verifies forged tokens and is never certified (DEC-0045)")
693
+ credentials: List[Any] = []
694
+ for index, authority in enumerate(_list(_get(value, "authorities"))):
695
+ if _contains(credentials, _get(authority, "credentialId")):
696
+ flag("/authorities/%d/credentialId" % index, "a passkey is listed once")
697
+ credentials.append(_get(authority, "credentialId"))
698
+ if "problem" in credential_key(_get(authority, "publicKey"), _s(authority, "alg")):
699
+ 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")
700
+
701
+
702
+ def _placements(value: Dict[str, Any], flag) -> None:
703
+ kids: List[Any] = []
704
+ for index, key in enumerate(_list(_get(value, "publisherKeys"))):
705
+ if _contains(kids, _get(key, "kid")):
706
+ flag("/publisherKeys/%d/kid" % index, "publisher key %s is pinned twice" % _s(key, "kid"))
707
+ kids.append(_get(key, "kid"))
708
+ for m, machine in enumerate(_list(_get(value, "machines"))):
709
+ agents: List[Any] = []
710
+ for p, placement in enumerate(_list(_get(machine, "packages"))):
711
+ at = "/machines/%d/packages/%d" % (m, p)
712
+ if _contains(agents, _get(placement, "agentRef")):
713
+ flag("%s/agentRef" % at, "%s is placed twice on one machine" % _s(placement, "agentRef"))
714
+ agents.append(_get(placement, "agentRef"))
715
+ if not _contains(kids, _get(placement, "publisherKid")):
716
+ flag("%s/publisherKid" % at, "publisher key %s is not in publisherKeys, so the machine pins no key to verify the package under" % _s(placement, "publisherKid"))
717
+
718
+
719
+ def _secret_references(value: Dict[str, Any], flag) -> None:
720
+ machine_ids = [_get(m, "id") for m in _list(_get(value, "machines"))]
721
+ for m, machine in enumerate(_list(_get(value, "machines"))):
722
+ placed = [_get(p, "agentRef") for p in _list(_get(machine, "packages"))]
723
+ slots: List[str] = []
724
+ for s, secret in enumerate(_list(_get(machine, "secrets"))):
725
+ at = "/machines/%d/secrets/%d" % (m, s)
726
+ for field in ("slot", "from", "agentRef"):
727
+ text = _get(secret, field)
728
+ if isinstance(text, str) and _secrets.secret_value(text):
729
+ flag("%s/%s" % (at, field), "a secret placement names a slot, never a value: this looks like a credential")
730
+ if not _contains(placed, _get(secret, "agentRef")):
731
+ 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")
732
+ if not _contains(machine_ids, _get(secret, "from")):
733
+ flag("%s/from" % at, "the slot is filled from a machine of this estate")
734
+ key = "%s %s" % (_s(secret, "agentRef"), _s(secret, "slot"))
735
+ if key in slots:
736
+ flag("%s/slot" % at, "slot %s is placed twice for one agent on one machine" % _s(secret, "slot"))
737
+ slots.append(key)
738
+
739
+
740
+ def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
741
+ grant_ids: List[Any] = []
742
+ for index, grant in enumerate(_list(_get(value, "shareGrants"))):
743
+ if _get(grant, "principalKind") != "external":
744
+ flag("/shareGrants/%d/principalKind" % index, "a share grant is to a principal outside the estate: principalKind external")
745
+ ident = _get(_get(grant, "meta"), "id") if isinstance(_get(grant, "meta"), dict) else _MISSING
746
+ if _contains(grant_ids, ident):
747
+ flag("/shareGrants/%d/meta/id" % index, "grant %s is listed twice" % _js_string(ident))
748
+ grant_ids.append(ident)
749
+ machines = _list(_get(value, "machines"))
750
+ listed = {
751
+ "authority": [_get(e, "credentialId") for e in _list(_get(value, "authorities"))],
752
+ "machine": [_get(m, "id") for m in machines],
753
+ "device-key": [_get(k, "kid") for m in machines for k in _list(_get(m, "deviceKeys"))],
754
+ "hub-key": [_get(m["hubKey"], "kid") for m in machines if isinstance(_get(m, "hubKey"), dict)],
755
+ "publisher-key": [_get(k, "kid") for k in _list(_get(value, "publisherKeys"))],
756
+ "package": [_get(p, "digest") for m in machines for p in _list(_get(m, "packages"))],
757
+ "grant": grant_ids,
758
+ }
759
+ seen: List[str] = []
760
+ for index, revocation in enumerate(_list(_get(value, "revocations"))):
761
+ key = "%s %s" % (_s(revocation, "kind"), _s(revocation, "ref"))
762
+ if key in seen:
763
+ flag("/revocations/%d" % index, "the %s %s is revoked twice" % (_s(revocation, "kind"), _s(revocation, "ref")))
764
+ seen.append(key)
765
+ if _contains(listed.get(_s(revocation, "kind"), []), _get(revocation, "ref")):
766
+ flag("/revocations/%d/ref" % index, "the record still lists the %s it revokes: revocation outranks addition" % _s(revocation, "kind"))
767
+
768
+
769
+ def _record_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
770
+ findings: List[Dict[str, str]] = []
771
+ _revision_whole(value, _finding(findings, "FAC-SEM-072"))
772
+ _keys(value, _finding(findings, "FAC-SEM-073"))
773
+ _placements(value, _finding(findings, "FAC-SEM-074"))
774
+ _secret_references(value, _finding(findings, "FAC-SEM-075"))
775
+ _grants_and_revocations(value, _finding(findings, "FAC-SEM-076"))
776
+ return findings
777
+
778
+
779
+ def _package_digest(pkg: Dict[str, Any]) -> str:
780
+ covered = {k: v for k, v in pkg.items() if k not in ("digest", "signatures")}
781
+ return "sha256:" + hashlib.sha256(_delegation.jcs(covered).encode("utf-8")).hexdigest()
782
+
783
+
784
+ def _installation(value: Dict[str, Any]) -> List[Dict[str, str]]:
785
+ findings: List[Dict[str, str]] = []
786
+ record = value["record"] if isinstance(_get(value, "record"), dict) else {}
787
+ pkg = value["package"] if isinstance(_get(value, "package"), dict) else {}
788
+ machines = _list(_get(record, "machines"))
789
+ machine_index = next((i for i, m in enumerate(machines) if _same(_get(m, "id"), _get(value, "machine"))), -1)
790
+ flag074 = _finding(findings, "FAC-SEM-074")
791
+ if machine_index < 0:
792
+ flag074("/machine", "machine %s is not in the estate record" % _s(value, "machine"))
793
+ return findings
794
+ machine = machines[machine_index]
795
+ try:
796
+ digest: Any = _package_digest(pkg)
797
+ except TypeError:
798
+ digest = _MISSING
799
+ if digest is _MISSING or not _same(digest, _get(pkg, "digest")):
800
+ flag074("/package/digest", "the package is not the one its digest names: it changed after it was packed")
801
+ placement = next((p for p in _list(_get(machine, "packages")) if _same(_get(p, "agentRef"), _get(pkg, "agentRef"))), None)
802
+ if placement is None:
803
+ flag074("/package/agentRef", "the record places no %s on machine %s" % (_s(pkg, "agentRef"), _s(value, "machine")))
804
+ elif not _same(_get(placement, "digest"), digest):
805
+ flag074("/package", "the record places %s of this agent on this machine, not this package" % _s(placement, "digest"))
806
+ declared = [_get(slot, "name") for slot in _list(_get(pkg, "secrets"))]
807
+ flag075 = _finding(findings, "FAC-SEM-075")
808
+ for index, secret in enumerate(_list(_get(machine, "secrets"))):
809
+ if _same(_get(secret, "agentRef"), _get(pkg, "agentRef")) and not _contains(declared, _get(secret, "slot")):
810
+ flag075("/record/machines/%d/secrets/%d/slot" % (machine_index, index),
811
+ "the package declares no slot %s: a secret goes only where a placed package declares it" % _s(secret, "slot"))
812
+ return findings
813
+
814
+
815
+ def estate_record_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
816
+ """FAC-SEM-072…076: findings for an `estate-record`, an `estate-record-revision` `{parent, next}` or an
817
+ `estate-placement` `{record, machine, package}`; None for another kind."""
818
+ if not isinstance(value, dict):
819
+ value = {}
820
+ if kind == "estate-record":
821
+ return _record_findings(value)
822
+ if kind == "estate-record-revision":
823
+ return _revision_chain(value)
824
+ if kind == "estate-placement":
825
+ return _installation(value)
826
+ return None