@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.
- package/CHANGELOG.md +97 -0
- package/README.md +3 -3
- 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/delegation.md +14 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +90 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +23 -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-delegation.mjs +26 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +443 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +140 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +32 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +826 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +217 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +7 -1
- 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
|