@passioncode-ai/fabric-agent-adapter 0.20.0 → 0.22.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 (22) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +40 -8
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
  5. package/plugins/fabric-agent-adapter/hooks/hooks.json +43 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +5 -3
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +14 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +90 -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-service.mjs +8 -2
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +32 -6
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +826 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +7 -1
  18. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  19. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +217 -0
  20. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +99 -0
  21. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +111 -0
  22. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1265 -0
@@ -0,0 +1,443 @@
1
+ // `estate-record/1` (Fabric Agent Contract DEC-0055): verify an estate revision and check its rules — the twin of
2
+ // fabric_estate.py. The contract's src/estate-record-rules.ts, src/webauthn.ts and src/p256.ts at the pin, held to the
3
+ // contract's own vectors (test/fixtures/contract/estate-vectors.json):
4
+ //
5
+ // - verifyEstateRecord(record, { at, parent } | { at, authorities }) — the verdict a machine applies a revision on, after
6
+ // it has checked the revision against estate-record.schema.json (the verifier does not check the schema);
7
+ // - estateRecordRules(kind, value) — FAC-SEM-072…076;
8
+ // - hubKeyCertified(record, kid, x, at) — DL-14: a delegation verifier that reads the estate record trusts a token's
9
+ // `kid` only while a current revision certifies it for a machine (fabric-delegation.mjs calls it).
10
+ //
11
+ // Node's own crypto: ES256 through createPublicKey (JWK) and verify (DER), EdDSA through Ed25519.
12
+
13
+ import crypto from 'node:crypto';
14
+ import { jcs } from './fabric-delegation.mjs';
15
+ import { secretValue } from './fabric-secrets.mjs';
16
+
17
+ export const ESTATE_RECORD_SCHEMA = 'estate-record/1';
18
+ export const ESTATE_CHALLENGE_TYPE = 'estate-revision/1';
19
+ export const MAX_VALIDITY_SECONDS = 30 * 24 * 60 * 60;
20
+ export const ASSERTION_ALGORITHMS = Object.freeze(['ES256', 'EdDSA']);
21
+ export const FLAGS = Object.freeze({ UP: 0x01, UV: 0x04, BE: 0x08, BS: 0x10, AT: 0x40, ED: 0x80 });
22
+
23
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ const isString = (value) => typeof value === 'string';
25
+ const list = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
26
+ const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest();
27
+ const B64URL = /^[A-Za-z0-9_-]+$/;
28
+ const bytes = (text) => Buffer.from(text, 'base64url');
29
+ const canonical = (text) => Buffer.from(text, 'base64url').toString('base64url') === text;
30
+
31
+ const SMALL_ORDER = new Set([
32
+ '0000000000000000000000000000000000000000000000000000000000000000', '0100000000000000000000000000000000000000000000000000000000000000',
33
+ '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05', 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
34
+ 'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f', 'edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
35
+ 'eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
36
+ ]);
37
+ /** The contract's isSmallOrderKey: a key that is not 32 bytes counts as one too. */
38
+ function smallOrder(raw) {
39
+ if (raw.length !== 32) return true;
40
+ const cleared = Buffer.from(raw);
41
+ cleared[31] &= 0x7f;
42
+ return SMALL_ORDER.has(cleared.toString('hex'));
43
+ }
44
+
45
+ // ---- P-256 (DEC-0053; SEC 1 v2 §3.2.2) ----
46
+
47
+ const P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
48
+ const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
49
+ const toBig = (part) => BigInt(`0x${Buffer.from(part).toString('hex')}`);
50
+
51
+ /** Whether `raw` is a valid uncompressed P-256 public key: 0x04 ‖ X ‖ Y, both below p, on the curve. */
52
+ export function isValidP256Key(raw) {
53
+ if (raw.length !== 65 || raw[0] !== 0x04) return false;
54
+ const x = toBig(raw.subarray(1, 33));
55
+ const y = toBig(raw.subarray(33, 65));
56
+ if (x >= P || y >= P) return false;
57
+ const left = (y * y) % P;
58
+ const right = (((x * x) % P) * x - 3n * x + B) % P;
59
+ return left === ((right % P) + P) % P;
60
+ }
61
+
62
+ // ---- WebAuthn Level 3 §7.2, offline ----
63
+
64
+ /** The credential's public key, validated before use: `{key}` or `{problem: "unusable-key" | "small-order-key"}`. */
65
+ export function credentialKey(publicKey, alg) {
66
+ if (!ASSERTION_ALGORITHMS.includes(alg)) return { problem: 'unusable-key' };
67
+ if (!isObject(publicKey) || publicKey.d !== undefined || !isString(publicKey.x) || !B64URL.test(publicKey.x) || !canonical(publicKey.x)) return { problem: 'unusable-key' };
68
+ if (alg === 'ES256') {
69
+ if (publicKey.kty !== 'EC' || publicKey.crv !== 'P-256' || !isString(publicKey.y) || !B64URL.test(publicKey.y) || !canonical(publicKey.y)) return { problem: 'unusable-key' };
70
+ const x = bytes(publicKey.x);
71
+ const y = bytes(publicKey.y);
72
+ if (x.length !== 32 || y.length !== 32 || !isValidP256Key(Buffer.concat([Buffer.from([4]), x, y]))) return { problem: 'unusable-key' };
73
+ return { key: crypto.createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: publicKey.x, y: publicKey.y }, format: 'jwk' }) };
74
+ }
75
+ if (publicKey.kty !== 'OKP' || publicKey.crv !== 'Ed25519') return { problem: 'unusable-key' };
76
+ const raw = bytes(publicKey.x);
77
+ if (raw.length !== 32) return { problem: 'unusable-key' };
78
+ // DEC-0045 (CT-29): a small-order key verifies forged signatures on Node 20 and 22; it is refused before any use.
79
+ if (smallOrder(raw)) return { problem: 'small-order-key' };
80
+ return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: publicKey.x }, format: 'jwk' }) };
81
+ }
82
+
83
+ /**
84
+ * Verify a passkey assertion under a credential the caller pinned (`credentialId`, `alg`, `publicKey`, `rpId`,
85
+ * `origins`) for the expected challenge bytes: `{ok: true, flags, signCount}` or `{ok: false, problem}`.
86
+ */
87
+ export function verifyAssertion(assertion, credential, challenge) {
88
+ if (!isObject(assertion)) return { ok: false, problem: 'malformed' };
89
+ const { credentialId, alg, authenticatorData, clientDataJSON, sig } = assertion;
90
+ for (const field of [credentialId, alg, authenticatorData, clientDataJSON, sig]) if (!isString(field)) return { ok: false, problem: 'malformed' };
91
+ if (![credentialId, authenticatorData, clientDataJSON, sig].every((field) => B64URL.test(field))) return { ok: false, problem: 'malformed' };
92
+ if (credentialId !== credential.credentialId) return { ok: false, problem: 'malformed' };
93
+ if (!ASSERTION_ALGORITHMS.includes(alg)) return { ok: false, problem: 'unknown-algorithm' };
94
+ if (alg !== credential.alg) return { ok: false, problem: 'key-algorithm-mismatch' };
95
+ const found = credentialKey(credential.publicKey, alg);
96
+ if ('problem' in found) return { ok: false, problem: found.problem };
97
+
98
+ const cData = bytes(clientDataJSON);
99
+ let client;
100
+ try {
101
+ client = JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(cData));
102
+ } catch {
103
+ return { ok: false, problem: 'malformed' };
104
+ }
105
+ if (!isObject(client)) return { ok: false, problem: 'malformed' };
106
+ if (client.type !== 'webauthn.get') return { ok: false, problem: 'wrong-type' };
107
+ if (client.challenge !== Buffer.from(challenge).toString('base64url')) return { ok: false, problem: 'wrong-challenge' };
108
+ if (!isString(client.origin) || !credential.origins.includes(client.origin)) return { ok: false, problem: 'wrong-origin' };
109
+ if (client.crossOrigin === true || client.topOrigin !== undefined) return { ok: false, problem: 'cross-origin' };
110
+
111
+ const authData = bytes(authenticatorData);
112
+ if (authData.length < 37) return { ok: false, problem: 'malformed' };
113
+ if (!authData.subarray(0, 32).equals(sha256(Buffer.from(credential.rpId, 'utf8')))) return { ok: false, problem: 'wrong-rp' };
114
+ const flags = authData[32];
115
+ if ((flags & FLAGS.AT) !== 0 || ((flags & FLAGS.BS) !== 0 && (flags & FLAGS.BE) === 0)) return { ok: false, problem: 'bad-flags' };
116
+ if ((flags & FLAGS.UP) === 0) return { ok: false, problem: 'no-user-presence' };
117
+ if ((flags & FLAGS.UV) === 0) return { ok: false, problem: 'no-user-verification' };
118
+
119
+ const signed = Buffer.concat([authData, sha256(cData)]);
120
+ const signature = bytes(sig);
121
+ let good;
122
+ try {
123
+ good = alg === 'ES256'
124
+ ? crypto.verify('sha256', signed, { key: found.key, dsaEncoding: 'der' }, signature)
125
+ : signature.length === 64 && crypto.verify(null, signed, found.key, signature);
126
+ } catch {
127
+ good = false;
128
+ }
129
+ if (!good) return { ok: false, problem: 'bad-signature' };
130
+ return { ok: true, flags, signCount: authData.readUInt32BE(33) };
131
+ }
132
+
133
+ // ---- the record ----
134
+
135
+ /** `sha256:` and the hex SHA-256 of the UTF-8 RFC 8785 form of the record without `digest` and `signature`. */
136
+ export function estateRecordDigest(record) {
137
+ const { digest: _digest, signature: _signature, ...covered } = record;
138
+ return `sha256:${sha256(Buffer.from(jcs(covered), 'utf8')).toString('hex')}`;
139
+ }
140
+
141
+ export const estateChallengeInput = (digest) => Buffer.from(jcs({ type: ESTATE_CHALLENGE_TYPE, digest }), 'utf8');
142
+ /** The WebAuthn challenge a signer passes to navigator.credentials.get(): SHA-256 of estateChallengeInput. */
143
+ export const estateChallenge = (digest) => sha256(estateChallengeInput(digest));
144
+
145
+ const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
146
+ /** An RFC 3339 date-time as milliseconds, or NaN: `T`, an offset and a day that exists; a space and `:60` refused (ER-10). */
147
+ export function estateTime(value) {
148
+ const match = isString(value) ? RFC3339.exec(value) : null;
149
+ if (!match) return Number.NaN;
150
+ const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
151
+ const date = new Date(0);
152
+ date.setUTCFullYear(year, month - 1, day);
153
+ if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return Number.NaN;
154
+ return Date.parse(value);
155
+ }
156
+ const revocationKey = (entry) => `${String(entry.kind)} ${String(entry.ref)}`;
157
+ const clockOf = (at) => (at instanceof Date ? at.getTime() : (typeof at === 'number' && Number.isFinite(at) ? at * 1000 : Number.NaN));
158
+
159
+ /**
160
+ * The reference verifier, in the order of estate-record.md's ER table. `at` is the clock (a Date, or epoch seconds); pass
161
+ * the verified `parent`, or for revision 1 the `authorities` the machine pinned at enrollment.
162
+ */
163
+ export function verifyEstateRecord(record, trust) {
164
+ const at = isObject(trust) ? clockOf(trust.at) : Number.NaN;
165
+ if (!Number.isFinite(at)) return { ok: false, problem: 'no-clock' };
166
+ if (!isObject(record) || !isString(record.digest) || !Number.isSafeInteger(record.revision) || !isObject(record.signature)) return { ok: false, problem: 'malformed' };
167
+ const signature = record.signature;
168
+ if (signature.format !== 'webauthn-assertion' || !isString(signature.credentialId)) return { ok: false, problem: 'malformed' };
169
+ let digest;
170
+ try {
171
+ digest = estateRecordDigest(record);
172
+ } catch {
173
+ return { ok: false, problem: 'malformed' };
174
+ }
175
+ if (digest !== record.digest) return { ok: false, problem: 'digest-mismatch' };
176
+
177
+ let trusted;
178
+ let revoked = new Set();
179
+ if (trust.parent !== undefined) {
180
+ const parent = trust.parent;
181
+ if (!isObject(parent)) return { ok: false, problem: 'broken-chain' };
182
+ let parentDigest;
183
+ try {
184
+ parentDigest = estateRecordDigest(parent);
185
+ } catch {
186
+ return { ok: false, problem: 'broken-chain' };
187
+ }
188
+ if (parent.estate !== record.estate || record.revision !== Number(parent.revision) + 1 || record.parent !== parentDigest) return { ok: false, problem: 'broken-chain' };
189
+ if (estateTime(record.issuedAt) < estateTime(parent.issuedAt)) return { ok: false, problem: 'broken-chain' };
190
+ const kept = new Set(list(record.revocations).map(revocationKey));
191
+ if (list(parent.revocations).some((entry) => !kept.has(revocationKey(entry)))) return { ok: false, problem: 'broken-chain' };
192
+ // A passkey the parent revokes signs nothing after it; a revision may still revoke its own signer (rotation).
193
+ revoked = new Set(list(parent.revocations).filter((entry) => entry.kind === 'authority').map((entry) => entry.ref));
194
+ trusted = list(parent.authorities);
195
+ } else {
196
+ if (record.revision !== 1 || record.parent !== undefined) return { ok: false, problem: 'broken-chain' };
197
+ trusted = Array.isArray(trust.authorities) ? trust.authorities : [];
198
+ }
199
+ if (trusted.length === 0) return { ok: false, problem: 'no-trusted-authority' };
200
+ const matches = trusted.filter((entry) => isObject(entry) && entry.credentialId === signature.credentialId);
201
+ if (matches.length > 1) return { ok: false, problem: 'duplicate-authority' };
202
+ const authority = matches[0];
203
+ if (!authority) return { ok: false, problem: 'unknown-authority' };
204
+ if (revoked.has(authority.credentialId)) return { ok: false, problem: 'revoked-authority' };
205
+ const credential = {
206
+ credentialId: String(authority.credentialId), alg: String(authority.alg), publicKey: isObject(authority.publicKey) ? authority.publicKey : {},
207
+ rpId: String(authority.rpId), origins: Array.isArray(authority.origins) ? authority.origins.filter(isString) : [],
208
+ };
209
+ const assertion = verifyAssertion(signature, credential, estateChallenge(digest));
210
+ if (!assertion.ok) return { ok: false, problem: assertion.problem };
211
+
212
+ const issuedAt = estateTime(record.issuedAt);
213
+ const validUntil = estateTime(record.validUntil);
214
+ if (!Number.isFinite(issuedAt) || !Number.isFinite(validUntil) || validUntil <= issuedAt || validUntil - issuedAt > MAX_VALIDITY_SECONDS * 1000) return { ok: false, problem: 'too-long-lived' };
215
+ if (at < issuedAt) return { ok: false, problem: 'not-yet-valid' };
216
+ if (at > validUntil) return { ok: false, problem: 'expired' };
217
+ const codes = [...new Set(recordFindings(record).map((finding) => finding.code))];
218
+ if (codes.length > 0) return { ok: false, problem: 'rule-violation', codes };
219
+ return { ok: true, digest, revision: record.revision, signedBy: credential.credentialId, signCount: assertion.signCount };
220
+ }
221
+
222
+ /** What a package verifier trusts for a package placed on a machine; undefined when the record places no such agent there. */
223
+ export function placementTrust(record, machineId, agentRef) {
224
+ const machine = list(record.machines).find((entry) => entry.id === machineId);
225
+ const placement = list(machine?.packages).find((entry) => entry.agentRef === agentRef);
226
+ if (!placement) return undefined;
227
+ return { publisher: list(record.publisherKeys).filter((key) => key.kid === placement.publisherKid) };
228
+ }
229
+
230
+ /**
231
+ * DL-14 with DEC-0055: trust a token's `kid` only while a current revision certifies it for a machine and does not revoke
232
+ * it. `record` is a revision the caller verified; `x` the key the published key set gives for `kid`; `at` the clock.
233
+ */
234
+ export function hubKeyCertified(record, kid, x, at) {
235
+ const now = clockOf(at);
236
+ if (!isObject(record) || !Number.isFinite(now)) return false;
237
+ const issuedAt = estateTime(record.issuedAt);
238
+ const validUntil = estateTime(record.validUntil);
239
+ if (!(Number.isFinite(issuedAt) && Number.isFinite(validUntil) && issuedAt <= now && now <= validUntil)) return false;
240
+ const revoked = new Set(list(record.revocations).map(revocationKey));
241
+ return list(record.machines).some((machine) => isObject(machine.hubKey) && machine.hubKey.kid === kid && machine.hubKey.x === x
242
+ && !revoked.has(`hub-key ${kid}`) && !revoked.has(`machine ${String(machine.id)}`));
243
+ }
244
+
245
+ // ---- FAC-SEM-072…076 ----
246
+
247
+ const flagger = (findings, code) => (instancePath, message) => findings.push({ code, instancePath, message });
248
+
249
+ function revisionWhole(value, flag) {
250
+ try {
251
+ const digest = estateRecordDigest(value);
252
+ if (digest !== value.digest) flag('/digest', `the digest is ${digest}: the record changed after it was drafted, or was hashed over another form than RFC 8785`);
253
+ } catch {
254
+ flag('/digest', 'the record has no RFC 8785 form, so it has no digest');
255
+ }
256
+ const issuedAt = estateTime(value.issuedAt);
257
+ const validUntil = estateTime(value.validUntil);
258
+ if (!Number.isFinite(issuedAt) || !Number.isFinite(validUntil)) {
259
+ flag(Number.isFinite(issuedAt) ? '/validUntil' : '/issuedAt', 'issuedAt and validUntil are RFC 3339 date-times with an offset, on days that exist');
260
+ } else if (validUntil <= issuedAt || validUntil - issuedAt > MAX_VALIDITY_SECONDS * 1000) {
261
+ flag('/validUntil', 'an estate record is valid after it is issued and for at most 30 days');
262
+ }
263
+ }
264
+
265
+ function revisionChain(value) {
266
+ const findings = [];
267
+ const flag = flagger(findings, 'FAC-SEM-072');
268
+ const parent = isObject(value.parent) ? value.parent : {};
269
+ const next = isObject(value.next) ? value.next : {};
270
+ if (next.estate !== parent.estate) flag('/next/estate', "a revision continues its parent's estate");
271
+ if (next.revision !== Number(parent.revision) + 1) flag('/next/revision', `the revision after ${String(parent.revision)} is ${Number(parent.revision) + 1}: a revision never skips or repeats one`);
272
+ let parentDigest;
273
+ try {
274
+ parentDigest = estateRecordDigest(parent);
275
+ } catch {
276
+ parentDigest = undefined;
277
+ }
278
+ if (next.parent !== parentDigest) flag('/next/parent', 'parent names the digest of the revision before it');
279
+ if (estateTime(next.issuedAt) < estateTime(parent.issuedAt)) flag('/next/issuedAt', 'a revision is never issued before its parent');
280
+ const kept = new Set(list(next.revocations).map(revocationKey));
281
+ list(parent.revocations).forEach((entry, index) => {
282
+ if (!kept.has(revocationKey(entry))) flag(`/parent/revocations/${index}`, `the ${String(entry.kind)} revocation of ${String(entry.ref)} is dropped: a revision keeps every revocation before it`);
283
+ });
284
+ return findings;
285
+ }
286
+
287
+ const validDeviceKey = (key) => {
288
+ const raw = Buffer.from(String(key.publicKey ?? ''), 'base64');
289
+ return key.alg === 'ed25519' ? raw.length === 32 && !smallOrder(raw) : isValidP256Key(raw);
290
+ };
291
+
292
+ function keys(value, flag) {
293
+ const ids = new Set();
294
+ const hubKids = new Set();
295
+ const hubKeys = new Set();
296
+ list(value.machines).forEach((machine, m) => {
297
+ const at = `/machines/${m}`;
298
+ if (ids.has(machine.id)) flag(`${at}/id`, `machine ${String(machine.id)} is listed twice`);
299
+ ids.add(machine.id);
300
+ const deviceKeys = list(machine.deviceKeys);
301
+ if (deviceKeys.filter((key) => key.use === 'sig').length !== 1) flag(`${at}/deviceKeys`, 'a machine has exactly one signing device key, <id>#sig');
302
+ if (deviceKeys.filter((key) => key.use === 'kem').length > 1) flag(`${at}/deviceKeys`, 'a machine has at most one key-agreement device key, <id>#kem');
303
+ const seen = new Set();
304
+ deviceKeys.forEach((key, k) => {
305
+ if (key.kid !== `${String(machine.id)}#${String(key.use)}`) flag(`${at}/deviceKeys/${k}/kid`, 'a device key is listed as <machine id>#sig or <machine id>#kem, by its role');
306
+ if (!validDeviceKey(key)) flag(`${at}/deviceKeys/${k}/publicKey`, 'an Ed25519 key is never of small order (DEC-0045) and a P-256 key is a valid point (SEC 1 §3.2.2)');
307
+ if (seen.has(key.publicKey)) flag(`${at}/deviceKeys/${k}/publicKey`, 'one key is never used for the other role');
308
+ seen.add(key.publicKey);
309
+ });
310
+ if (isObject(machine.hubKey)) {
311
+ const hub = machine.hubKey;
312
+ if (hubKids.has(hub.kid)) flag(`${at}/hubKey/kid`, `hub key ${String(hub.kid)} is certified for two machines: one kid per machine`);
313
+ hubKids.add(hub.kid);
314
+ if (hubKeys.has(hub.x)) flag(`${at}/hubKey/x`, 'one hub public key is certified for two machines under two kids: one key per machine');
315
+ hubKeys.add(hub.x);
316
+ if (smallOrder(Buffer.from(String(hub.x ?? ''), 'base64url'))) flag(`${at}/hubKey/x`, 'a hub key of small order verifies forged tokens and is never certified (DEC-0045)');
317
+ }
318
+ });
319
+ const credentials = new Set();
320
+ list(value.authorities).forEach((authority, index) => {
321
+ if (credentials.has(authority.credentialId)) flag(`/authorities/${index}/credentialId`, 'a passkey is listed once');
322
+ credentials.add(authority.credentialId);
323
+ if ('problem' in credentialKey(authority.publicKey, String(authority.alg))) flag(`/authorities/${index}/publicKey`, "a passkey's public key is a valid P-256 point for ES256 or an Ed25519 key of full order for EdDSA");
324
+ });
325
+ }
326
+
327
+ function placements(value, flag) {
328
+ const kids = new Set();
329
+ list(value.publisherKeys).forEach((key, index) => {
330
+ if (kids.has(key.kid)) flag(`/publisherKeys/${index}/kid`, `publisher key ${String(key.kid)} is pinned twice`);
331
+ kids.add(key.kid);
332
+ });
333
+ list(value.machines).forEach((machine, m) => {
334
+ const agents = new Set();
335
+ list(machine.packages).forEach((placement, p) => {
336
+ const at = `/machines/${m}/packages/${p}`;
337
+ if (agents.has(placement.agentRef)) flag(`${at}/agentRef`, `${String(placement.agentRef)} is placed twice on one machine`);
338
+ agents.add(placement.agentRef);
339
+ if (!kids.has(placement.publisherKid)) flag(`${at}/publisherKid`, `publisher key ${String(placement.publisherKid)} is not in publisherKeys, so the machine pins no key to verify the package under`);
340
+ });
341
+ });
342
+ }
343
+
344
+ function secretReferences(value, flag) {
345
+ const machineIds = new Set(list(value.machines).map((machine) => machine.id));
346
+ list(value.machines).forEach((machine, m) => {
347
+ const placed = new Set(list(machine.packages).map((placement) => placement.agentRef));
348
+ const slots = new Set();
349
+ list(machine.secrets).forEach((secret, s) => {
350
+ const at = `/machines/${m}/secrets/${s}`;
351
+ for (const field of ['slot', 'from', 'agentRef']) {
352
+ const text = secret[field];
353
+ if (isString(text) && secretValue(text)) flag(`${at}/${field}`, 'a secret placement names a slot, never a value: this looks like a credential');
354
+ }
355
+ if (!placed.has(secret.agentRef)) flag(`${at}/agentRef`, 'a secret goes only where a placed package declares it (v4 Path 2): this agent is not placed on this machine');
356
+ if (!machineIds.has(secret.from)) flag(`${at}/from`, 'the slot is filled from a machine of this estate');
357
+ const key = `${String(secret.agentRef)} ${String(secret.slot)}`;
358
+ if (slots.has(key)) flag(`${at}/slot`, `slot ${String(secret.slot)} is placed twice for one agent on one machine`);
359
+ slots.add(key);
360
+ });
361
+ });
362
+ }
363
+
364
+ function grantsAndRevocations(value, flag) {
365
+ const grantIds = new Set();
366
+ list(value.shareGrants).forEach((grant, index) => {
367
+ if (grant.principalKind !== 'external') flag(`/shareGrants/${index}/principalKind`, 'a share grant is to a principal outside the estate: principalKind external');
368
+ const id = isObject(grant.meta) ? grant.meta.id : undefined;
369
+ if (grantIds.has(id)) flag(`/shareGrants/${index}/meta/id`, `grant ${String(id)} is listed twice`);
370
+ grantIds.add(id);
371
+ });
372
+ const machines = list(value.machines);
373
+ // A Map, so a kind such as `constructor` or `__proto__` reads nothing from Object.prototype.
374
+ const listed = new Map(Object.entries({
375
+ authority: new Set(list(value.authorities).map((entry) => entry.credentialId)),
376
+ machine: new Set(machines.map((machine) => machine.id)),
377
+ 'device-key': new Set(machines.flatMap((machine) => list(machine.deviceKeys).map((key) => key.kid))),
378
+ 'hub-key': new Set(machines.flatMap((machine) => (isObject(machine.hubKey) ? [machine.hubKey.kid] : []))),
379
+ 'publisher-key': new Set(list(value.publisherKeys).map((key) => key.kid)),
380
+ package: new Set(machines.flatMap((machine) => list(machine.packages).map((placement) => placement.digest))),
381
+ grant: grantIds,
382
+ }));
383
+ const seen = new Set();
384
+ list(value.revocations).forEach((revocation, index) => {
385
+ const key = `${String(revocation.kind)} ${String(revocation.ref)}`;
386
+ if (seen.has(key)) flag(`/revocations/${index}`, `the ${String(revocation.kind)} ${String(revocation.ref)} is revoked twice`);
387
+ seen.add(key);
388
+ if (listed.get(String(revocation.kind))?.has(revocation.ref)) flag(`/revocations/${index}/ref`, `the record still lists the ${String(revocation.kind)} it revokes: revocation outranks addition`);
389
+ });
390
+ }
391
+
392
+ function recordFindings(value) {
393
+ const findings = [];
394
+ revisionWhole(value, flagger(findings, 'FAC-SEM-072'));
395
+ keys(value, flagger(findings, 'FAC-SEM-073'));
396
+ placements(value, flagger(findings, 'FAC-SEM-074'));
397
+ secretReferences(value, flagger(findings, 'FAC-SEM-075'));
398
+ grantsAndRevocations(value, flagger(findings, 'FAC-SEM-076'));
399
+ return findings;
400
+ }
401
+
402
+ function packageDigest(pkg) {
403
+ const { digest: _digest, signatures: _signatures, ...covered } = pkg;
404
+ return `sha256:${sha256(Buffer.from(jcs(covered), 'utf8')).toString('hex')}`;
405
+ }
406
+
407
+ function installation(value) {
408
+ const findings = [];
409
+ const record = isObject(value.record) ? value.record : {};
410
+ const pkg = isObject(value.package) ? value.package : {};
411
+ const machineIndex = list(record.machines).findIndex((entry) => entry.id === value.machine);
412
+ const machine = list(record.machines)[machineIndex];
413
+ let digest;
414
+ try {
415
+ digest = packageDigest(pkg);
416
+ } catch {
417
+ digest = undefined;
418
+ }
419
+ const flag074 = flagger(findings, 'FAC-SEM-074');
420
+ if (!machine) {
421
+ flag074('/machine', `machine ${String(value.machine)} is not in the estate record`);
422
+ return findings;
423
+ }
424
+ if (digest === undefined || digest !== pkg.digest) flag074('/package/digest', 'the package is not the one its digest names: it changed after it was packed');
425
+ const placement = list(machine.packages).find((entry) => entry.agentRef === pkg.agentRef);
426
+ if (!placement) flag074('/package/agentRef', `the record places no ${String(pkg.agentRef)} on machine ${String(value.machine)}`);
427
+ else if (placement.digest !== digest) flag074('/package', `the record places ${String(placement.digest)} of this agent on this machine, not this package`);
428
+ const declared = new Set(list(pkg.secrets).map((slot) => slot.name));
429
+ const flag075 = flagger(findings, 'FAC-SEM-075');
430
+ list(machine.secrets).forEach((secret, index) => {
431
+ if (secret.agentRef === pkg.agentRef && !declared.has(secret.slot)) flag075(`/record/machines/${machineIndex}/secrets/${index}/slot`, `the package declares no slot ${String(secret.slot)}: a secret goes only where a placed package declares it`);
432
+ });
433
+ return findings;
434
+ }
435
+
436
+ /** FAC-SEM-072…076 for an `estate-record`, an `estate-record-revision` `{parent, next}` or an `estate-placement`. */
437
+ export function estateRecordRules(kind, value) {
438
+ const doc = isObject(value) ? value : {};
439
+ if (kind === 'estate-record') return recordFindings(doc);
440
+ if (kind === 'estate-record-revision') return revisionChain(doc);
441
+ if (kind === 'estate-placement') return installation(doc);
442
+ return undefined;
443
+ }
@@ -866,6 +866,12 @@ function byCodePoint(a, b) {
866
866
  * strings escaped as JSON requires and nothing else, integers in decimal — Python's
867
867
  * `json.dumps(value, sort_keys=True, ensure_ascii=False)`. Refuses a fraction, an unsafe integer and non-JSON.
868
868
  */
869
+ /** CT-25 (DEC-0052): a lone surrogate in a string or a key has no canonical form — Python cannot encode it as UTF-8. */
870
+ function settingsString(text) {
871
+ if (/\p{Surrogate}/u.test(text)) throw new ServiceError('a lone surrogate has no canonical form');
872
+ return JSON.stringify(text);
873
+ }
874
+
869
875
  export function settingsCanonicalJson(value) {
870
876
  if (value === null) return 'null';
871
877
  if (typeof value === 'boolean') return value ? 'true' : 'false';
@@ -873,10 +879,10 @@ export function settingsCanonicalJson(value) {
873
879
  if (!Number.isSafeInteger(value)) throw new ServiceError(`${value} has no canonical form: a settings backup stores a fraction as its decimal string`);
874
880
  return String(value);
875
881
  }
876
- if (typeof value === 'string') return JSON.stringify(value);
882
+ if (typeof value === 'string') return settingsString(value);
877
883
  if (Array.isArray(value)) return `[${value.map(settingsCanonicalJson).join(', ')}]`;
878
884
  if (isPlainObject(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))) {
879
- return `{${Object.keys(value).sort(byCodePoint).map((key) => `${JSON.stringify(key)}: ${settingsCanonicalJson(value[key])}`).join(', ')}}`;
885
+ return `{${Object.keys(value).sort(byCodePoint).map((key) => `${settingsString(key)}: ${settingsCanonicalJson(value[key])}`).join(', ')}}`;
880
886
  }
881
887
  throw new ServiceError(`${typeof value} is not a JSON value`);
882
888
  }
