@passioncode-ai/fabric-agent-adapter 0.37.0 → 0.37.1
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/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 +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +13 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +210 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +298 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.37.1 - 2026-10-20
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **The rest of contract DEC-0084 in both kits** (FAA-57), in `fabric_ceremonies.py` / `fabric-ceremonies.mjs`: the
|
|
8
|
+
nearby pairing (`pairing_commit`, `check_reveal`, `comparison_code`, `checked_comparison_code`, `estate_fingerprint`,
|
|
9
|
+
`rendezvous_verdict`, `join_listed`, `own_keys_decision`, `machine_of_keys`) held to the contract's `pairing.json`
|
|
10
|
+
(116 cases in eight groups), and the hub's single use of a token (`redeem_enroll_token`, with the `enroll-join/1`
|
|
11
|
+
proof as ES256 r and s low S or Ed25519) held to `enroll-token.json` group `redeem` and a high-S variant. 26 planted
|
|
12
|
+
defects across both kits, all caught. `fabric-estate.mjs` exports `validDeviceKey`.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **`building-fabric-services` → remote placement:** which `installedBy` form an agent installer writes (the string
|
|
17
|
+
form, or `{by: "person", role: "operator", tool}` when a person asked it), and that a TypeScript app must name its
|
|
18
|
+
`.well-known` route folder in `tsconfig` `include`, which skips dot folders. Both reported by an agent that built a
|
|
19
|
+
remote service in a Next.js app.
|
|
20
|
+
|
|
3
21
|
## 0.37.0 - 2026-10-19
|
|
4
22
|
|
|
5
23
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.37.
|
|
3
|
+
"version": "0.37.1",
|
|
4
4
|
"description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "fabric-agent-adapter",
|
|
4
4
|
"displayName": "Fabric Agent Adapter",
|
|
5
5
|
"description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
|
|
6
|
-
"version": "0.37.
|
|
6
|
+
"version": "0.37.1",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.37.
|
|
8
|
+
version: "0.37.1"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "07042181bfae2a481aa0da1a5651f24c76669c48"
|
|
11
11
|
---
|
|
@@ -18,7 +18,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
18
18
|
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
19
19
|
metadata:
|
|
20
20
|
author: PassionCode.ai
|
|
21
|
-
version: "0.37.
|
|
21
|
+
version: "0.37.1"
|
|
22
22
|
contract-version: "0.1.0"
|
|
23
23
|
extension: "fabric-service/0.1"
|
|
24
24
|
extension-commit: "07042181bfae2a481aa0da1a5651f24c76669c48"
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md
CHANGED
|
@@ -137,9 +137,19 @@ passkey; what a service applying the record checks is in the record itself:
|
|
|
137
137
|
with the machine's `#sig` key and present that proof with the token. The token gives the machine no trust: only the
|
|
138
138
|
passkey-signed revision that lists it does. `enroll_token_rules("enroll-token", token)` is FAC-SEM-113.
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
- **A machine nearby pairs by a comparison code.** Each side commits (`pairing_commit`), then reveals; each checks the
|
|
141
|
+
other's reveal against its commitment, and both screens show the same six digits only when nothing sat in the middle
|
|
142
|
+
(`checked_comparison_code`, never `comparison_code` on unchecked reveals). The one-time rendezvous burns on the first
|
|
143
|
+
repeat, out-of-order message, mismatch, abort or after 600 s (`rendezvous_verdict`). Before it pins the estate, the new
|
|
144
|
+
machine checks that the revision lists it with the keys it revealed and the trusted machine with its `#sig` key
|
|
145
|
+
(`join_listed`), and shows `estate_fingerprint` of revision 1 for the person to compare. On each later revision it
|
|
146
|
+
runs `own_keys_decision`: apply and retire the keys the record dropped, or stop when it was removed or another holder
|
|
147
|
+
carries its key.
|
|
148
|
+
- **The hub binds a token once** (`redeem_enroll_token`): the joining machine's proof is its `#sig` key's signature over
|
|
149
|
+
`enroll_join_input(token)` (ES256 as 64 bytes r and s, low S; Ed25519), and a second use, by any key, is refused and
|
|
150
|
+
logged.
|
|
151
|
+
|
|
152
|
+
The relay's admission and session checks belong to the relay; the kits do not run a relay.
|
|
143
153
|
|
|
144
154
|
## Share grants into an organization
|
|
145
155
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md
CHANGED
|
@@ -30,6 +30,16 @@ same objects — well-known document, events feed, login code — at the same pa
|
|
|
30
30
|
literal; not `localhost`, `*.local`, `*.internal`, `*.home.arpa`, `*.lan`, `*.localdomain`.
|
|
31
31
|
- `lifecycle.manager` is `none`, with no `label` or `plist`. `paths` is optional.
|
|
32
32
|
- `commands` may carry `doctor` (a local executable); never `update`.
|
|
33
|
+
- `installedBy` is the installer's name and version as a string (`"example-installer 1.2.0"`), or
|
|
34
|
+
`{by, role?, tool}` where `by` is `person`, `node` or `organization` and `role` (`operator`, `administrator`) is a
|
|
35
|
+
person's only. There is no `agent` kind: an agent that registers a service writes the string form, or
|
|
36
|
+
`{by: "person", role: "operator", tool: "<agent> <version>"}` when a person asked it to. `registerRemote` defaults to
|
|
37
|
+
the person form with `tool: "fabric-service register-remote"`.
|
|
38
|
+
|
|
39
|
+
**A TypeScript app (Next.js and the like):** TypeScript's `include` and typescript-eslint skip dot folders by default,
|
|
40
|
+
so `app/.well-known/fabric-service/route.ts` is neither type-checked nor linted until the project names it, for example
|
|
41
|
+
`"include": [..., "src/app/.well-known/**/*.ts"]` in `tsconfig.json`. Check it is: put a type error in the route and
|
|
42
|
+
watch the gate fail.
|
|
33
43
|
|
|
34
44
|
## On the platform
|
|
35
45
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
10
|
import { jcs } from './fabric-delegation.mjs';
|
|
11
|
-
import { CLOCK_SKEW_SECONDS, estateTime, isSigningRpId, originWithin, verifyAssertion } from './fabric-estate.mjs';
|
|
11
|
+
import { CLOCK_SKEW_SECONDS, deviceKeyFingerprint, deviceKeyRef, estateTime, isSigningRpId, isValidP256Key, originWithin, validDeviceKey, verifyAssertion } from './fabric-estate.mjs';
|
|
12
12
|
|
|
13
13
|
export const ENROLL_TOKEN_SCHEMA = 'enroll-token/1';
|
|
14
14
|
export const ENROLL_TOKEN_TYPE = 'enroll-token/1';
|
|
@@ -126,4 +126,213 @@ export function enrollTokenRules(kind, value) {
|
|
|
126
126
|
if (kind === 'enroll-token') return tokenFindings(isObject(value) ? value : {});
|
|
127
127
|
return undefined;
|
|
128
128
|
}
|
|
129
|
+
// --- the hub's single use of a token (DEC-0084) ------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
const PROOF = /^[A-Za-z0-9_-]{85}[AQgw]$/;
|
|
132
|
+
const P256_HALF_N = 0x7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8n;
|
|
133
|
+
|
|
134
|
+
function es256Raw(octets, message, raw) {
|
|
135
|
+
if (octets.length !== 65 || octets[0] !== 4 || !isValidP256Key(octets) || raw.length !== 64) return false;
|
|
136
|
+
if (BigInt(`0x${raw.subarray(32).toString('hex')}`) > P256_HALF_N) return false;
|
|
137
|
+
const jwk = { kty: 'EC', crv: 'P-256', x: octets.subarray(1, 33).toString('base64url'), y: octets.subarray(33).toString('base64url') };
|
|
138
|
+
try { return crypto.verify('sha256', Buffer.from(message, 'utf8'), { key: jwk, format: 'jwk', dsaEncoding: 'ieee-p1363' }, raw); } catch { return false; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function proves(key, message, proof) {
|
|
142
|
+
if (!isString(proof) || !PROOF.test(proof)) return false;
|
|
143
|
+
const octets = Buffer.from(key.publicKey, 'base64');
|
|
144
|
+
const signature = Buffer.from(proof, 'base64url');
|
|
145
|
+
if (key.alg === 'es256') return es256Raw(octets, message, signature);
|
|
146
|
+
try {
|
|
147
|
+
const publicKey = crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: octets.toString('base64url') }, format: 'jwk' });
|
|
148
|
+
return crypto.verify(null, Buffer.from(message, 'utf8'), publicKey, signature);
|
|
149
|
+
} catch { return false; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The hub's single use of a verified token; `ledger` is a Map from nonce to {state: redeemed, fingerprint} or {state: revoked}. */
|
|
153
|
+
export function redeemEnrollToken(token, machineKey, proof, ledger) {
|
|
154
|
+
const input = enrollJoinInput(token);
|
|
155
|
+
if (input === undefined || !(ledger instanceof Map)) return { ok: false, problem: 'malformed' };
|
|
156
|
+
const { nonce } = statementFields(token);
|
|
157
|
+
if (machineOfKeys([machineKey]) === undefined) return { ok: false, problem: 'bad-key' };
|
|
158
|
+
if (!proves(machineKey, input, proof)) return { ok: false, problem: 'bad-proof' };
|
|
159
|
+
const fingerprint = deviceKeyFingerprint(machineKey);
|
|
160
|
+
const entry = ledger.get(nonce);
|
|
161
|
+
if (isObject(entry) && entry.state === 'revoked') return { ok: false, problem: 'revoked' };
|
|
162
|
+
if (entry !== undefined) return { ok: false, problem: 'reused', log: { nonce, presented: fingerprint, boundTo: isObject(entry) ? show(entry.fingerprint) : show(entry) } };
|
|
163
|
+
return { ok: true, record: { nonce, state: 'redeemed', fingerprint } };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- the nearby pairing (DEC-0084) ---------------------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
export const PAIRING_SCHEMA = 'pairing-message/1';
|
|
169
|
+
export const COMMIT_TYPE = 'fabric-enroll-commit/1';
|
|
170
|
+
export const CODE_TYPE = 'fabric-enroll-code/1';
|
|
171
|
+
export const FINGERPRINT_TYPE = 'estate-fingerprint/1';
|
|
172
|
+
export const CODE_DIGITS = 6;
|
|
173
|
+
export const FINGERPRINT_GROUPS = 5;
|
|
174
|
+
export const FINGERPRINT_GROUP_DIGITS = 5;
|
|
175
|
+
export const RENDEZVOUS_SECONDS = 600;
|
|
176
|
+
const MACHINE_ID = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}$/;
|
|
177
|
+
const KEY_MEMBERS = 'alg,kid,publicKey,use';
|
|
178
|
+
const ALGORITHMS = new Map([['sig', ['es256', 'ed25519']], ['kem', ['ecdh-p256']]]);
|
|
179
|
+
const sha = (text) => crypto.createHash('sha256').update(Buffer.from(text, 'utf8')).digest();
|
|
180
|
+
const uint40 = (digest, offset) => digest.readUIntBE(offset, 5);
|
|
181
|
+
const own = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
|
|
182
|
+
const isRole = (value) => value === 'new' || value === 'trusted';
|
|
183
|
+
function jsonEqual(a, b) {
|
|
184
|
+
if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]));
|
|
185
|
+
if (isObject(a) || isObject(b)) {
|
|
186
|
+
if (!isObject(a) || !isObject(b)) return false;
|
|
187
|
+
const keys = Object.keys(a);
|
|
188
|
+
return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && jsonEqual(a[key], b[key]));
|
|
189
|
+
}
|
|
190
|
+
return a === b;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The machine a key list belongs to, or undefined (one sig key, at most one kem key, one machine id, valid keys). */
|
|
194
|
+
export function machineOfKeys(keys) {
|
|
195
|
+
if (!Array.isArray(keys) || keys.length < 1 || keys.length > 2) return undefined;
|
|
196
|
+
const ids = new Set(); const uses = new Set(); const octets = new Set();
|
|
197
|
+
for (const key of keys) {
|
|
198
|
+
if (!isObject(key) || Object.keys(key).sort().join() !== KEY_MEMBERS) return undefined;
|
|
199
|
+
const { use, kid } = key;
|
|
200
|
+
if (!isString(use) || !isString(kid) || !isString(key.alg) || !ALGORITHMS.get(use)?.includes(key.alg)) return undefined;
|
|
201
|
+
const id = kid.endsWith(`#${use}`) ? kid.slice(0, -(use.length + 1)) : '';
|
|
202
|
+
if (!MACHINE_ID.test(id) || uses.has(use) || deviceKeyFingerprint(key) === undefined || !validDeviceKey(key) || octets.has(key.publicKey)) return undefined;
|
|
203
|
+
ids.add(id); uses.add(use); octets.add(key.publicKey);
|
|
204
|
+
}
|
|
205
|
+
const [id] = [...ids];
|
|
206
|
+
return ids.size === 1 && uses.has('sig') ? id : undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function sideProblem(side) {
|
|
210
|
+
if (!isObject(side)) return 'malformed';
|
|
211
|
+
const role = own(side, 'role');
|
|
212
|
+
if (!isRole(role)) return 'bad-role';
|
|
213
|
+
if (!matches(NONCE32, own(side, 'nonce'))) return 'bad-nonce';
|
|
214
|
+
if (machineOfKeys(own(side, 'keys')) === undefined) return 'bad-keys';
|
|
215
|
+
const genesis = own(side, 'genesis');
|
|
216
|
+
if (role === 'trusted' ? !matches(SHA256, genesis) : Object.hasOwn(side, 'genesis')) return 'bad-genesis';
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
const covered = (side) => (side.role === 'trusted' ? { keys: side.keys, nonce: side.nonce, genesis: side.genesis } : { keys: side.keys, nonce: side.nonce });
|
|
220
|
+
|
|
221
|
+
/** A side's commitment over {type, role, keys, nonce, genesis?}. */
|
|
222
|
+
export function pairingCommit(side) {
|
|
223
|
+
const problem = sideProblem(side);
|
|
224
|
+
if (problem) return { ok: false, problem };
|
|
225
|
+
return { ok: true, commit: `sha256:${sha(jcs({ type: COMMIT_TYPE, role: side.role, ...covered(side) })).toString('hex')}` };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function message(value, step) {
|
|
229
|
+
if (!isObject(value) || own(value, 'schema') !== PAIRING_SCHEMA || own(value, 'step') !== step) return undefined;
|
|
230
|
+
const members = Object.keys(value).sort().join();
|
|
231
|
+
if (step === 'commit') return members === 'commit,role,schema,step' && matches(SHA256, value.commit) ? value : undefined;
|
|
232
|
+
return members === 'keys,nonce,role,schema,step' || members === 'genesis,keys,nonce,role,schema,step' ? value : undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Check the other side's reveal against its commitment; answers the revealing machine's id. */
|
|
236
|
+
export function checkReveal(commit, reveal, expected) {
|
|
237
|
+
const c = message(commit, 'commit');
|
|
238
|
+
const r = message(reveal, 'reveal');
|
|
239
|
+
if (!c || !r) return { ok: false, problem: 'malformed' };
|
|
240
|
+
if (r.role !== c.role || (expected !== undefined && expected !== null && r.role !== expected)) return { ok: false, problem: 'role-mismatch' };
|
|
241
|
+
const { schema: _schema, step: _step, ...side } = r;
|
|
242
|
+
const made = pairingCommit(side);
|
|
243
|
+
if (!made.ok) return made;
|
|
244
|
+
if (made.commit !== c.commit) return { ok: false, problem: 'commit-mismatch' };
|
|
245
|
+
return { ok: true, machine: machineOfKeys(r.keys) };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The six-digit code both screens show. */
|
|
249
|
+
export function comparisonCode(newReveal, trustedReveal) {
|
|
250
|
+
const a = message(newReveal, 'reveal');
|
|
251
|
+
const b = message(trustedReveal, 'reveal');
|
|
252
|
+
if (!a || !b) return { ok: false, problem: 'malformed' };
|
|
253
|
+
if (a.role !== 'new' || b.role !== 'trusted') return { ok: false, problem: isRole(a.role) && isRole(b.role) ? 'role-mismatch' : 'bad-role' };
|
|
254
|
+
const problem = sideProblem(a) ?? sideProblem(b);
|
|
255
|
+
if (problem) return { ok: false, problem };
|
|
256
|
+
if (machineOfKeys(a.keys) === machineOfKeys(b.keys)) return { ok: false, problem: 'same-machine' };
|
|
257
|
+
const value = uint40(sha(jcs({ type: CODE_TYPE, new: covered(a), trusted: covered(b) })), 0) % 10 ** CODE_DIGITS;
|
|
258
|
+
return { ok: true, code: String(value).padStart(CODE_DIGITS, '0') };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The estate's fingerprint from revision 1's digest. */
|
|
262
|
+
export function estateFingerprint(digest) {
|
|
263
|
+
if (!matches(SHA256, digest)) return { ok: false, problem: 'malformed' };
|
|
264
|
+
const f = sha(jcs({ type: FINGERPRINT_TYPE, digest }));
|
|
265
|
+
return { ok: true, fingerprint: Array.from({ length: FINGERPRINT_GROUPS }, (_, i) => String(uint40(f, 5 * i) % 10 ** FINGERPRINT_GROUP_DIGITS).padStart(FINGERPRINT_GROUP_DIGITS, '0')).join(' ') };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The comparison code only after each reveal opened its own commitment. */
|
|
269
|
+
export function checkedComparisonCode(newCommit, newReveal, trustedCommit, trustedReveal) {
|
|
270
|
+
const a = checkReveal(newCommit, newReveal, 'new');
|
|
271
|
+
if (!a.ok) return a;
|
|
272
|
+
const b = checkReveal(trustedCommit, trustedReveal, 'trusted');
|
|
273
|
+
if (!b.ok) return b;
|
|
274
|
+
return comparisonCode(newReveal, trustedReveal);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A one-time rendezvous after its messages at `at` (a Date): open, complete, or burned with its reason and index. */
|
|
278
|
+
export function rendezvousVerdict(openedAt, messages, at) {
|
|
279
|
+
const opened = estateTime(openedAt);
|
|
280
|
+
const now = at instanceof Date ? at.getTime() : Number.NaN;
|
|
281
|
+
if (!Number.isFinite(opened) || !Number.isFinite(now) || !Array.isArray(messages)) return { state: 'burned', reason: 'malformed', index: -1 };
|
|
282
|
+
if (now > opened + RENDEZVOUS_SECONDS * 1000 || now < opened - CLOCK_SKEW_SECONDS * 1000) return { state: 'burned', reason: 'expired', index: -1 };
|
|
283
|
+
const commits = new Map();
|
|
284
|
+
const revealed = new Set();
|
|
285
|
+
for (const [index, item] of messages.entries()) {
|
|
286
|
+
const burn = (reason) => ({ state: 'burned', reason, index });
|
|
287
|
+
if (isObject(item) && own(item, 'schema') === PAIRING_SCHEMA && own(item, 'step') === 'abort' && Object.keys(item).sort().join() === 'role,schema,step' && isRole(item.role)) return burn('aborted');
|
|
288
|
+
if (revealed.size === 2) return burn('closed');
|
|
289
|
+
const commit = message(item, 'commit');
|
|
290
|
+
const reveal = message(item, 'reveal');
|
|
291
|
+
const role = (commit ?? reveal)?.role;
|
|
292
|
+
if (!isRole(role)) return burn('malformed');
|
|
293
|
+
if (commit) {
|
|
294
|
+
if (commits.has(role)) return burn('repeated');
|
|
295
|
+
commits.set(role, commit);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (revealed.has(role)) return burn('repeated');
|
|
299
|
+
if (commits.size < 2) return burn('out-of-order');
|
|
300
|
+
const checked = checkReveal(commits.get(role), reveal, role);
|
|
301
|
+
if (!checked.ok) return burn(checked.problem === 'commit-mismatch' ? 'commit-mismatch' : 'malformed');
|
|
302
|
+
revealed.add(role);
|
|
303
|
+
}
|
|
304
|
+
return revealed.size === 2 ? { state: 'complete' } : { state: 'open' };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const machineOf = (record, id) => (Array.isArray(record.machines) ? record.machines.find((machine) => isObject(machine) && machine.id === id) : undefined);
|
|
308
|
+
const machineRevoked = (record, id) => Array.isArray(record.revocations) && record.revocations.some((entry) => isObject(entry) && entry.kind === 'machine' && entry.ref === id);
|
|
309
|
+
const sameKeys = (a, b) => a.length === b.length && a.every((key) => b.some((other) => jsonEqual(key, other)));
|
|
310
|
+
|
|
311
|
+
/** What the new machine checks before it pins and applies a revision that adds it. */
|
|
312
|
+
export function joinListed(record, newReveal, trustedReveal, genesis) {
|
|
313
|
+
const a = message(newReveal, 'reveal');
|
|
314
|
+
const b = message(trustedReveal, 'reveal');
|
|
315
|
+
if (!isObject(record) || !a || !b || a.role !== 'new' || b.role !== 'trusted' || sideProblem(a) || sideProblem(b)) return { ok: false, problem: 'malformed' };
|
|
316
|
+
if (b.genesis !== genesis) return { ok: false, problem: 'other-estate' };
|
|
317
|
+
const newId = machineOfKeys(a.keys);
|
|
318
|
+
const trustedId = machineOfKeys(b.keys);
|
|
319
|
+
const machine = machineOf(record, newId);
|
|
320
|
+
if (!machine || machineRevoked(record, newId)) return { ok: false, problem: 'not-listed' };
|
|
321
|
+
if (!Array.isArray(machine.deviceKeys) || !sameKeys(machine.deviceKeys, a.keys)) return { ok: false, problem: 'other-keys' };
|
|
322
|
+
const trusted = machineOf(record, trustedId);
|
|
323
|
+
const sig = b.keys.find((key) => key.use === 'sig');
|
|
324
|
+
if (!trusted || machineRevoked(record, trustedId) || !Array.isArray(trusted.deviceKeys) || !trusted.deviceKeys.some((key) => jsonEqual(key, sig))) return { ok: false, problem: 'trusted-not-listed' };
|
|
325
|
+
return { ok: true };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** What a node does with a verified revision when its own keys are in question. */
|
|
329
|
+
export function ownKeysDecision(record, machineId, held) {
|
|
330
|
+
if (!isObject(record) || !isString(machineId) || !Array.isArray(held)) return { action: 'malformed' };
|
|
331
|
+
const machine = machineOf(record, machineId);
|
|
332
|
+
if (!machine || machineRevoked(record, machineId)) return { action: 'stop', reason: 'removed' };
|
|
333
|
+
const listed = Array.isArray(machine.deviceKeys) ? machine.deviceKeys : [];
|
|
334
|
+
if (listed.length === 0 || !listed.every((key) => held.some((mine) => jsonEqual(key, mine)))) return { action: 'stop', reason: 'keys-replaced' };
|
|
335
|
+
const retire = held.filter((key) => !listed.some((other) => jsonEqual(key, other))).map(deviceKeyRef).filter(isString);
|
|
336
|
+
return { action: 'apply', retire };
|
|
337
|
+
}
|
|
129
338
|
// #endregion ceremonies-kit-node
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs
CHANGED
|
@@ -412,7 +412,7 @@ export function deviceKeyRef(key) {
|
|
|
412
412
|
return fingerprint !== undefined && isString(kid) ? `${kid}@${fingerprint}` : undefined;
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
-
const validDeviceKey = (key) => {
|
|
415
|
+
export const validDeviceKey = (key) => {
|
|
416
416
|
const raw = Buffer.from(show(key.publicKey ?? ''), 'base64');
|
|
417
417
|
return key.alg === 'ed25519' ? raw.length === 32 && !smallOrder(raw) : isValidP256Key(raw);
|
|
418
418
|
};
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py
CHANGED
|
@@ -10,6 +10,9 @@ no trust; only a passkey-signed revision that lists it does. Its ``genesis`` aut
|
|
|
10
10
|
- ``verify_enroll_token(token, record=, genesis=, at=)``: the reference verifier, the hub's and the node's.
|
|
11
11
|
- ``enroll_join_input(token)``: the ``enroll-join/1`` statement the joining machine signs with its ``#sig`` key.
|
|
12
12
|
- ``enroll_token_rules("enroll-token", token)``: FAC-SEM-113.
|
|
13
|
+
- ``redeem_enroll_token(token, machine_key, proof, ledger)``: the hub's single use of a verified token.
|
|
14
|
+
- The nearby pairing: ``pairing_commit``, ``check_reveal``, ``comparison_code``, ``checked_comparison_code``,
|
|
15
|
+
``estate_fingerprint``, ``rendezvous_verdict``, ``join_listed``, ``own_keys_decision`` (``machine_of_keys``).
|
|
13
16
|
|
|
14
17
|
Every function answers and never raises on malformed input. Normative source: the contract's src/enroll-token.ts,
|
|
15
18
|
src/signing-challenges.ts and docs/specification/ceremonies.md.
|
|
@@ -18,6 +21,7 @@ src/signing-challenges.ts and docs/specification/ceremonies.md.
|
|
|
18
21
|
|
|
19
22
|
from __future__ import annotations
|
|
20
23
|
|
|
24
|
+
import base64
|
|
21
25
|
import hashlib
|
|
22
26
|
import re
|
|
23
27
|
import sys
|
|
@@ -191,4 +195,298 @@ def enroll_token_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
|
|
|
191
195
|
if kind == "enroll-token":
|
|
192
196
|
return _token_findings(value if isinstance(value, dict) else {})
|
|
193
197
|
return None
|
|
198
|
+
# --- the hub's single use of a token (DEC-0084) ----------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
_PROOF = re.compile(r"[A-Za-z0-9_-]{85}[AQgw]\Z")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _b64url(text: str) -> bytes:
|
|
204
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _es256_raw(octets: bytes, message: bytes, raw: bytes) -> bool:
|
|
208
|
+
"""ECDSA P-256 over SHA-256 with a 64-byte r || s signature, low-S only (relay-auth/1's form)."""
|
|
209
|
+
if len(octets) != 65 or octets[0] != 4 or not _estate.is_valid_p256_key(octets) or len(raw) != 64:
|
|
210
|
+
return False
|
|
211
|
+
r, s = int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big")
|
|
212
|
+
n = _estate._N
|
|
213
|
+
if not (1 <= r < n and 1 <= s <= n // 2):
|
|
214
|
+
return False
|
|
215
|
+
e = int.from_bytes(hashlib.sha256(message).digest(), "big")
|
|
216
|
+
w = pow(s, -1, n)
|
|
217
|
+
point = _estate._shamir(e * w % n, r * w % n, (int.from_bytes(octets[1:33], "big"), int.from_bytes(octets[33:], "big")))
|
|
218
|
+
return point is not None and point[0] % n == r
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _proves(key: Dict[str, Any], message: str, proof: Any) -> bool:
|
|
222
|
+
"""Whether ``proof`` is the presented key's signature over ``message``: 64 bytes in their one canonical base64url
|
|
223
|
+
form, ES256 as r || s low-S, Ed25519 as itself."""
|
|
224
|
+
if not isinstance(proof, str) or not _PROOF.match(proof):
|
|
225
|
+
return False
|
|
226
|
+
octets = base64.b64decode(key["publicKey"])
|
|
227
|
+
signature = _b64url(proof)
|
|
228
|
+
if key.get("alg") == "es256":
|
|
229
|
+
return _es256_raw(octets, message.encode("utf-8"), signature)
|
|
230
|
+
return _delegation.ed25519_verify(octets, message.encode("utf-8"), signature)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def redeem_enroll_token(token: Any, machine_key: Any, proof: Any, ledger: Any) -> Dict[str, Any]:
|
|
234
|
+
"""The hub's single use of a token verify_enroll_token accepted. ``machine_key`` is the joining machine's #sig key,
|
|
235
|
+
``proof`` its signature over ``enroll_join_input(token)``, ``ledger`` the hub's dict from nonce to entry
|
|
236
|
+
(``{"state": "redeemed", "fingerprint"}`` or ``{"state": "revoked"}``). The first failure answers: malformed,
|
|
237
|
+
bad-key, bad-proof, revoked, reused (with the log the hub records); the first use answers the record to store."""
|
|
238
|
+
join = enroll_join_input(token)
|
|
239
|
+
if join is None or not isinstance(ledger, dict):
|
|
240
|
+
return {"ok": False, "problem": "malformed"}
|
|
241
|
+
nonce = _statement_fields(token)["nonce"]
|
|
242
|
+
if machine_of_keys([machine_key]) is None:
|
|
243
|
+
return {"ok": False, "problem": "bad-key"}
|
|
244
|
+
if not _proves(machine_key, join, proof):
|
|
245
|
+
return {"ok": False, "problem": "bad-proof"}
|
|
246
|
+
fingerprint = _estate.device_key_fingerprint(machine_key)
|
|
247
|
+
entry = ledger.get(nonce, _estate._MISSING)
|
|
248
|
+
if isinstance(entry, dict) and entry.get("state") == "revoked":
|
|
249
|
+
return {"ok": False, "problem": "revoked"}
|
|
250
|
+
if entry is not _estate._MISSING:
|
|
251
|
+
bound = _estate._show(entry.get("fingerprint", _estate._MISSING)) if isinstance(entry, dict) else _estate._show(entry)
|
|
252
|
+
return {"ok": False, "problem": "reused", "log": {"nonce": nonce, "presented": fingerprint, "boundTo": bound}}
|
|
253
|
+
return {"ok": True, "record": {"nonce": nonce, "state": "redeemed", "fingerprint": fingerprint}}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# --- the nearby pairing (DEC-0084) ------------------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
PAIRING_SCHEMA = "pairing-message/1"
|
|
259
|
+
COMMIT_TYPE = "fabric-enroll-commit/1"
|
|
260
|
+
CODE_TYPE = "fabric-enroll-code/1"
|
|
261
|
+
FINGERPRINT_TYPE = "estate-fingerprint/1"
|
|
262
|
+
CODE_DIGITS = 6
|
|
263
|
+
FINGERPRINT_GROUPS = 5
|
|
264
|
+
FINGERPRINT_GROUP_DIGITS = 5
|
|
265
|
+
RENDEZVOUS_SECONDS = 600
|
|
266
|
+
_MACHINE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}\Z")
|
|
267
|
+
_KEY_MEMBERS = ["alg", "kid", "publicKey", "use"]
|
|
268
|
+
_ALGORITHMS = {"sig": ("es256", "ed25519"), "kem": ("ecdh-p256",)}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _json_equal(a: Any, b: Any) -> bool:
|
|
272
|
+
"""The contract's jsonEqual: arrays in order, objects by their members, primitives as JavaScript's ===."""
|
|
273
|
+
if isinstance(a, list) or isinstance(b, list):
|
|
274
|
+
return isinstance(a, list) and isinstance(b, list) and len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b))
|
|
275
|
+
if isinstance(a, dict) or isinstance(b, dict):
|
|
276
|
+
return isinstance(a, dict) and isinstance(b, dict) and len(a) == len(b) and all(k in b and _json_equal(a[k], b[k]) for k in a)
|
|
277
|
+
return _estate._same(a, b)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _is_role(value: Any) -> bool:
|
|
281
|
+
return value in ("new", "trusted") and isinstance(value, str)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def machine_of_keys(keys: Any) -> Optional[str]:
|
|
285
|
+
"""The machine a key list belongs to, or None: one or two keys, each exactly {kid, use, alg, publicKey}, one sig
|
|
286
|
+
(es256 or ed25519) and at most one kem (ecdh-p256), each kid <id>#<use> for one machine id, each key valid and in
|
|
287
|
+
canonical padded base64, no key in both roles."""
|
|
288
|
+
if not isinstance(keys, list) or not 1 <= len(keys) <= 2:
|
|
289
|
+
return None
|
|
290
|
+
ids, uses, octets = set(), set(), set()
|
|
291
|
+
for key in keys:
|
|
292
|
+
if not isinstance(key, dict) or sorted(key) != _KEY_MEMBERS:
|
|
293
|
+
return None
|
|
294
|
+
use, kid, alg = key["use"], key["kid"], key["alg"]
|
|
295
|
+
if not isinstance(use, str) or not isinstance(kid, str) or not isinstance(alg, str) or alg not in _ALGORITHMS.get(use, ()):
|
|
296
|
+
return None
|
|
297
|
+
machine = kid[: -(len(use) + 1)] if kid.endswith("#" + use) else ""
|
|
298
|
+
if (not _MACHINE_ID.match(machine) or use in uses or _estate.device_key_fingerprint(key) is None
|
|
299
|
+
or not _estate._valid_device_key(key) or key["publicKey"] in octets):
|
|
300
|
+
return None
|
|
301
|
+
ids.add(machine)
|
|
302
|
+
uses.add(use)
|
|
303
|
+
octets.add(key["publicKey"])
|
|
304
|
+
return next(iter(ids)) if len(ids) == 1 and "sig" in uses else None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _side_problem(side: Any) -> Optional[str]:
|
|
308
|
+
if not isinstance(side, dict):
|
|
309
|
+
return "malformed"
|
|
310
|
+
role = side.get("role")
|
|
311
|
+
if not _is_role(role):
|
|
312
|
+
return "bad-role"
|
|
313
|
+
if not _matches(_NONCE32, side.get("nonce")):
|
|
314
|
+
return "bad-nonce"
|
|
315
|
+
if machine_of_keys(side.get("keys")) is None:
|
|
316
|
+
return "bad-keys"
|
|
317
|
+
if role == "trusted":
|
|
318
|
+
if not _matches(_SHA256, side.get("genesis")):
|
|
319
|
+
return "bad-genesis"
|
|
320
|
+
elif "genesis" in side:
|
|
321
|
+
return "bad-genesis"
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _covered(side: Dict[str, Any]) -> Dict[str, Any]:
|
|
326
|
+
if side["role"] == "trusted":
|
|
327
|
+
return {"keys": side["keys"], "nonce": side["nonce"], "genesis": side["genesis"]}
|
|
328
|
+
return {"keys": side["keys"], "nonce": side["nonce"]}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _sha_hex(text: str) -> str:
|
|
332
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def pairing_commit(side: Any) -> Dict[str, Any]:
|
|
336
|
+
"""A side's commitment: sha256 of the RFC 8785 form of {type: fabric-enroll-commit/1, role, keys, nonce, genesis?}."""
|
|
337
|
+
problem = _side_problem(side)
|
|
338
|
+
if problem:
|
|
339
|
+
return {"ok": False, "problem": problem}
|
|
340
|
+
return {"ok": True, "commit": "sha256:" + _sha_hex(_delegation.jcs(dict({"type": COMMIT_TYPE, "role": side["role"]}, **_covered(side))))}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _message(value: Any, step: str) -> Optional[Dict[str, Any]]:
|
|
344
|
+
if not isinstance(value, dict) or value.get("schema") != PAIRING_SCHEMA or value.get("step") != step:
|
|
345
|
+
return None
|
|
346
|
+
members = ",".join(sorted(value))
|
|
347
|
+
if step == "commit":
|
|
348
|
+
return value if members == "commit,role,schema,step" and _matches(_SHA256, value.get("commit")) else None
|
|
349
|
+
return value if members in ("keys,nonce,role,schema,step", "genesis,keys,nonce,role,schema,step") else None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def check_reveal(commit: Any, reveal: Any, expected: Optional[str] = None) -> Dict[str, Any]:
|
|
353
|
+
"""Check the other side's reveal against its commitment; answers the revealing machine's id."""
|
|
354
|
+
c, r = _message(commit, "commit"), _message(reveal, "reveal")
|
|
355
|
+
if c is None or r is None:
|
|
356
|
+
return {"ok": False, "problem": "malformed"}
|
|
357
|
+
if not _estate._same(r["role"], c["role"]) or (expected is not None and not _estate._same(r["role"], expected)):
|
|
358
|
+
return {"ok": False, "problem": "role-mismatch"}
|
|
359
|
+
made = pairing_commit({k: v for k, v in r.items() if k not in ("schema", "step")})
|
|
360
|
+
if not made["ok"]:
|
|
361
|
+
return made
|
|
362
|
+
if made["commit"] != c["commit"]:
|
|
363
|
+
return {"ok": False, "problem": "commit-mismatch"}
|
|
364
|
+
return {"ok": True, "machine": machine_of_keys(r["keys"])}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def comparison_code(new_reveal: Any, trusted_reveal: Any) -> Dict[str, Any]:
|
|
368
|
+
"""The six-digit code both screens show, over both reveals' keys, nonces and the estate's genesis."""
|
|
369
|
+
a, b = _message(new_reveal, "reveal"), _message(trusted_reveal, "reveal")
|
|
370
|
+
if a is None or b is None:
|
|
371
|
+
return {"ok": False, "problem": "malformed"}
|
|
372
|
+
if not _estate._same(a["role"], "new") or not _estate._same(b["role"], "trusted"):
|
|
373
|
+
return {"ok": False, "problem": "role-mismatch" if _is_role(a["role"]) and _is_role(b["role"]) else "bad-role"}
|
|
374
|
+
problem = _side_problem(a) or _side_problem(b)
|
|
375
|
+
if problem:
|
|
376
|
+
return {"ok": False, "problem": problem}
|
|
377
|
+
if machine_of_keys(a["keys"]) == machine_of_keys(b["keys"]):
|
|
378
|
+
return {"ok": False, "problem": "same-machine"}
|
|
379
|
+
digest = hashlib.sha256(_delegation.jcs({"type": CODE_TYPE, "new": _covered(a), "trusted": _covered(b)}).encode("utf-8")).digest()
|
|
380
|
+
return {"ok": True, "code": str(int.from_bytes(digest[:5], "big") % 10 ** CODE_DIGITS).zfill(CODE_DIGITS)}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def estate_fingerprint(digest: Any) -> Dict[str, Any]:
|
|
384
|
+
"""The estate's fingerprint from revision 1's digest: five groups of five digits a person compares."""
|
|
385
|
+
if not _matches(_SHA256, digest):
|
|
386
|
+
return {"ok": False, "problem": "malformed"}
|
|
387
|
+
f = hashlib.sha256(_delegation.jcs({"type": FINGERPRINT_TYPE, "digest": digest}).encode("utf-8")).digest()
|
|
388
|
+
groups = [str(int.from_bytes(f[5 * i:5 * i + 5], "big") % 10 ** FINGERPRINT_GROUP_DIGITS).zfill(FINGERPRINT_GROUP_DIGITS) for i in range(FINGERPRINT_GROUPS)]
|
|
389
|
+
return {"ok": True, "fingerprint": " ".join(groups)}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def checked_comparison_code(new_commit: Any, new_reveal: Any, trusted_commit: Any, trusted_reveal: Any) -> Dict[str, Any]:
|
|
393
|
+
"""The comparison code only after each reveal opened its own commitment."""
|
|
394
|
+
a = check_reveal(new_commit, new_reveal, "new")
|
|
395
|
+
if not a["ok"]:
|
|
396
|
+
return a
|
|
397
|
+
b = check_reveal(trusted_commit, trusted_reveal, "trusted")
|
|
398
|
+
if not b["ok"]:
|
|
399
|
+
return b
|
|
400
|
+
return comparison_code(new_reveal, trusted_reveal)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def rendezvous_verdict(opened_at: Any, messages: Any, at: Any) -> Dict[str, Any]:
|
|
404
|
+
"""A one-time rendezvous after the messages it carried, at ``at`` (an aware datetime or epoch seconds): open,
|
|
405
|
+
complete, or burned with the reason and the index of the message that burned it."""
|
|
406
|
+
opened = _estate.estate_time(opened_at)
|
|
407
|
+
now = _estate._clock(at)
|
|
408
|
+
if not _finite(opened) or not _finite(now) or not isinstance(messages, list):
|
|
409
|
+
return {"state": "burned", "reason": "malformed", "index": -1}
|
|
410
|
+
if now > opened + RENDEZVOUS_SECONDS * 1000 or now < opened - _estate.CLOCK_SKEW_SECONDS * 1000:
|
|
411
|
+
return {"state": "burned", "reason": "expired", "index": -1}
|
|
412
|
+
commits: Dict[str, Any] = {}
|
|
413
|
+
revealed: set = set()
|
|
414
|
+
for index, item in enumerate(messages):
|
|
415
|
+
def burn(reason: str) -> Dict[str, Any]:
|
|
416
|
+
return {"state": "burned", "reason": reason, "index": index}
|
|
417
|
+
if (isinstance(item, dict) and item.get("schema") == PAIRING_SCHEMA and item.get("step") == "abort"
|
|
418
|
+
and sorted(item) == ["role", "schema", "step"] and _is_role(item.get("role"))):
|
|
419
|
+
return burn("aborted")
|
|
420
|
+
if len(revealed) == 2:
|
|
421
|
+
return burn("closed")
|
|
422
|
+
commit, reveal = _message(item, "commit"), _message(item, "reveal")
|
|
423
|
+
role = (commit or reveal or {}).get("role")
|
|
424
|
+
if not _is_role(role):
|
|
425
|
+
return burn("malformed")
|
|
426
|
+
if commit is not None:
|
|
427
|
+
if role in commits:
|
|
428
|
+
return burn("repeated")
|
|
429
|
+
commits[role] = commit
|
|
430
|
+
continue
|
|
431
|
+
if role in revealed:
|
|
432
|
+
return burn("repeated")
|
|
433
|
+
if len(commits) < 2:
|
|
434
|
+
return burn("out-of-order")
|
|
435
|
+
checked = check_reveal(commits.get(role), reveal, role)
|
|
436
|
+
if not checked["ok"]:
|
|
437
|
+
return burn("commit-mismatch" if checked["problem"] == "commit-mismatch" else "malformed")
|
|
438
|
+
revealed.add(role)
|
|
439
|
+
return {"state": "complete"} if len(revealed) == 2 else {"state": "open"}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _machine_of(record: Dict[str, Any], machine_id: Any) -> Optional[Dict[str, Any]]:
|
|
443
|
+
machines = record.get("machines")
|
|
444
|
+
if not isinstance(machines, list):
|
|
445
|
+
return None
|
|
446
|
+
return next((m for m in machines if isinstance(m, dict) and _estate._same(m.get("id", _estate._MISSING), machine_id)), None)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _machine_revoked(record: Dict[str, Any], machine_id: Any) -> bool:
|
|
450
|
+
revocations = record.get("revocations")
|
|
451
|
+
return isinstance(revocations, list) and any(isinstance(e, dict) and e.get("kind") == "machine"
|
|
452
|
+
and _estate._same(e.get("ref", _estate._MISSING), machine_id) for e in revocations)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def join_listed(record: Any, new_reveal: Any, trusted_reveal: Any, genesis: Any) -> Dict[str, Any]:
|
|
456
|
+
"""What the new machine checks before it pins and applies: the estate is the one the code bound, and the record
|
|
457
|
+
lists it with exactly the keys it revealed and the trusted machine with its revealed #sig key, revoking neither."""
|
|
458
|
+
a, b = _message(new_reveal, "reveal"), _message(trusted_reveal, "reveal")
|
|
459
|
+
if (not isinstance(record, dict) or a is None or b is None or not _estate._same(a["role"], "new")
|
|
460
|
+
or not _estate._same(b["role"], "trusted") or _side_problem(a) or _side_problem(b)):
|
|
461
|
+
return {"ok": False, "problem": "malformed"}
|
|
462
|
+
if not isinstance(genesis, str) or b["genesis"] != genesis:
|
|
463
|
+
return {"ok": False, "problem": "other-estate"}
|
|
464
|
+
new_id, trusted_id = machine_of_keys(a["keys"]), machine_of_keys(b["keys"])
|
|
465
|
+
machine = _machine_of(record, new_id)
|
|
466
|
+
if machine is None or _machine_revoked(record, new_id):
|
|
467
|
+
return {"ok": False, "problem": "not-listed"}
|
|
468
|
+
listed = machine.get("deviceKeys")
|
|
469
|
+
if not isinstance(listed, list) or len(listed) != len(a["keys"]) or not all(any(_json_equal(k, o) for o in a["keys"]) for k in listed):
|
|
470
|
+
return {"ok": False, "problem": "other-keys"}
|
|
471
|
+
trusted = _machine_of(record, trusted_id)
|
|
472
|
+
sig = next(k for k in b["keys"] if k["use"] == "sig")
|
|
473
|
+
if (trusted is None or _machine_revoked(record, trusted_id) or not isinstance(trusted.get("deviceKeys"), list)
|
|
474
|
+
or not any(_json_equal(k, sig) for k in trusted["deviceKeys"])):
|
|
475
|
+
return {"ok": False, "problem": "trusted-not-listed"}
|
|
476
|
+
return {"ok": True}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def own_keys_decision(record: Any, machine_id: Any, held: Any) -> Dict[str, Any]:
|
|
480
|
+
"""What a node does with a verified revision when its own keys are in question: apply (retiring the held keys the
|
|
481
|
+
record no longer lists, by key reference), stop as removed, stop as keys-replaced, or malformed."""
|
|
482
|
+
if not isinstance(record, dict) or not isinstance(machine_id, str) or not isinstance(held, list):
|
|
483
|
+
return {"action": "malformed"}
|
|
484
|
+
machine = _machine_of(record, machine_id)
|
|
485
|
+
if machine is None or _machine_revoked(record, machine_id):
|
|
486
|
+
return {"action": "stop", "reason": "removed"}
|
|
487
|
+
listed = machine.get("deviceKeys") if isinstance(machine.get("deviceKeys"), list) else []
|
|
488
|
+
if not listed or not all(any(_json_equal(k, mine) for mine in held) for k in listed):
|
|
489
|
+
return {"action": "stop", "reason": "keys-replaced"}
|
|
490
|
+
retire = [ref for ref in (_estate.device_key_ref(k) for k in held if not any(_json_equal(k, o) for o in listed)) if isinstance(ref, str)]
|
|
491
|
+
return {"action": "apply", "retire": retire}
|
|
194
492
|
# #endregion ceremonies-kit
|
|
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.37.
|
|
8
|
+
version: "0.37.1"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "07042181bfae2a481aa0da1a5651f24c76669c48"
|
|
11
11
|
---
|
|
@@ -66,7 +66,7 @@ WATCH_MAX_BACKOFF_S = 900
|
|
|
66
66
|
SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
|
|
67
67
|
LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
|
|
68
68
|
LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
|
|
69
|
-
VERSION = "0.37.
|
|
69
|
+
VERSION = "0.37.1"
|
|
70
70
|
HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
|
|
71
71
|
"hermes": "Hermes", "opencode": "OpenCode", "openclaw": "OpenClaw", "pi": "Pi"}
|
|
72
72
|
NATIVE_PLUGIN_HOSTS = frozenset(("hermes", "opencode", "openclaw", "pi"))
|