@passioncode-ai/passioncode 0.2.41 → 0.2.43
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 +18 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +85 -13
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +22 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +44 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +48 -9
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +7 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +30 -15
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +33 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +146 -7
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +5 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +17 -9
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate-rules.mjs +606 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +48 -525
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +43 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +48 -15
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +4 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +39 -16
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +34 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +149 -45
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +8 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +86 -642
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate_rules.py +766 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +70 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +59 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +56 -16
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +314 -25
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +13 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +75 -36
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +28 -14
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +42 -12
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +12 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +12 -2
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +360 -16
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
// `estate-record/1` (Fabric Agent Contract DEC-0055): the record's own rules, without a verifier; the twin of
|
|
2
|
+
// fabric_estate_rules.py. The contract's src/estate-record-rules.ts at the pin, held to the contract's own vectors
|
|
3
|
+
// (test/fixtures/contract/estate-vectors.json). Split from fabric-estate.mjs (0.52.0, decision D8) so each module stays
|
|
4
|
+
// under the kit's line ceiling; fabric-estate.mjs re-exports every public name here.
|
|
5
|
+
//
|
|
6
|
+
// - estateRecordRules(kind, value): FAC-SEM-072…076, with FAC-SEM-092, 110, 111, 114, 122, 134, 137 and FAC-SEM-147
|
|
7
|
+
// (DEC-0127, DEC-0128: the organizations a record pins, their context issuers and the context hub keys of its machines);
|
|
8
|
+
// - estateRecordDigest, estateChallengeInput, estateChallenge: what a passkey signs;
|
|
9
|
+
// - parseContext: the personal context or an organization's `org:<orgId>` (DEC-0082);
|
|
10
|
+
// - credentialKey: whether a passkey's public key is usable (FAC-SEM-073 reads it; the assertion verifier uses it);
|
|
11
|
+
// - machineProfile, deltaAnchorOf, deviceKeyRef, grantContextFindings, placementGrantFindings.
|
|
12
|
+
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import { jcs } from './fabric-jsvalue.mjs';
|
|
15
|
+
import { normalIdentifier } from './fabric-identifiers.mjs';
|
|
16
|
+
import { secretValue } from './fabric-secrets.mjs';
|
|
17
|
+
import { isObject, isString, objects as list, indexed, show, toNumber, keyOf } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
|
|
18
|
+
import { sha256, isSmallOrderOrMalformed as smallOrder, isValidP256Key } from './fabric-crypto.mjs'; // the core's (FAA-65)
|
|
19
|
+
import { parseRfc3339Ms as estateTime } from './fabric-time.mjs'; // the core's (FAA-65)
|
|
20
|
+
|
|
21
|
+
export const ESTATE_RECORD_SCHEMA = 'estate-record/1';
|
|
22
|
+
export const ESTATE_CHALLENGE_TYPE = 'estate-revision/1';
|
|
23
|
+
export const MAX_VALIDITY_SECONDS = 30 * 24 * 60 * 60;
|
|
24
|
+
export const ASSERTION_ALGORITHMS = Object.freeze(['ES256', 'EdDSA']);
|
|
25
|
+
|
|
26
|
+
/** DEC-0082: the personal context, and an organization's context `org:<orgId>` (the contract's `context-route.ts`). */
|
|
27
|
+
export const PERSONAL_CONTEXT = 'personal';
|
|
28
|
+
export const ORG_CONTEXT_PREFIX = 'org:';
|
|
29
|
+
const ORG_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
30
|
+
/** The context `value` names, or undefined: `personal` exactly, or `org:` and an organization id; compared as written. */
|
|
31
|
+
export function parseContext(value) {
|
|
32
|
+
if (typeof value !== 'string') return undefined;
|
|
33
|
+
if (value === PERSONAL_CONTEXT) return { kind: 'personal' };
|
|
34
|
+
if (!value.startsWith(ORG_CONTEXT_PREFIX)) return undefined;
|
|
35
|
+
const org = value.slice(ORG_CONTEXT_PREFIX.length);
|
|
36
|
+
return ORG_ID.test(org) ? { kind: 'org', org } : undefined;
|
|
37
|
+
}
|
|
38
|
+
/** A placement's or a secret placement's context: its `context`, `personal` when absent (DEC-0082). */
|
|
39
|
+
export const contextOf = (entry) => (entry.context === undefined ? PERSONAL_CONTEXT : entry.context);
|
|
40
|
+
export const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
41
|
+
const bytes = (text) => Buffer.from(text, 'base64url');
|
|
42
|
+
const canonical = (text) => Buffer.from(text, 'base64url').toString('base64url') === text;
|
|
43
|
+
|
|
44
|
+
// ---- a passkey's public key (src/webauthn.ts) ----
|
|
45
|
+
|
|
46
|
+
/** The credential's public key, validated before use: `{key}` or `{problem: "unusable-key" | "small-order-key"}`. */
|
|
47
|
+
export function credentialKey(publicKey, alg) {
|
|
48
|
+
if (!ASSERTION_ALGORITHMS.includes(alg)) return { problem: 'unusable-key' };
|
|
49
|
+
if (!isObject(publicKey) || publicKey.d !== undefined || !isString(publicKey.x) || !B64URL.test(publicKey.x) || !canonical(publicKey.x)) return { problem: 'unusable-key' };
|
|
50
|
+
if (alg === 'ES256') {
|
|
51
|
+
if (publicKey.kty !== 'EC' || publicKey.crv !== 'P-256' || !isString(publicKey.y) || !B64URL.test(publicKey.y) || !canonical(publicKey.y)) return { problem: 'unusable-key' };
|
|
52
|
+
const x = bytes(publicKey.x);
|
|
53
|
+
const y = bytes(publicKey.y);
|
|
54
|
+
if (x.length !== 32 || y.length !== 32 || !isValidP256Key(Buffer.concat([Buffer.from([4]), x, y]))) return { problem: 'unusable-key' };
|
|
55
|
+
return { key: crypto.createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: publicKey.x, y: publicKey.y }, format: 'jwk' }) };
|
|
56
|
+
}
|
|
57
|
+
if (publicKey.kty !== 'OKP' || publicKey.crv !== 'Ed25519') return { problem: 'unusable-key' };
|
|
58
|
+
const raw = bytes(publicKey.x);
|
|
59
|
+
if (raw.length !== 32) return { problem: 'unusable-key' };
|
|
60
|
+
// DEC-0045 (CT-29): a small-order key verifies forged signatures on Node 20 and 22; it is refused before any use.
|
|
61
|
+
if (smallOrder(raw)) return { problem: 'small-order-key' };
|
|
62
|
+
return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: publicKey.x }, format: 'jwk' }) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
// ---- the record ----
|
|
67
|
+
|
|
68
|
+
/** `sha256:` and the hex SHA-256 of the UTF-8 RFC 8785 form of the record without `digest` and `signature`. */
|
|
69
|
+
export function estateRecordDigest(record) {
|
|
70
|
+
const { digest: _digest, signature: _signature, ...covered } = record;
|
|
71
|
+
return `sha256:${sha256(Buffer.from(jcs(covered), 'utf8')).toString('hex')}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const estateChallengeInput = (digest) => Buffer.from(jcs({ type: ESTATE_CHALLENGE_TYPE, digest }), 'utf8');
|
|
75
|
+
/** The WebAuthn challenge a signer passes to navigator.credentials.get(): SHA-256 of estateChallengeInput. */
|
|
76
|
+
export const estateChallenge = (digest) => sha256(estateChallengeInput(digest));
|
|
77
|
+
|
|
78
|
+
const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
79
|
+
/** `sign.` and at least two more labels, lower case: a signing RP ID is never a bare `sign.<tld>` (DEC-0063). */
|
|
80
|
+
const SIGNING_RP = new RegExp(`^sign(?:\\.${LABEL}){2,}$`);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether an RP ID is a dedicated signing RP (v4 §7, DEC-0063): first label `sign`, then the organization's or
|
|
84
|
+
* PassionCode's domain, lower case. A credential under any other RP ID is a sign-in credential and never signs.
|
|
85
|
+
*/
|
|
86
|
+
export const isSigningRpId = (rpId) => isString(rpId) && rpId.length <= 253 && SIGNING_RP.test(rpId);
|
|
87
|
+
|
|
88
|
+
/** Whether a pinned origin may run a signing ceremony for `rpId`: https, no port, no path, the RP ID or a host under it. */
|
|
89
|
+
export function originWithin(origin, rpId) {
|
|
90
|
+
const host = isString(origin) ? /^https:\/\/([a-z0-9.-]{1,253})$/.exec(origin)?.[1] : undefined;
|
|
91
|
+
return host !== undefined && (host === rpId || host.endsWith(`.${rpId}`));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** FAC-SEM-092 on one signing credential: a signing RP ID, and every pinned origin within it. */
|
|
95
|
+
export function signingCredential(credential, path, flag) {
|
|
96
|
+
const value = isObject(credential) ? credential : {};
|
|
97
|
+
if (!isSigningRpId(value.rpId)) {
|
|
98
|
+
flag(`${path}/rpId`, 'a passkey signs only under a dedicated signing RP, sign.<domain>, never under the RP ID it signs in with');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
(Array.isArray(value.origins) ? value.origins : []).forEach((origin, index) => {
|
|
102
|
+
if (!originWithin(origin, value.rpId)) flag(`${path}/origins/${index}`, `a signing ceremony runs only on ${value.rpId} or a host under it, over https without a port`);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const revocationKey = (entry) => `${String(entry.kind)} ${String(entry.ref)}`;
|
|
107
|
+
|
|
108
|
+
/** Whether a placement in `context` under `kid` may use that key (DEC-0090): an unscoped key anywhere, a scoped one only in its contexts. */
|
|
109
|
+
export function scopeAdmits(record, kid, context) {
|
|
110
|
+
const scopes = list(record.publisherScopes).filter((scope) => scope.kid === kid);
|
|
111
|
+
return scopes.length === 0 || scopes.some((scope) => Array.isArray(scope.contexts) && scope.contexts.includes(context));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- FAC-SEM-072…076 ----
|
|
115
|
+
|
|
116
|
+
const flagger = (findings, code) => (instancePath, message) => findings.push({ code, instancePath, message });
|
|
117
|
+
|
|
118
|
+
function revisionWhole(value, flag) {
|
|
119
|
+
try {
|
|
120
|
+
const digest = estateRecordDigest(value);
|
|
121
|
+
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`);
|
|
122
|
+
} catch {
|
|
123
|
+
flag('/digest', 'the record has no RFC 8785 form, so it has no digest');
|
|
124
|
+
}
|
|
125
|
+
const issuedAt = estateTime(value.issuedAt);
|
|
126
|
+
const validUntil = estateTime(value.validUntil);
|
|
127
|
+
if (!Number.isFinite(issuedAt) || !Number.isFinite(validUntil)) {
|
|
128
|
+
flag(Number.isFinite(issuedAt) ? '/validUntil' : '/issuedAt', 'issuedAt and validUntil are RFC 3339 date-times with an offset, on days that exist');
|
|
129
|
+
} else if (validUntil <= issuedAt || validUntil - issuedAt > MAX_VALIDITY_SECONDS * 1000) {
|
|
130
|
+
flag('/validUntil', 'an estate record is valid after it is issued and for at most 30 days');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function revisionChain(value) {
|
|
135
|
+
const findings = [];
|
|
136
|
+
const flag = flagger(findings, 'FAC-SEM-072');
|
|
137
|
+
const parent = isObject(value.parent) ? value.parent : {};
|
|
138
|
+
const next = isObject(value.next) ? value.next : {};
|
|
139
|
+
if (next.estate !== parent.estate) flag('/next/estate', "a revision continues its parent's estate");
|
|
140
|
+
if (next.revision !== toNumber(parent.revision) + 1) flag('/next/revision', `the revision after ${show(parent.revision)} is ${toNumber(parent.revision) + 1}: a revision never skips or repeats one`);
|
|
141
|
+
let parentDigest;
|
|
142
|
+
try {
|
|
143
|
+
parentDigest = estateRecordDigest(parent);
|
|
144
|
+
} catch {
|
|
145
|
+
parentDigest = undefined;
|
|
146
|
+
}
|
|
147
|
+
if (next.parent !== parentDigest) flag('/next/parent', 'parent names the digest of the revision before it');
|
|
148
|
+
if (estateTime(next.issuedAt) < estateTime(parent.issuedAt)) flag('/next/issuedAt', 'a revision is never issued before its parent');
|
|
149
|
+
const kept = new Set(list(next.revocations).map(revocationKey));
|
|
150
|
+
indexed(parent.revocations).forEach(([entry, index]) => {
|
|
151
|
+
if (!kept.has(revocationKey(entry))) flag(`/parent/revocations/${index}`, `the ${show(entry.kind)} revocation of ${show(entry.ref)} is dropped: a revision keeps every revocation before it`);
|
|
152
|
+
});
|
|
153
|
+
return findings;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Standard base64 with its padding, the one form DEC-0053 records a device key's octets in. */
|
|
157
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
158
|
+
/** DEC-0084: a key reference, a device-key kid, `@sha256:` and 64 lower-case hex digits. */
|
|
159
|
+
export const KEY_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}#(sig|kem)@sha256:[a-f0-9]{64}$/;
|
|
160
|
+
|
|
161
|
+
/** A device key's fingerprint (DEC-0084): `sha256:` and the hex SHA-256 of its canonical base64 octets, or undefined. */
|
|
162
|
+
export function deviceKeyFingerprint(key) {
|
|
163
|
+
const text = isObject(key) ? key.publicKey : undefined;
|
|
164
|
+
if (!isString(text) || text.length === 0 || !BASE64.test(text)) return undefined;
|
|
165
|
+
const raw = Buffer.from(text, 'base64');
|
|
166
|
+
if (raw.toString('base64') !== text) return undefined;
|
|
167
|
+
return `sha256:${sha256(raw).toString('hex')}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The `device-key` revocation reference of one key (DEC-0084): `<kid>@<fingerprint>`, or undefined. */
|
|
171
|
+
export function deviceKeyRef(key) {
|
|
172
|
+
const fingerprint = deviceKeyFingerprint(key);
|
|
173
|
+
const kid = isObject(key) ? key.kid : undefined;
|
|
174
|
+
return fingerprint !== undefined && isString(kid) ? `${kid}@${fingerprint}` : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const validDeviceKey = (key) => {
|
|
178
|
+
const raw = Buffer.from(show(key.publicKey ?? ''), 'base64');
|
|
179
|
+
return key.alg === 'ed25519' ? raw.length === 32 && !smallOrder(raw) : isValidP256Key(raw);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* FAC-SEM-073: every key is usable (DEC-0053) and named once. A context hub key (DEC-0128) is unique among every hub key
|
|
184
|
+
* of the record, of full order, once per context on its machine, in a pinned context, and held only by a machine that
|
|
185
|
+
* holds the personal hub key.
|
|
186
|
+
*/
|
|
187
|
+
function keys(value, flag) {
|
|
188
|
+
const pinned = new Set(list(value.organizations).map((pin) => pin.org));
|
|
189
|
+
const ids = new Set();
|
|
190
|
+
const hubKids = new Set();
|
|
191
|
+
const hubKeys = new Set();
|
|
192
|
+
indexed(value.machines).forEach(([machine, m]) => {
|
|
193
|
+
const at = `/machines/${m}`;
|
|
194
|
+
if (ids.has(machine.id)) flag(`${at}/id`, `machine ${show(machine.id)} is listed twice`);
|
|
195
|
+
ids.add(machine.id);
|
|
196
|
+
const deviceKeys = list(machine.deviceKeys);
|
|
197
|
+
if (deviceKeys.filter((key) => key.use === 'sig').length !== 1) flag(`${at}/deviceKeys`, 'a machine has exactly one signing device key, <id>#sig');
|
|
198
|
+
if (deviceKeys.filter((key) => key.use === 'kem').length > 1) flag(`${at}/deviceKeys`, 'a machine has at most one key-agreement device key, <id>#kem');
|
|
199
|
+
const seen = new Set();
|
|
200
|
+
// The index is the key's position in the list as written, non-objects counted, as the contract and the Python twin name it.
|
|
201
|
+
indexed(machine.deviceKeys).forEach(([key, k]) => {
|
|
202
|
+
if (key.kid !== `${show(machine.id)}#${show(key.use)}`) flag(`${at}/deviceKeys/${k}/kid`, 'a device key is listed as <machine id>#sig or <machine id>#kem, by its role');
|
|
203
|
+
// A key with no canonical fingerprint could be read as two keys by two readers, and no key reference names it (DEC-0084).
|
|
204
|
+
if (deviceKeyFingerprint(key) === undefined) flag(`${at}/deviceKeys/${k}/publicKey`, "a device key's octets are written in canonical padded base64, the one form a key reference can name (DEC-0084)");
|
|
205
|
+
else 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)');
|
|
206
|
+
if (seen.has(key.publicKey)) flag(`${at}/deviceKeys/${k}/publicKey`, 'one key is never used for the other role');
|
|
207
|
+
seen.add(key.publicKey);
|
|
208
|
+
});
|
|
209
|
+
if (isObject(machine.hubKey)) {
|
|
210
|
+
const hub = machine.hubKey;
|
|
211
|
+
if (hubKids.has(hub.kid)) flag(`${at}/hubKey/kid`, `hub key ${show(hub.kid)} is certified for two machines: one kid per machine`);
|
|
212
|
+
hubKids.add(hub.kid);
|
|
213
|
+
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');
|
|
214
|
+
hubKeys.add(hub.x);
|
|
215
|
+
if (smallOrder(Buffer.from(show(hub.x ?? ''), 'base64url'))) flag(`${at}/hubKey/x`, 'a hub key of small order verifies forged tokens and is never certified (DEC-0045)');
|
|
216
|
+
}
|
|
217
|
+
// DEC-0128: a context's hub key is as unique as the personal one, across every hub key of the record, and belongs to a
|
|
218
|
+
// pinned organization, so a token in that context verifies only under its own context's key.
|
|
219
|
+
const contexts = new Set();
|
|
220
|
+
// A context's key lives with the personal hub key, so a hub move carries every context's key with it (DL-15).
|
|
221
|
+
if (list(machine.contextHubKeys).length > 0 && !isObject(machine.hubKey)) flag(`${at}/contextHubKeys`, "a context's hub key is held only by the machine that holds the personal hub key (DEC-0128)");
|
|
222
|
+
indexed(machine.contextHubKeys).forEach(([entry, c]) => {
|
|
223
|
+
const keyAt = `${at}/contextHubKeys/${c}`;
|
|
224
|
+
if (contexts.has(entry.context)) flag(`${keyAt}/context`, `context ${show(entry.context)} has two hub keys on one machine`);
|
|
225
|
+
contexts.add(entry.context);
|
|
226
|
+
if (!pinned.has(entry.context)) flag(`${keyAt}/context`, `context ${show(entry.context)} is not an organization the record pins (DEC-0127)`);
|
|
227
|
+
// A key that is not an object is the schema's to refuse; nothing here reads it.
|
|
228
|
+
if (!isObject(entry.key)) return;
|
|
229
|
+
const key = entry.key;
|
|
230
|
+
if (hubKids.has(key.kid)) flag(`${keyAt}/key/kid`, `hub key ${show(key.kid)} is certified twice: one kid per hub key, personal or per context`);
|
|
231
|
+
hubKids.add(key.kid);
|
|
232
|
+
if (hubKeys.has(key.x)) flag(`${keyAt}/key/x`, 'one public key is certified as two hub keys: a context key is its own key');
|
|
233
|
+
hubKeys.add(key.x);
|
|
234
|
+
if (smallOrder(Buffer.from(show(key.x ?? ''), 'base64url'))) flag(`${keyAt}/key/x`, 'a hub key of small order verifies forged tokens and is never certified (DEC-0045)');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
const credentials = new Set();
|
|
238
|
+
indexed(value.authorities).forEach(([authority, index]) => {
|
|
239
|
+
if (credentials.has(authority.credentialId)) flag(`/authorities/${index}/credentialId`, 'a passkey is listed once');
|
|
240
|
+
credentials.add(authority.credentialId);
|
|
241
|
+
if ('problem' in credentialKey(authority.publicKey, show(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");
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function placements(value, flag) {
|
|
246
|
+
const kids = new Set();
|
|
247
|
+
indexed(value.publisherKeys).forEach(([key, index]) => {
|
|
248
|
+
if (kids.has(key.kid)) flag(`/publisherKeys/${index}/kid`, `publisher key ${show(key.kid)} is pinned twice`);
|
|
249
|
+
kids.add(key.kid);
|
|
250
|
+
});
|
|
251
|
+
indexed(value.machines).forEach(([machine, m]) => {
|
|
252
|
+
const agents = new Set();
|
|
253
|
+
indexed(machine.packages).forEach(([placement, p]) => {
|
|
254
|
+
const at = `/machines/${m}/packages/${p}`;
|
|
255
|
+
const key = keyOf(placement.agentRef, contextOf(placement));
|
|
256
|
+
if (agents.has(key)) flag(`${at}/agentRef`, `${show(placement.agentRef)} is placed twice on one machine in the context ${show(contextOf(placement))}`);
|
|
257
|
+
agents.add(key);
|
|
258
|
+
if (!kids.has(placement.publisherKid)) flag(`${at}/publisherKid`, `publisher key ${show(placement.publisherKid)} is not in publisherKeys, so the machine pins no key to verify the package under`);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* FAC-SEM-122 on one record (DEC-0090): a partner's publisher key is scoped once, to a key the record pins, and a
|
|
265
|
+
* placement under it is in one of the organization contexts its scope names, never `personal` or another.
|
|
266
|
+
*/
|
|
267
|
+
function partnerScopes(value, flag) {
|
|
268
|
+
const pinned = new Set(list(value.publisherKeys).map((key) => key.kid));
|
|
269
|
+
const scoped = new Set();
|
|
270
|
+
indexed(value.publisherScopes).forEach(([scope, index]) => {
|
|
271
|
+
if (scoped.has(scope.kid)) flag(`/publisherScopes/${index}/kid`, `publisher key ${show(scope.kid)} is scoped twice`);
|
|
272
|
+
scoped.add(scope.kid);
|
|
273
|
+
if (!pinned.has(scope.kid)) flag(`/publisherScopes/${index}/kid`, `publisher key ${show(scope.kid)} is not in publisherKeys, so the scope limits no key`);
|
|
274
|
+
});
|
|
275
|
+
indexed(value.machines).forEach(([machine, m]) => {
|
|
276
|
+
indexed(machine.packages).forEach(([placement, p]) => {
|
|
277
|
+
if (!scopeAdmits(value, placement.publisherKid, contextOf(placement))) {
|
|
278
|
+
flag(`/machines/${m}/packages/${p}/publisherKid`, `publisher key ${show(placement.publisherKid)} is a partner's, trusted only in the contexts its scope names, not in ${show(contextOf(placement))}`);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function secretReferences(value, flag) {
|
|
285
|
+
const machineIds = new Set(list(value.machines).map((machine) => machine.id));
|
|
286
|
+
indexed(value.machines).forEach(([machine, m]) => {
|
|
287
|
+
const placed = new Set(list(machine.packages).map((placement) => keyOf(placement.agentRef, contextOf(placement))));
|
|
288
|
+
const slots = new Set();
|
|
289
|
+
indexed(machine.secrets).forEach(([secret, s]) => {
|
|
290
|
+
const at = `/machines/${m}/secrets/${s}`;
|
|
291
|
+
// The message never repeats the value, and no message of this rule repeats a secret placement's context.
|
|
292
|
+
for (const field of ['slot', 'from', 'agentRef', 'context']) {
|
|
293
|
+
const text = secret[field];
|
|
294
|
+
if (isString(text) && secretValue(text)) flag(`${at}/${field}`, 'a secret placement names a slot, never a value: this looks like a credential');
|
|
295
|
+
}
|
|
296
|
+
if (!placed.has(keyOf(secret.agentRef, contextOf(secret)))) flag(`${at}/agentRef`, "a secret goes only where a placed package declares it (v4 Path 2): this agent is not placed on this machine in the secret's context");
|
|
297
|
+
if (!machineIds.has(secret.from)) flag(`${at}/from`, 'the slot is filled from a machine of this estate');
|
|
298
|
+
// An envelope names no context (DEC-0082), so an organization's instance fills its slot on its own machine.
|
|
299
|
+
else if (contextOf(secret) !== PERSONAL_CONTEXT && secret.from !== machine.id) flag(`${at}/from`, "an organization's instance fills its slot from its own machine's vault, never by a secret envelope from another machine");
|
|
300
|
+
const key = keyOf(secret.agentRef, secret.slot, contextOf(secret));
|
|
301
|
+
if (slots.has(key)) flag(`${at}/slot`, `slot ${show(secret.slot)} is placed twice for one agent in one context on one machine`);
|
|
302
|
+
slots.add(key);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function grantsAndRevocations(value, flag) {
|
|
308
|
+
// A grant's id is a URI, so grants and their revocations are keyed by its normal form (DEC-0076).
|
|
309
|
+
const grantKey = (id) => (typeof id === 'string' ? normalIdentifier(id) : id);
|
|
310
|
+
const grantIds = new Set();
|
|
311
|
+
indexed(value.shareGrants).forEach(([grant, index]) => {
|
|
312
|
+
if (grant.principalKind !== 'external') flag(`/shareGrants/${index}/principalKind`, 'a share grant is to a principal outside the estate: principalKind external');
|
|
313
|
+
const id = isObject(grant.meta) ? grant.meta.id : undefined;
|
|
314
|
+
if (grantIds.has(grantKey(id))) flag(`/shareGrants/${index}/meta/id`, `grant ${show(id)} is listed twice`);
|
|
315
|
+
grantIds.add(grantKey(id));
|
|
316
|
+
});
|
|
317
|
+
const machines = list(value.machines);
|
|
318
|
+
// A Map, so a kind such as `constructor` or `__proto__` reads nothing from Object.prototype.
|
|
319
|
+
const listed = new Map(Object.entries({
|
|
320
|
+
authority: new Set(list(value.authorities).map((entry) => entry.credentialId)),
|
|
321
|
+
machine: new Set(machines.map((machine) => machine.id)),
|
|
322
|
+
// A kid, and each listed key's own reference (DEC-0084): revoking either while listing it is refused.
|
|
323
|
+
'device-key': new Set(machines.flatMap((machine) => list(machine.deviceKeys).flatMap((key) => [key.kid, deviceKeyRef(key)].filter((ref) => ref !== undefined)))),
|
|
324
|
+
// A context's hub key (DEC-0128) is listed as the personal one is; an entry whose key is no object lists undefined.
|
|
325
|
+
'hub-key': new Set(machines.flatMap((machine) => [
|
|
326
|
+
...(isObject(machine.hubKey) ? [machine.hubKey.kid] : []),
|
|
327
|
+
...list(machine.contextHubKeys).map((entry) => (isObject(entry.key) ? entry.key.kid : undefined)),
|
|
328
|
+
])),
|
|
329
|
+
'publisher-key': new Set(list(value.publisherKeys).map((key) => key.kid)),
|
|
330
|
+
package: new Set(machines.flatMap((machine) => list(machine.packages).map((placement) => placement.digest))),
|
|
331
|
+
grant: grantIds,
|
|
332
|
+
}));
|
|
333
|
+
const seen = new Set();
|
|
334
|
+
indexed(value.revocations).forEach(([revocation, index]) => {
|
|
335
|
+
const ref = revocation.kind === 'grant' ? grantKey(revocation.ref) : revocation.ref;
|
|
336
|
+
const key = `${show(revocation.kind)} ${show(ref)}`;
|
|
337
|
+
if (seen.has(key)) flag(`/revocations/${index}`, `the ${show(revocation.kind)} ${show(revocation.ref)} is revoked twice`);
|
|
338
|
+
if (revocation.kind === 'device-key' && typeof revocation.ref === 'string' && revocation.ref.includes('@') && !KEY_REFERENCE.test(revocation.ref)) {
|
|
339
|
+
flag(`/revocations/${index}/ref`, 'a device-key revocation names a kid or a key reference <kid>@sha256:<64 lower-case hex digits> (DEC-0084)');
|
|
340
|
+
}
|
|
341
|
+
seen.add(key);
|
|
342
|
+
if (listed.get(show(revocation.kind))?.has(ref)) flag(`/revocations/${index}/ref`, `the record still lists the ${show(revocation.kind)} it revokes: revocation outranks addition`);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* FAC-SEM-110 (DEC-0082): a grant to a principal outside the estate (`principalKind: external`) is used in that
|
|
348
|
+
* principal's organization, so its `context` is never `personal`; absent means not stated.
|
|
349
|
+
*/
|
|
350
|
+
export function grantContextFindings(grant, at) {
|
|
351
|
+
if (!isObject(grant) || grant.principalKind !== 'external' || grant.context !== PERSONAL_CONTEXT) return [];
|
|
352
|
+
return [{ code: 'FAC-SEM-110', instancePath: `${at}/context`, message: "a grant to a principal outside the estate is used in that principal's organization, never in the personal context: name the organization's org:<orgId>, or leave context out" }];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* FAC-SEM-111 (DEC-0082) for one placement: an instance in an organization's context names, in `grant`, a share grant
|
|
357
|
+
* of the same record (by the normal form of its id, DEC-0076) whose `context` is the placement's, as written, and that
|
|
358
|
+
* the record does not revoke; a personal instance names none; a malformed context fails closed.
|
|
359
|
+
*/
|
|
360
|
+
function placementGrantProblems(record, placement) {
|
|
361
|
+
const context = contextOf(placement);
|
|
362
|
+
if (context === PERSONAL_CONTEXT) {
|
|
363
|
+
return placement.grant === undefined ? [] : [['/grant', "a personal instance runs under no share grant: only a placement in an organization's context names one"]];
|
|
364
|
+
}
|
|
365
|
+
if (typeof placement.grant !== 'string') return [['/grant', `an instance placed in the context ${show(context)} names the share grant it runs under, by its id`]];
|
|
366
|
+
const id = normalIdentifier(placement.grant);
|
|
367
|
+
const grants = list(record.shareGrants).filter((grant) => isObject(grant.meta) && typeof grant.meta.id === 'string' && normalIdentifier(grant.meta.id) === id);
|
|
368
|
+
const problems = [];
|
|
369
|
+
if (grants.length === 0) problems.push(['/grant', `the record has no share grant ${placement.grant}`]);
|
|
370
|
+
else if (parseContext(context)?.kind !== 'org') problems.push(['/context', `${show(context)} is not an organization's context id (org:<orgId>), so no share grant matches it`]);
|
|
371
|
+
else if (!grants.some((grant) => grant.context === context)) problems.push(['/grant', `the share grant ${placement.grant} is not for the context ${show(context)}: its context must be the placement's, as written`]);
|
|
372
|
+
if (list(record.revocations).some((entry) => entry.kind === 'grant' && typeof entry.ref === 'string' && normalIdentifier(entry.ref) === id)) problems.push(['/grant', `the record revokes the share grant ${placement.grant}, so no instance runs under it`]);
|
|
373
|
+
return problems;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** FAC-SEM-111 for one placement of `record`, as findings (`instancePath` relative to the placement); never throws (FAA-125). */
|
|
377
|
+
export function placementGrantFindings(record, placement) {
|
|
378
|
+
const object = (value) => (isObject(value) ? value : {});
|
|
379
|
+
return placementGrantProblems(object(record), object(placement)).map(([instancePath, message]) => ({ code: 'FAC-SEM-111', instancePath, message }));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function placementGrants(value, flag) {
|
|
383
|
+
indexed(value.machines).forEach(([machine, m]) => {
|
|
384
|
+
indexed(machine.packages).forEach(([placement, p]) => {
|
|
385
|
+
for (const [suffix, message] of placementGrantProblems(value, placement)) flag(`/machines/${m}/packages/${p}${suffix}`, message);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** DEC-0106: what a machine of the estate may be. Absent is `full`, and a writer omits `full`. */
|
|
391
|
+
export const MACHINE_PROFILES = Object.freeze(['full', 'member', 'view']);
|
|
392
|
+
const isProfile = (value) => typeof value === 'string' && MACHINE_PROFILES.includes(value);
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* A machine's profile in a record (DEC-0106): `full` when the machine is listed once with no `profile`, its value when
|
|
396
|
+
* that is one of the three, undefined otherwise (not listed, listed twice, an unreadable record or id, an unknown
|
|
397
|
+
* value). The contract's machineProfile; it never throws.
|
|
398
|
+
*/
|
|
399
|
+
export function machineProfile(record, machineId) {
|
|
400
|
+
if (!isObject(record) || !isString(machineId)) return undefined;
|
|
401
|
+
const found = list(record.machines).filter((machine) => machine.id === machineId);
|
|
402
|
+
if (found.length !== 1) return undefined;
|
|
403
|
+
const { profile } = found[0];
|
|
404
|
+
if (profile === undefined) return 'full';
|
|
405
|
+
return isProfile(profile) ? profile : undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* FAC-SEM-134 (DEC-0106): a profile, when present, is `full`, `member` or `view`; only a `full` machine carries a
|
|
410
|
+
* `hubKey`; a `view` machine has no placement and no secret placement, and no secret placement names it as `from`.
|
|
411
|
+
*/
|
|
412
|
+
function machineProfiles(value, flag) {
|
|
413
|
+
const views = new Set();
|
|
414
|
+
indexed(value.machines).forEach(([machine, m]) => {
|
|
415
|
+
if (machine.profile === undefined) return;
|
|
416
|
+
if (!isProfile(machine.profile)) {
|
|
417
|
+
flag(`/machines/${m}/profile`, `profile ${show(machine.profile)} is none of full, member, view`);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (machine.profile !== 'full' && machine.hubKey !== undefined) flag(`/machines/${m}/hubKey`, `a ${machine.profile} machine is never the hub's: only a full machine carries a hub key`);
|
|
421
|
+
if (machine.profile !== 'full' && list(machine.contextHubKeys).length > 0) flag(`/machines/${m}/contextHubKeys`, `a ${machine.profile} machine is never the hub's: only a full machine carries a context's hub key (DEC-0128)`);
|
|
422
|
+
if (machine.profile === 'view') {
|
|
423
|
+
views.add(machine.id);
|
|
424
|
+
if (list(machine.packages).length > 0) flag(`/machines/${m}/packages`, 'a view machine runs no agent: it has no placement');
|
|
425
|
+
if (list(machine.secrets).length > 0) flag(`/machines/${m}/secrets`, "a view machine holds none of the estate's secrets");
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
indexed(value.machines).forEach(([machine, m]) => {
|
|
429
|
+
indexed(machine.secrets).forEach(([secret, s]) => {
|
|
430
|
+
if (views.has(secret.from)) flag(`/machines/${m}/secrets/${s}/from`, `machine ${show(secret.from)} is a view machine: it is the source of no secret`);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* FAC-SEM-147: the organizations the person admitted (DEC-0127 OM-2), each once by its context, and no root of small
|
|
437
|
+
* order (DEC-0045), since the root's key sets are what every attestation of that organization verifies under; each
|
|
438
|
+
* context issuer once and never containing the estate id (DEC-0128).
|
|
439
|
+
*/
|
|
440
|
+
function organizationPins(value, flag) {
|
|
441
|
+
const seen = new Set();
|
|
442
|
+
const issuers = new Set();
|
|
443
|
+
for (const [pin, index] of indexed(value.organizations)) {
|
|
444
|
+
const at = `/organizations/${index}`;
|
|
445
|
+
const org = show(pin.org);
|
|
446
|
+
if (seen.has(org)) flag(`${at}/org`, `organization ${org} is pinned twice`);
|
|
447
|
+
seen.add(org);
|
|
448
|
+
// DEC-0128: a context's issuer is its own and names no estate, so two organizations' tokens share nothing.
|
|
449
|
+
if (typeof pin.iss === 'string') {
|
|
450
|
+
if (issuers.has(pin.iss)) flag(`${at}/iss`, `context issuer ${show(pin.iss)} is used by two organizations`);
|
|
451
|
+
issuers.add(pin.iss);
|
|
452
|
+
}
|
|
453
|
+
// Only the opaque part is the pin's own: the fixed prefix would match a short estate id by chance.
|
|
454
|
+
const opaquePart = typeof pin.iss === 'string' ? pin.iss.replace(/^urn:fabric:context:/, '').toLowerCase() : '';
|
|
455
|
+
// An estate id shorter than eight characters appears inside any random id by chance; the issuer's duty covers it.
|
|
456
|
+
if (typeof value.estate === 'string' && value.estate.length >= 8 && opaquePart.includes(value.estate.toLowerCase())) {
|
|
457
|
+
flag(`${at}/iss`, 'a context issuer never contains the estate id (DEC-0128)');
|
|
458
|
+
}
|
|
459
|
+
const x = isObject(pin.root) ? pin.root.x : undefined;
|
|
460
|
+
if (typeof x === 'string' && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(x) && smallOrder(Buffer.from(x, 'base64url'))) {
|
|
461
|
+
flag(`${at}/root/x`, "a root of small order lets anyone forge the organization's key sets (DEC-0045)");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function recordFindings(value) {
|
|
467
|
+
const findings = [];
|
|
468
|
+
revisionWhole(value, flagger(findings, 'FAC-SEM-072'));
|
|
469
|
+
keys(value, flagger(findings, 'FAC-SEM-073'));
|
|
470
|
+
placements(value, flagger(findings, 'FAC-SEM-074'));
|
|
471
|
+
partnerScopes(value, flagger(findings, 'FAC-SEM-122'));
|
|
472
|
+
secretReferences(value, flagger(findings, 'FAC-SEM-075'));
|
|
473
|
+
grantsAndRevocations(value, flagger(findings, 'FAC-SEM-076'));
|
|
474
|
+
indexed(value.shareGrants).forEach(([grant, index]) => findings.push(...grantContextFindings(grant, `/shareGrants/${index}`)));
|
|
475
|
+
placementGrants(value, flagger(findings, 'FAC-SEM-111'));
|
|
476
|
+
machineProfiles(value, flagger(findings, 'FAC-SEM-134'));
|
|
477
|
+
organizationPins(value, flagger(findings, 'FAC-SEM-147'));
|
|
478
|
+
// FAC-SEM-092 (DEC-0063) on each passkey the record lists.
|
|
479
|
+
indexed(value.authorities).forEach(([authority, index]) => signingCredential(authority, `/authorities/${index}`, flagger(findings, 'FAC-SEM-092')));
|
|
480
|
+
return findings;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function packageDigest(pkg) {
|
|
484
|
+
const { digest: _digest, signatures: _signatures, ...covered } = pkg;
|
|
485
|
+
return `sha256:${sha256(Buffer.from(jcs(covered), 'utf8')).toString('hex')}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function installation(value) {
|
|
489
|
+
const findings = [];
|
|
490
|
+
const record = isObject(value.record) ? value.record : {};
|
|
491
|
+
const pkg = isObject(value.package) ? value.package : {};
|
|
492
|
+
const [machine, machineIndex] = indexed(record.machines).find(([entry]) => entry.id === value.machine) ?? [undefined, -1];
|
|
493
|
+
let digest;
|
|
494
|
+
try {
|
|
495
|
+
digest = packageDigest(pkg);
|
|
496
|
+
} catch {
|
|
497
|
+
digest = undefined;
|
|
498
|
+
}
|
|
499
|
+
const flag074 = flagger(findings, 'FAC-SEM-074');
|
|
500
|
+
if (!machine) {
|
|
501
|
+
flag074('/machine', `machine ${show(value.machine)} is not in the estate record`);
|
|
502
|
+
return findings;
|
|
503
|
+
}
|
|
504
|
+
if (digest === undefined || digest !== pkg.digest) flag074('/package/digest', 'the package is not the one its digest names: it changed after it was packed');
|
|
505
|
+
const context = contextOf(value);
|
|
506
|
+
const [placement, placementIndex] = indexed(machine.packages).find(([entry]) => entry.agentRef === pkg.agentRef && contextOf(entry) === context) ?? [undefined, -1];
|
|
507
|
+
if (!placement) flag074('/package/agentRef', `the record places no ${show(pkg.agentRef)} on machine ${show(value.machine)} in the context ${show(context)}`);
|
|
508
|
+
else if (placement.digest !== digest) flag074('/package', `the record places ${show(placement.digest)} of this agent on this machine in this context, not this package`);
|
|
509
|
+
const declared = new Set(list(pkg.secrets).map((slot) => slot.name));
|
|
510
|
+
const flag075 = flagger(findings, 'FAC-SEM-075');
|
|
511
|
+
indexed(machine.secrets).forEach(([secret, index]) => {
|
|
512
|
+
if (secret.agentRef === pkg.agentRef && contextOf(secret) === context && !declared.has(secret.slot)) flag075(`/record/machines/${machineIndex}/secrets/${index}/slot`, `the package declares no slot ${show(secret.slot)}: a secret goes only where a placed package declares it`);
|
|
513
|
+
});
|
|
514
|
+
if (placement && !scopeAdmits(record, placement.publisherKid, context)) {
|
|
515
|
+
flagger(findings, 'FAC-SEM-122')(`/record/machines/${machineIndex}/packages/${placementIndex}/publisherKid`, `publisher key ${show(placement.publisherKid)} is a partner's, not trusted in ${show(context)}`);
|
|
516
|
+
}
|
|
517
|
+
if (placement) {
|
|
518
|
+
const flag111 = flagger(findings, 'FAC-SEM-111');
|
|
519
|
+
for (const [suffix, message] of placementGrantProblems(record, placement)) flag111(`/record/machines/${machineIndex}/packages/${placementIndex}${suffix}`, message);
|
|
520
|
+
}
|
|
521
|
+
return findings;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** The anchor a record vouches for (DEC-0107), or undefined when it names none or its shape is not the schema's. */
|
|
525
|
+
export function deltaAnchorOf(record) {
|
|
526
|
+
if (!isObject(record) || !isObject(record.deltaAnchor)) return undefined;
|
|
527
|
+
const { sequence, sha256 } = record.deltaAnchor;
|
|
528
|
+
return Number.isSafeInteger(sequence) && sequence >= 1 && isString(sha256) && /^[a-f0-9]{64}$/.test(sha256) ? { sequence, sha256 } : undefined;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* FAC-SEM-137 across two revisions (DEC-0107): the delta anchor never moves back. A revision whose parent anchors the
|
|
533
|
+
* delta anchors it too, at the parent's sequence or later, with the parent's hash at the same sequence.
|
|
534
|
+
*/
|
|
535
|
+
export function anchorFindings(parent, next) {
|
|
536
|
+
const findings = [];
|
|
537
|
+
const flag = flagger(findings, 'FAC-SEM-137');
|
|
538
|
+
const before = deltaAnchorOf(parent);
|
|
539
|
+
if (before === undefined) return findings;
|
|
540
|
+
const after = deltaAnchorOf(next);
|
|
541
|
+
if (after === undefined) flag('/next/deltaAnchor', `the parent anchors the delta at ${before.sequence}: a revision keeps an anchor once one is set`);
|
|
542
|
+
else if (after.sequence < before.sequence) flag('/next/deltaAnchor/sequence', `the anchor moves back from ${before.sequence} to ${after.sequence}`);
|
|
543
|
+
else if (after.sequence === before.sequence && after.sha256 !== before.sha256) flag('/next/deltaAnchor/sha256', `another hash at sequence ${after.sequence}: two delta histories`);
|
|
544
|
+
return findings;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* FAC-SEM-114 across two revisions (DEC-0084): what a revision drops, it revokes in the same revision. A removed machine
|
|
549
|
+
* as `machine <id>` and its hub key as `hub-key <kid>`; a replaced or dropped device key of a kept machine as `device-key`
|
|
550
|
+
* by its reference or its kid; a hub key no longer certified as it was as `hub-key <old kid>`.
|
|
551
|
+
*/
|
|
552
|
+
/** A machine's context hub keys that are objects, with their indexes, each with the context it is certified for. */
|
|
553
|
+
const contextKeys = (machine) => indexed(machine.contextHubKeys).filter(([entry]) => isObject(entry.key)).map(([entry, c]) => [entry.key, c, entry.context]);
|
|
554
|
+
|
|
555
|
+
export function droppedFindings(parent, next) {
|
|
556
|
+
const findings = [];
|
|
557
|
+
const flag = flagger(findings, 'FAC-SEM-114');
|
|
558
|
+
const revoked = (kind, ...refs) => list(next.revocations).some((entry) => entry.kind === kind && refs.some((ref) => ref !== undefined && entry.ref === ref));
|
|
559
|
+
const nextMachines = new Map();
|
|
560
|
+
for (const machine of list(next.machines)) if (!nextMachines.has(machine.id)) nextMachines.set(machine.id, machine);
|
|
561
|
+
indexed(parent.machines).forEach(([machine, m]) => {
|
|
562
|
+
const at = `/parent/machines/${m}`;
|
|
563
|
+
const hub = isObject(machine.hubKey) ? machine.hubKey : undefined;
|
|
564
|
+
const kept = nextMachines.get(machine.id);
|
|
565
|
+
if (!kept) {
|
|
566
|
+
if (!revoked('machine', machine.id)) flag(`${at}/id`, `machine ${show(machine.id)} is removed without its revocation: a revision that drops a machine lists machine ${show(machine.id)} in revocations`);
|
|
567
|
+
if (hub && !revoked('hub-key', hub.kid)) flag(`${at}/hubKey/kid`, `the hub key ${show(hub.kid)} of a removed machine is revoked in the same revision`);
|
|
568
|
+
contextKeys(machine).forEach(([key, c]) => {
|
|
569
|
+
if (!revoked('hub-key', key.kid)) flag(`${at}/contextHubKeys/${c}/key/kid`, `the context hub key ${show(key.kid)} of a removed machine is revoked in the same revision (DEC-0128)`);
|
|
570
|
+
});
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const listedRefs = new Set(list(kept.deviceKeys).map(deviceKeyRef).filter((ref) => ref !== undefined));
|
|
574
|
+
indexed(machine.deviceKeys).forEach(([key, k]) => {
|
|
575
|
+
const ref = deviceKeyRef(key);
|
|
576
|
+
if (ref === undefined || listedRefs.has(ref)) return;
|
|
577
|
+
if (!revoked('device-key', ref, key.kid)) flag(`${at}/deviceKeys/${k}`, `the device key ${show(key.kid)} is replaced or dropped without its revocation: the same revision lists device-key ${ref}`);
|
|
578
|
+
});
|
|
579
|
+
const nextHub = isObject(kept.hubKey) ? kept.hubKey : undefined;
|
|
580
|
+
if (hub && !(nextHub && nextHub.kid === hub.kid && nextHub.x === hub.x) && !revoked('hub-key', hub.kid)) {
|
|
581
|
+
flag(`${at}/hubKey/kid`, `the hub key ${show(hub.kid)} is no longer certified as it was: the same revision revokes hub-key ${show(hub.kid)}, and a re-certified hub key takes a new kid`);
|
|
582
|
+
}
|
|
583
|
+
// DEC-0128: a context's hub key dropped (its context unpinned, OM-5, or the key replaced) is revoked the same way.
|
|
584
|
+
// A key is kept only in its own context: one moved to another organization is a key that organization must not inherit.
|
|
585
|
+
const keptContext = new Set(contextKeys(kept).map(([key, , context]) => `${show(context)} ${show(key.kid)} ${show(key.x)}`));
|
|
586
|
+
contextKeys(machine).forEach(([key, c, context]) => {
|
|
587
|
+
if (!keptContext.has(`${show(context)} ${show(key.kid)} ${show(key.x)}`) && !revoked('hub-key', key.kid)) {
|
|
588
|
+
flag(`${at}/contextHubKeys/${c}/key/kid`, `the context hub key ${show(key.kid)} is no longer certified as it was: the same revision revokes hub-key ${show(key.kid)} (DEC-0128)`);
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
return findings;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** FAC-SEM-072…076 for an `estate-record`, an `estate-record-revision` `{parent, next}` or an `estate-placement`. */
|
|
596
|
+
export function estateRecordRules(kind, value) {
|
|
597
|
+
const doc = isObject(value) ? value : {};
|
|
598
|
+
if (kind === 'estate-record') return recordFindings(doc);
|
|
599
|
+
if (kind === 'estate-record-revision') {
|
|
600
|
+
const parent = isObject(doc.parent) ? doc.parent : {};
|
|
601
|
+
const next = isObject(doc.next) ? doc.next : {};
|
|
602
|
+
return [...revisionChain(doc), ...droppedFindings(parent, next), ...anchorFindings(parent, next)];
|
|
603
|
+
}
|
|
604
|
+
if (kind === 'estate-placement') return installation(doc);
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|