@@ -432,11 +432,30 @@ def _approval_shape(payload: Dict[str, Any]) -> bool:
432
432
  return payload.get("method") in ("prompt", "operator-channel") # DEC-0045: `device` is removed
433
433
 
434
434
 
435
+ def _estate_problem(estate: Any, jwks: Dict[str, Any], kid: str, now: float) -> Optional[Tuple[str, str]]:
436
+ """DL-14 with DEC-0055: a verifier that reads the estate record trusts a kid only while a current revision certifies
437
+ it for a machine and does not revoke it. `estate` is a revision the caller verified (fabric_estate.
438
+ verify_estate_record) or a callable returning the current one; None skips the rule. (refusal kind, detail) or None."""
439
+ if estate is None:
440
+ return None
441
+ try:
442
+ record = estate() if callable(estate) else estate
443
+ except Exception: # an unreadable record certifies nothing; it is never a pass
444
+ return ("issuer", "estate-unreadable")
445
+ import fabric_estate # beside this file; imported here, since fabric_estate imports this module
446
+ jwk = next((k for k in jwks["keys"] if isinstance(k, dict) and k.get("kid") == kid), {})
447
+ if not fabric_estate.hub_key_certified(record, kid, jwk.get("x"), now):
448
+ return ("token", "kid-not-certified")
449
+ return None
450
+
451
+
435
452
  def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capability: str, effect: str,
436
- issuer: Optional[str], read_only: bool = False) -> Dict[str, Any]:
453
+ issuer: Optional[str], read_only: bool = False, estate: Any = None) -> Dict[str, Any]:
437
454
  """DL-13, in its fixed order: nothing about a token is believed before its signature is.
438
455
  `now` is seconds since the epoch. Returns {ok: True, claims, principal, caller, chain, kid} or a refusal
439
- {ok: False, code, detail[, data]} whose detail is a short machine word, never the token. `issuer` is required."""
456
+ {ok: False, code, detail[, data]} whose detail is a short machine word, never the token. `issuer` is required.
457
+ With `estate` (a verified estate revision, or a callable returning the current one), the token's kid must be one
458
+ the revision certifies for a machine (DL-14, DEC-0055)."""
440
459
  if not _is_string(issuer):
441
460
  return _refuse("issuer-unavailable", "no-trusted-issuer")
442
461
  if not _has_keys(jwks):
@@ -444,6 +463,9 @@ def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capab
444
463
  checked = _authentic(token, jwks, DELEGATION_TYP)
445
464
  if "problem" in checked:
446
465
  return _refuse("issuer-unavailable" if checked.get("issuer") else "unauthenticated", checked["problem"])
466
+ uncertified = _estate_problem(estate, jwks, checked["kid"], now)
467
+ if uncertified:
468
+ return _refuse("issuer-unavailable" if uncertified[0] == "issuer" else "unauthenticated", uncertified[1])
447
469
  claims = checked["compact"]["payload"]
448
470
  problem, depth = _delegation_shape(claims)
449
471
  if problem:
@@ -543,7 +565,7 @@ UNSAFE_NO_JTI_STORE = _NoReplayProtection()
543
565
 
544
566
 
545
567
  def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: float, action_digest: str,
546
- jti_store: Any, issuer: Optional[str]) -> Dict[str, Any]:
568
+ jti_store: Any, issuer: Optional[str], estate: Any = None) -> Dict[str, Any]:
547
569
  """DL-18: a missing approval is approval-required carrying the action digest; any fault of the document is
548
570
  approval-invalid; a second use is approval-reused. The jti is consumed last."""
549
571
  if approval is None or approval == "":
@@ -557,6 +579,9 @@ def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: f
557
579
  checked = _authentic(approval, jwks, APPROVAL_TYP)
558
580
  if "problem" in checked:
559
581
  return _refuse("issuer-unavailable" if checked.get("issuer") else "approval-invalid", checked["problem"])
582
+ uncertified = _estate_problem(estate, jwks, checked["kid"], now)
583
+ if uncertified:
584
+ return _refuse("issuer-unavailable" if uncertified[0] == "issuer" else "approval-invalid", uncertified[1])
560
585
  claims = checked["compact"]["payload"]
561
586
  if not _approval_shape(claims):
562
587
  return _refuse("approval-invalid", "malformed-claims")
@@ -795,7 +820,7 @@ TIERS = ("T0", "T1", "T2")
795
820
 
796
821
 
797
822
  def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], issuer: str,
798
- read_only: bool = False, clock: Callable[[], float] = time.time) -> Callable[..., Dict[str, Any]]:
823
+ read_only: bool = False, clock: Callable[[], float] = time.time, estate: Any = None) -> Callable[..., Dict[str, Any]]:
799
824
  """A check for one tools/call: `gate(tool, headers, tier="T2")` answers the accepted delegation or a DL-19
800
825
  refusal. `effects` maps each tool to its declared effect class (its manifest entry); a tool with none is
801
826
  refused rather than guessed. `jwks` is a JwksCache or a key set; `issuer` (auth.delegation.issuer) is required.
@@ -804,7 +829,8 @@ def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], iss
804
829
  own local floor has authenticated; T1 an enrolled machine of the same owner; T2 everything else, and any call
805
830
  relayed from off the machine. A T0 or T1 caller is never required to present a token: with none, the call
806
831
  passes with no delegation ({ok: True, tier, principal: None, ...}). A present token is verified at every tier
807
- and refused when invalid. Unknown tiers count as T2."""
832
+ and refused when invalid. Unknown tiers count as T2. `estate` (a callable returning the current verified estate
833
+ revision) makes the gate trust only kids that revision certifies (DL-14, DEC-0055)."""
808
834
  if not _is_string(issuer):
809
835
  raise ValueError("the gate needs the issuer it trusts (auth.delegation.issuer)")
810
836
 
@@ -819,7 +845,7 @@ def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], iss
819
845
  return _refuse("unauthenticated", "no-token")
820
846
  keys = jwks.get(_kid_of(carried["token"])) if isinstance(jwks, JwksCache) else jwks
821
847
  verdict = verify_delegation(carried["token"], audience=audience, jwks=keys, now=clock(), capability=tool,
822
- effect=effect, issuer=issuer, read_only=read_only)
848
+ effect=effect, issuer=issuer, read_only=read_only, estate=estate)
823
849
  if verdict["ok"]:
824
850
  verdict["tier"] = tier if tier in TIERS else "T2"
825
851
  return verdict