@passioncode-ai/passioncode 0.1.45 → 0.1.47
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 +27 -0
- package/README.md +6 -0
- package/SECURITY.md +6 -1
- package/family.json +1 -1
- package/lib/launcher.js +212 -3
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +3 -3
- package/payload/manifest.json +11 -10
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -2
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +43 -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/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -7
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +32 -15
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +46 -14
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +5 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +35 -20
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +77 -9
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +167 -36
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -19
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +107 -16
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +270 -49
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +8 -2
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +217 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +99 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +111 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1265 -0
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -43,12 +43,16 @@ It checks, in the order of the ER table:
|
|
|
43
43
|
parent, and no revocation dropped.
|
|
44
44
|
5. **The signer.** The signing passkey must be among the trusted ones and not revoked by the parent. A revision may
|
|
45
45
|
revoke its own signer: that is rotation.
|
|
46
|
-
6. **The assertion.**
|
|
46
|
+
6. **The assertion.** The passkey is a signing credential (DEC-0063): an RP ID that is not a dedicated signing RP,
|
|
47
|
+
`sign.<domain>` in lower case, is `wrong-rp` before the assertion is read, and an origin outside the RP ID is not
|
|
48
|
+
pinned, so a ceremony that ran there is `wrong-origin`.
|
|
47
49
|
- Client data: type, challenge, origin, no cross-origin use.
|
|
48
50
|
- The RP ID hash, and the user-presence and user-verification flags.
|
|
49
51
|
- The signature: ES256 or EdDSA, under a key validated first (a valid P-256 point; an Ed25519 key of full order).
|
|
50
|
-
7. **The window.** At most 30 days.
|
|
51
|
-
|
|
52
|
+
7. **The window.** At most 30 days. A revision may be issued up to 60 s ahead of the verifier's clock (DEC-0066,
|
|
53
|
+
`CLOCK_SKEW_SECONDS`); earlier than that it is `not-yet-valid`.
|
|
54
|
+
8. **The record's own rules.** A violation is `rule-violation` with its `codes`, `FAC-SEM-072…076` and `FAC-SEM-092`
|
|
55
|
+
(each passkey the record lists is a signing credential, its origins within its RP ID).
|
|
52
56
|
|
|
53
57
|
Dates are strict RFC 3339: a `T`, an offset, and a day that exists. `:60` and a space are refused. Passkey JWK `x`
|
|
54
58
|
and `y` must be canonical base64url. The Python kit verifies ES256 in pure Python (P-256, strict DER), and the Node
|
|
@@ -56,22 +60,35 @@ kit through `node:crypto`.
|
|
|
56
60
|
|
|
57
61
|
## Trusting a delegation key
|
|
58
62
|
|
|
59
|
-
A delegation verifier that reads the estate record trusts a token's `kid` only while
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
A delegation verifier that reads the estate record trusts a token's `kid` only while the revision it reads certifies
|
|
64
|
+
it (DL-14, contract DEC-0060): exactly one machine's `hubKey` has that `kid` and the published key's `x`, the clock is
|
|
65
|
+
inside the revision's window (from 60 s before `issuedAt`, DEC-0066, to `validUntil`), and neither the revision nor the
|
|
66
|
+
revocation delta's entries applied since it revoke `hub-key <kid>` or `machine <id>`. It is checked right after the
|
|
67
|
+
signature (DL-13 row 4a). Give the gate the current verified revision, or a function that returns it, the highest
|
|
68
|
+
revision the machine applied, and the delta's revocations:
|
|
62
69
|
|
|
63
70
|
```python
|
|
64
|
-
gate = fd.delegation_gate(audience=..., jwks=keys, issuer=..., effects=..., estate=lambda: current
|
|
71
|
+
gate = fd.delegation_gate(audience=..., jwks=keys, issuer=..., effects=..., estate=lambda: current,
|
|
72
|
+
estate_applied={"revision": applied_number, "digest": applied_digest},
|
|
73
|
+
estate_revocations=lambda: delta_entries)
|
|
65
74
|
```
|
|
66
75
|
|
|
67
|
-
-
|
|
68
|
-
`approval-invalid`) with detail `kid-not-certified`.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
- **The token's fault** — no such certification, another key under that `kid`, a `kid` on two machines, a revoked hub
|
|
77
|
+
key or machine — is `unauthenticated` (for an approval, `approval-invalid`) with detail `kid-not-certified`.
|
|
78
|
+
- **The trust source's fault** is `issuer-unavailable`: `estate-unreadable` (a source that throws or answers nothing
|
|
79
|
+
readable, a revision without a number or digest, a window of zero or over 30 days, dates read strictly, `machines` or
|
|
80
|
+
`revocations` not arrays), `estate-outside-window`, `estate-rolled-back` (lower than the applied revision) and
|
|
81
|
+
`estate-forked` (the applied number with another digest). A caller cannot cure these by minting again.
|
|
82
|
+
- An unreadable clock — not a finite number of seconds a `Date` can hold — is refused `unknown-state` `no-clock`
|
|
83
|
+
before row 1, in every verifier.
|
|
84
|
+
- Only an absent `estate` skips the check. In the Node kit `null` is an unreadable source; in Python, where `None` is
|
|
85
|
+
the absent value, pass a function returning `None` for a source that has nothing.
|
|
86
|
+
- **What you pass in.** Pass only a revision that `verify_estate_record` accepted and whose number is not lower than
|
|
87
|
+
the highest the machine applied; a higher one only when you verified its chain from the applied revision.
|
|
88
|
+
- `hub_key_certified(record, kid, x, at, applied, extra_revocations)` /
|
|
89
|
+
`hubKeyCertified(record, kid, x, at, applied, {extraRevocations})` answers the question on its own:
|
|
90
|
+
`{certified: true, machine, revision}` or `{certified: false, fault, detail}`. Both kits are held to the contract's
|
|
91
|
+
`fixtures/vectors/hub-key.json` (`test/fixtures/contract/hub-key-vectors.json`).
|
|
75
92
|
|
|
76
93
|
## Checking a record you produce
|
|
77
94
|
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -73,12 +73,36 @@ operator's real jobs.
|
|
|
73
73
|
## One copy under a supervisor
|
|
74
74
|
|
|
75
75
|
Started by hand, a copy that finds `service.lock` held prints one sentence naming the
|
|
76
|
-
holder
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
holder (or saying the holder is unknown, when its pid cannot be read) and exits 75 at once.
|
|
77
|
+
Under launchd that exit is a respawn every `ThrottleInterval` (10 s), forever, and under
|
|
78
|
+
systemd one every `RestartSec`. So a copy its supervisor started, and that says so with the
|
|
79
|
+
**supervisor marker**, backs off in-process — 0.5 s doubling to 30 s, 300 s in all — takes over
|
|
80
|
+
if the holder leaves, and exits 75 only when the wait runs out: a few starts an hour instead of
|
|
81
|
+
360. It touches nothing while it waits. Two installs sharing one data directory are still the
|
|
82
|
+
defect to remove; the back-off only stops the loop from costing the machine.
|
|
83
|
+
|
|
84
|
+
The marker (contract DEC-0057, CT-01) is the variable `FABRIC_SERVICE_SUPERVISOR=<manager>` or
|
|
85
|
+
the argument `--fabric-supervisor <manager>` (also `--fabric-supervisor=<manager>`), after the
|
|
86
|
+
executable and before any `--`; `<manager>` is `launchd`, `systemd`, `task-scheduler` or `node`
|
|
87
|
+
(Project Observatory's node, which supervises the services it starts). The writer of the
|
|
88
|
+
supervisor file writes it: `launchd_plist` and `systemd_unit` / `systemdUnit` set the variable,
|
|
89
|
+
always; `task_xml` / `taskXml` put the argument in the task's `Arguments`, because a task's action
|
|
90
|
+
carries no environment. Each writer refuses a marker already in the program arguments. A copy
|
|
91
|
+
never infers supervision from its parent process.
|
|
92
|
+
|
|
93
|
+
- `supervisor_marker(env, argv)` / `supervisorMarker({env, argv})` reads both forms and answers
|
|
94
|
+
`supervised` (with the manager), `manual` or `conflict`. Only an empty variable counts as no
|
|
95
|
+
marker; a bare `--fabric-supervisor` (followed by nothing, by `--` or by another `--…` flag,
|
|
96
|
+
none of which it consumes) names no manager.
|
|
97
|
+
- Markers that disagree — the variable and the argument with different values, the argument
|
|
98
|
+
twice, or a bare or empty argument beside the variable — exit 75 at once, before the lock and
|
|
99
|
+
whether or not it is held, naming both markers.
|
|
100
|
+
- A service's own argument parser accepts the flag in both spellings and ignores it: in Python,
|
|
101
|
+
`add_supervisor_argument(parser)` (argparse otherwise exits 2 on an unknown flag); the sample
|
|
102
|
+
service adds it to the parser and to each command.
|
|
103
|
+
- The kits are held to the contract's `fixtures/vectors/service-supervisor.json`, and their
|
|
104
|
+
starts against a held lock are recorded and read as `FAC-SEM-078` reads them
|
|
105
|
+
(`test/*supervisors*`).
|
|
82
106
|
|
|
83
107
|
## Directories
|
|
84
108
|
|
|
@@ -151,7 +175,7 @@ control character (a line break would end a unit line early).
|
|
|
151
175
|
**Linux — a systemd user unit.** `systemd_unit(unit, argv, working_directory=…)` /
|
|
152
176
|
`systemdUnit(unit, argv, {workingDirectory})` writes `Restart=always`, `RestartSec=10`,
|
|
153
177
|
`TimeoutStopSec` above the drain (15 s default), `KillMode=mixed`, `WantedBy=default.target`
|
|
154
|
-
and `FABRIC_SERVICE_SUPERVISOR=systemd
|
|
178
|
+
and `FABRIC_SERVICE_SUPERVISOR=systemd` (always, whatever the caller passed), quoting every argument (`%` → `%%`, `$` → `$$`).
|
|
155
179
|
`WorkingDirectory=` is written unquoted: systemd reads it as the rest of the line, so quotes would
|
|
156
180
|
become part of the path and the unit would be refused (`systemd-analyze verify` checks it in CI). The
|
|
157
181
|
descriptor says `"lifecycle": {"manager": "systemd", "unit": "<id>.<instance>.service"}`.
|
|
@@ -179,9 +203,10 @@ CI job has the real `schtasks` create, read back and delete such a task. The des
|
|
|
179
203
|
| stop, keep off | `schtasks /End /TN <task>`, then `schtasks /Change /TN <task> /DISABLE` |
|
|
180
204
|
| uninstall | `task_uninstall` / `taskUninstall`: `/End`, then `/Delete /F` |
|
|
181
205
|
|
|
182
|
-
A task cannot set an environment variable, so
|
|
183
|
-
|
|
184
|
-
|
|
206
|
+
A task cannot set an environment variable, so `task_xml` / `taskXml` write the marker as an
|
|
207
|
+
argument, `--fabric-supervisor task-scheduler`, before any `--`: a copy the Task Scheduler starts
|
|
208
|
+
backs off in-process like one under launchd or systemd. `IgnoreNew` keeps the scheduler from
|
|
209
|
+
starting a second copy.
|
|
185
210
|
|
|
186
211
|
**One copy on each system.** The Python kit takes `flock` (Linux) or a locked byte
|
|
187
212
|
(Windows); the Node kit cannot, so it holds a name instead — an abstract unix socket on Linux,
|
|
@@ -192,7 +217,14 @@ holds the same name beside its file lock, so the two kits exclude each other eve
|
|
|
192
217
|
|
|
193
218
|
**Token files on Windows.** The privacy of `service.token` is its ACL (service.md *Windows
|
|
194
219
|
token files*): the kits write a protected list — the user, SYSTEM and Administrators, set before the
|
|
195
|
-
token's bytes go in, any entry already there dropped
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
220
|
+
token's bytes go in, any entry already there dropped. On read, `windows_token_acl_problem` /
|
|
221
|
+
`windowsTokenAclProblem` decide over what was read — the file's flags, its owner SID and each ACE
|
|
222
|
+
as `{type, sid, mask}` (DEC-0057, `FAC-SEM-079`, held to the contract's
|
|
223
|
+
`fixtures/vectors/windows-token.json`): a reparse point, a directory, a real path outside the
|
|
224
|
+
profile, another owner, an allow ACE with a non-zero mask for any other SID, an ACE of another type
|
|
225
|
+
than allow or deny (an object or callback ACE), or anything unreadable is refused, naming the SID;
|
|
226
|
+
deny ACEs and an allow with an empty mask grant nothing; SIDs compare case-insensitively. The Python
|
|
227
|
+
kit reads the ACEs from the file's SDDL (`acl_from_sddl`); the Node kit reads them with Windows
|
|
228
|
+
PowerShell 5.1 (.NET's raw descriptor, no modules), converting .NET's signed mask to the unsigned
|
|
229
|
+
one, and writes the list with `icacls`; the path goes through the environment, never a command line.
|
|
230
|
+
An inherit-only ACE applies to children, not to the file, and is not read.
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# fabric-service/0.1 — wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0`
|
|
4
4
|
(`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
|
|
5
5
|
DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
|
|
6
6
|
delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
|
|
7
7
|
canonical forms DEC-0043; `degraded[].setup` and `run.mcp.stdio.scope` DEC-0044; delegation sealed and trust tiers
|
|
8
|
-
DEC-0045; runner and model profiles DEC-0046
|
|
8
|
+
DEC-0045; runner and model profiles DEC-0046; the estate record DEC-0055; one copy under any supervisor and its marker,
|
|
9
|
+
the Windows token-file reader DEC-0057; the hub key the estate certifies DEC-0060; signing credentials DEC-0063; the
|
|
10
|
+
estate record's 60 s skew DEC-0066). The contract's schemas are normative;
|
|
9
11
|
this page is the working summary. Extension key:
|
|
10
12
|
`https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
|
|
11
13
|
|
|
@@ -172,8 +172,11 @@ class Probe:
|
|
|
172
172
|
self.add("descriptor.valid", "FAIL" if problems else "PASS", "; ".join(problems) or str(self.path))
|
|
173
173
|
if fs.WINDOWS:
|
|
174
174
|
# DEC-0032: on Windows the file's ACL is its privacy.
|
|
175
|
-
owner,
|
|
176
|
-
|
|
175
|
+
owner, aces = fs.windows_acl(self.path)
|
|
176
|
+
info = os.lstat(self.path)
|
|
177
|
+
problem = fs.windows_token_acl_problem({"isRegularFile": stat.S_ISREG(info.st_mode), "isReparsePoint": stat.S_ISLNK(info.st_mode),
|
|
178
|
+
"realPathInProfile": True, "ownerSid": owner, "aces": aces}, fs.windows_user_sid())
|
|
179
|
+
problem = problem and problem["message"]
|
|
177
180
|
self.add("descriptor.private", "FAIL" if problem else "PASS", problem or "owner-only ACL")
|
|
178
181
|
else:
|
|
179
182
|
mode = stat.S_IMODE(os.stat(self.path).st_mode)
|
|
@@ -139,7 +139,7 @@ function keyFor(jwks, kid) {
|
|
|
139
139
|
if (raw.length !== 32) return { problem: 'unusable-key' };
|
|
140
140
|
if (isSmallOrderKey(raw)) return { problem: 'small-order-key' }; // before any verification (DEC-0045, CT-29)
|
|
141
141
|
try {
|
|
142
|
-
return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }), raw };
|
|
142
|
+
return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }), raw, x: jwk.x };
|
|
143
143
|
} catch {
|
|
144
144
|
return { problem: 'unusable-key' };
|
|
145
145
|
}
|
|
@@ -175,7 +175,7 @@ function authentic(token, jwks, typ) {
|
|
|
175
175
|
// Only an unknown kid is the token's fault; a published key that cannot be used is the issuer's (DL-13 row 3).
|
|
176
176
|
if ('problem' in found) return found.problem === 'unknown-key' ? { problem: found.problem } : { problem: found.problem, issuer: true };
|
|
177
177
|
if (compact.signature.length !== 64 || isSmallOrderKey(found.raw) || !crypto.verify(null, compact.signingInput, found.key, compact.signature)) return { problem: 'bad-signature' };
|
|
178
|
-
return { compact, kid };
|
|
178
|
+
return { compact, kid, x: found.x };
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
/** Depth of an actor chain; Infinity for a chain that is not one (a missing or non-string sub at any level). */
|
|
@@ -249,33 +249,47 @@ function approvalShape(payload) {
|
|
|
249
249
|
return payload;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/** ECMAScript's time range in seconds (±8.64e15 ms): a NumericDate outside it is no instant a Date can hold. */
|
|
253
|
+
const MAX_CLOCK_SECONDS = 8.64e12;
|
|
254
|
+
/** A clock the time rows can compare (DEC-0060): a finite number of seconds within a Date's range. NaN or a string would pass every row. */
|
|
255
|
+
const readableClock = (now) => typeof now === 'number' && Number.isFinite(now) && Math.abs(now) <= MAX_CLOCK_SECONDS;
|
|
256
|
+
|
|
252
257
|
/**
|
|
253
|
-
* DL-
|
|
254
|
-
* the
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
* verifyEstateRecord) or a function returning the current one; undefined skips the rule. `[kind, detail]` or null.
|
|
258
|
+
* DL-14 row 4a (DEC-0055, DEC-0060), right after the signature: when the callee reads the estate record, the token's
|
|
259
|
+
* `kid` and the published key must be certified by that revision. `estate` is a revision the caller verified
|
|
260
|
+
* (fabric-estate.mjs verifyEstateRecord) and its machine applied, or a function returning it; only an absent `estate`
|
|
261
|
+
* skips the rule (null is an unreadable source). `estateApplied` is the highest revision the machine applied,
|
|
262
|
+
* `estateRevocations` the revocation delta's entries since (an array or a function). A source that throws is
|
|
263
|
+
* unreadable, never a pass. `[kind, detail]` or null.
|
|
260
264
|
*/
|
|
261
|
-
function estateProblem(estate,
|
|
262
|
-
if (estate === undefined
|
|
265
|
+
function estateProblem({ estate, estateApplied, estateRevocations }, kid, x, now) {
|
|
266
|
+
if (estate === undefined) return null;
|
|
263
267
|
let record;
|
|
268
|
+
let extra;
|
|
264
269
|
try {
|
|
265
270
|
record = typeof estate === 'function' ? estate() : estate;
|
|
271
|
+
extra = typeof estateRevocations === 'function' ? estateRevocations() : estateRevocations;
|
|
266
272
|
} catch {
|
|
267
|
-
return ['issuer', 'estate-unreadable'];
|
|
273
|
+
return ['issuer', 'estate-unreadable'];
|
|
268
274
|
}
|
|
269
|
-
const
|
|
270
|
-
|
|
275
|
+
const verdict = hubKeyCertified(record, kid, x, now, estateApplied, extra === undefined ? undefined : { extraRevocations: extra });
|
|
276
|
+
// The clock was refused before row 1, so a verdict here is the token's or the trust source's.
|
|
277
|
+
return verdict.certified ? null : [verdict.fault === 'token' ? 'token' : 'issuer', verdict.detail];
|
|
271
278
|
}
|
|
272
279
|
|
|
273
|
-
|
|
280
|
+
/**
|
|
281
|
+
* DL-13, in its fixed order: nothing about a token is believed before its signature is. `now` is seconds since
|
|
282
|
+
* the epoch; a clock that is not one is refused `unknown-state` `no-clock` before row 1. Answers {ok: true, claims,
|
|
283
|
+
* principal, caller, chain, kid} or {ok: false, code, detail}.
|
|
284
|
+
*/
|
|
285
|
+
export function verifyDelegation(token, { audience, jwks, now, capability, effect, issuer, readOnly, estate, estateApplied, estateRevocations } = {}) {
|
|
286
|
+
// Before row 1: every time row compares against the clock, and a comparison with NaN is always false.
|
|
287
|
+
if (!readableClock(now)) return refuse('unknown-state', 'no-clock');
|
|
274
288
|
if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
|
|
275
289
|
if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
|
|
276
290
|
const checked = authentic(token, jwks, DELEGATION_TYP);
|
|
277
291
|
if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'unauthenticated', checked.problem);
|
|
278
|
-
const uncertified = estateProblem(estate,
|
|
292
|
+
const uncertified = estateProblem({ estate, estateApplied, estateRevocations }, checked.kid, checked.x, now);
|
|
279
293
|
if (uncertified) return refuse(uncertified[0] === 'issuer' ? 'issuer-unavailable' : 'unauthenticated', uncertified[1]);
|
|
280
294
|
const shaped = delegationShape(checked.compact.payload);
|
|
281
295
|
if ('problem' in shaped) return refuse('unauthenticated', shaped.problem);
|
|
@@ -295,14 +309,15 @@ export function verifyDelegation(token, { audience, jwks, now, capability, effec
|
|
|
295
309
|
}
|
|
296
310
|
|
|
297
311
|
/** DL-18: approval-required carries the digest; any fault is approval-invalid; the jti is consumed last. */
|
|
298
|
-
export function verifyApproval(approval, { audience, jwks, now, actionDigest: expected, issuer, jtiStore, estate } = {}) {
|
|
312
|
+
export function verifyApproval(approval, { audience, jwks, now, actionDigest: expected, issuer, jtiStore, estate, estateApplied, estateRevocations } = {}) {
|
|
299
313
|
if (approval === undefined || approval === null || approval === '') return refuse('approval-required', 'no-approval', { actionDigest: expected });
|
|
300
314
|
if (!isObject(jtiStore) || typeof jtiStore.consume !== 'function') return refuse('unknown-state', 'no-jti-store');
|
|
315
|
+
if (!readableClock(now)) return refuse('unknown-state', 'no-clock');
|
|
301
316
|
if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
|
|
302
317
|
if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
|
|
303
318
|
const checked = authentic(approval, jwks, APPROVAL_TYP);
|
|
304
319
|
if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'approval-invalid', checked.problem);
|
|
305
|
-
const uncertified = estateProblem(estate,
|
|
320
|
+
const uncertified = estateProblem({ estate, estateApplied, estateRevocations }, checked.kid, checked.x, now);
|
|
306
321
|
if (uncertified) return refuse(uncertified[0] === 'issuer' ? 'issuer-unavailable' : 'approval-invalid', uncertified[1]);
|
|
307
322
|
const claims = approvalShape(checked.compact.payload);
|
|
308
323
|
if (!claims) return refuse('approval-invalid', 'malformed-claims');
|
|
@@ -469,7 +484,7 @@ export const TIERS = Object.freeze(['T0', 'T1', 'T2']);
|
|
|
469
484
|
* relayed from off the machine). A T0 or T1 caller is never required to present a token: with none, the call passes
|
|
470
485
|
* with no delegation. A present token is verified at every tier. `issuer` (auth.delegation.issuer) is required.
|
|
471
486
|
*/
|
|
472
|
-
export function delegationGate({ audience, jwks, effects, issuer, readOnly = false, clock = () => Date.now() / 1000, estate }) {
|
|
487
|
+
export function delegationGate({ audience, jwks, effects, issuer, readOnly = false, clock = () => Date.now() / 1000, estate, estateApplied, estateRevocations }) {
|
|
473
488
|
if (!isString(issuer)) throw new Error('the gate needs the issuer it trusts (auth.delegation.issuer)');
|
|
474
489
|
return async (tool, headers, tier = 'T2') => {
|
|
475
490
|
const effect = effects?.[tool];
|
|
@@ -480,7 +495,7 @@ export function delegationGate({ audience, jwks, effects, issuer, readOnly = fal
|
|
|
480
495
|
return refuse('unauthenticated', 'no-token');
|
|
481
496
|
}
|
|
482
497
|
const keys = jwks instanceof JwksCache ? await jwks.get(kidOf(carried.token)) : jwks;
|
|
483
|
-
const verdict = verifyDelegation(carried.token, { audience, jwks: keys, now: clock(), capability: tool, effect, readOnly, issuer, estate });
|
|
498
|
+
const verdict = verifyDelegation(carried.token, { audience, jwks: keys, now: clock(), capability: tool, effect, readOnly, issuer, estate, estateApplied, estateRevocations });
|
|
484
499
|
if (verdict.ok) verdict.tier = TIERS.includes(tier) ? tier : 'T2';
|
|
485
500
|
return verdict;
|
|
486
501
|
};
|
|
@@ -17,6 +17,8 @@ import { secretValue } from './fabric-secrets.mjs';
|
|
|
17
17
|
export const ESTATE_RECORD_SCHEMA = 'estate-record/1';
|
|
18
18
|
export const ESTATE_CHALLENGE_TYPE = 'estate-revision/1';
|
|
19
19
|
export const MAX_VALIDITY_SECONDS = 30 * 24 * 60 * 60;
|
|
20
|
+
/** How far ahead of the verifier's clock a signed `issuedAt` may be before it is `not-yet-valid` (DEC-0066). */
|
|
21
|
+
export const CLOCK_SKEW_SECONDS = 60;
|
|
20
22
|
export const ASSERTION_ALGORITHMS = Object.freeze(['ES256', 'EdDSA']);
|
|
21
23
|
export const FLAGS = Object.freeze({ UP: 0x01, UV: 0x04, BE: 0x08, BS: 0x10, AT: 0x40, ED: 0x80 });
|
|
22
24
|
|
|
@@ -153,6 +155,34 @@ export function estateTime(value) {
|
|
|
153
155
|
if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return Number.NaN;
|
|
154
156
|
return Date.parse(value);
|
|
155
157
|
}
|
|
158
|
+
const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
159
|
+
/** `sign.` and at least two more labels, lower case: a signing RP ID is never a bare `sign.<tld>` (DEC-0063). */
|
|
160
|
+
const SIGNING_RP = new RegExp(`^sign(?:\\.${LABEL}){2,}$`);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether an RP ID is a dedicated signing RP (v4 §7, DEC-0063): first label `sign`, then the organization's or
|
|
164
|
+
* PassionCode's domain, lower case. A credential under any other RP ID is a sign-in credential and never signs.
|
|
165
|
+
*/
|
|
166
|
+
export const isSigningRpId = (rpId) => isString(rpId) && rpId.length <= 253 && SIGNING_RP.test(rpId);
|
|
167
|
+
|
|
168
|
+
/** Whether a pinned origin may run a signing ceremony for `rpId`: https, no port, no path, the RP ID or a host under it. */
|
|
169
|
+
export function originWithin(origin, rpId) {
|
|
170
|
+
const host = isString(origin) ? /^https:\/\/([a-z0-9.-]{1,253})$/.exec(origin)?.[1] : undefined;
|
|
171
|
+
return host !== undefined && (host === rpId || host.endsWith(`.${rpId}`));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** FAC-SEM-092 on one signing credential: a signing RP ID, and every pinned origin within it. */
|
|
175
|
+
function signingCredential(credential, path, flag) {
|
|
176
|
+
const value = isObject(credential) ? credential : {};
|
|
177
|
+
if (!isSigningRpId(value.rpId)) {
|
|
178
|
+
flag(`${path}/rpId`, 'a passkey signs only under a dedicated signing RP, sign.<domain>, never under the RP ID it signs in with');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
(Array.isArray(value.origins) ? value.origins : []).forEach((origin, index) => {
|
|
182
|
+
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`);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
156
186
|
const revocationKey = (entry) => `${String(entry.kind)} ${String(entry.ref)}`;
|
|
157
187
|
const clockOf = (at) => (at instanceof Date ? at.getTime() : (typeof at === 'number' && Number.isFinite(at) ? at * 1000 : Number.NaN));
|
|
158
188
|
|
|
@@ -206,13 +236,18 @@ export function verifyEstateRecord(record, trust) {
|
|
|
206
236
|
credentialId: String(authority.credentialId), alg: String(authority.alg), publicKey: isObject(authority.publicKey) ? authority.publicKey : {},
|
|
207
237
|
rpId: String(authority.rpId), origins: Array.isArray(authority.origins) ? authority.origins.filter(isString) : [],
|
|
208
238
|
};
|
|
239
|
+
// DEC-0063: the estate passkey is a signing credential. An RP ID that is not sign.<domain> is wrong-rp before the
|
|
240
|
+
// assertion is read; an origin outside the RP ID is not pinned, so a ceremony that ran there is wrong-origin.
|
|
241
|
+
if (!isSigningRpId(authority.rpId)) return { ok: false, problem: 'wrong-rp' };
|
|
242
|
+
credential.origins = credential.origins.filter((origin) => originWithin(origin, credential.rpId));
|
|
209
243
|
const assertion = verifyAssertion(signature, credential, estateChallenge(digest));
|
|
210
244
|
if (!assertion.ok) return { ok: false, problem: assertion.problem };
|
|
211
245
|
|
|
212
246
|
const issuedAt = estateTime(record.issuedAt);
|
|
213
247
|
const validUntil = estateTime(record.validUntil);
|
|
214
248
|
if (!Number.isFinite(issuedAt) || !Number.isFinite(validUntil) || validUntil <= issuedAt || validUntil - issuedAt > MAX_VALIDITY_SECONDS * 1000) return { ok: false, problem: 'too-long-lived' };
|
|
215
|
-
|
|
249
|
+
// A revision issued a moment ahead of this clock is admitted within the skew every relay time allows (DEC-0066).
|
|
250
|
+
if (at < issuedAt - CLOCK_SKEW_SECONDS * 1000) return { ok: false, problem: 'not-yet-valid' };
|
|
216
251
|
if (at > validUntil) return { ok: false, problem: 'expired' };
|
|
217
252
|
const codes = [...new Set(recordFindings(record).map((finding) => finding.code))];
|
|
218
253
|
if (codes.length > 0) return { ok: false, problem: 'rule-violation', codes };
|
|
@@ -227,19 +262,50 @@ export function placementTrust(record, machineId, agentRef) {
|
|
|
227
262
|
return { publisher: list(record.publisherKeys).filter((key) => key.kid === placement.publisherKid) };
|
|
228
263
|
}
|
|
229
264
|
|
|
265
|
+
const MAX_WINDOW_MS = MAX_VALIDITY_SECONDS * 1000;
|
|
266
|
+
const isRevision = (value) => Number.isSafeInteger(value) && value >= 1;
|
|
267
|
+
const refused = (fault, detail) => ({ certified: false, fault, detail });
|
|
268
|
+
|
|
230
269
|
/**
|
|
231
|
-
* DL-14
|
|
232
|
-
*
|
|
270
|
+
* DL-14 (DEC-0055, DEC-0060): whether `record` certifies the delegation key `kid` with the published key's `x` at `at`
|
|
271
|
+
* (a Date or epoch seconds), for a verifier whose machine applied `applied` (`{revision, digest?}`). `extraRevocations`
|
|
272
|
+
* are the revocation delta's entries applied since the revision (DEC-0054). The contract's `hubKeyCertified`, in its
|
|
273
|
+
* order; never throws. Answers `{certified: true, machine, revision}` or `{certified: false, fault, detail}`: fault
|
|
274
|
+
* `token` (`kid-not-certified`), `estate` (`estate-unreadable`, `estate-outside-window`, `estate-rolled-back`,
|
|
275
|
+
* `estate-forked`) or `clock` (`no-clock`).
|
|
233
276
|
*/
|
|
234
|
-
export function hubKeyCertified(record, kid, x, at) {
|
|
277
|
+
export function hubKeyCertified(record, kid, x, at, applied, options) {
|
|
235
278
|
const now = clockOf(at);
|
|
236
|
-
if (
|
|
279
|
+
if (Number.isNaN(now)) return refused('clock', 'no-clock');
|
|
280
|
+
if (!isObject(record) || !isRevision(record.revision) || !isString(record.digest) || !Array.isArray(record.machines)) return refused('estate', 'estate-unreadable');
|
|
281
|
+
if (record.revocations !== undefined && !Array.isArray(record.revocations)) return refused('estate', 'estate-unreadable');
|
|
282
|
+
const extra = options?.extraRevocations;
|
|
283
|
+
if (extra !== undefined && !Array.isArray(extra)) return refused('estate', 'estate-unreadable');
|
|
237
284
|
const issuedAt = estateTime(record.issuedAt);
|
|
238
285
|
const validUntil = estateTime(record.validUntil);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
286
|
+
// A window that is not a time, not positive or longer than 30 days is no window: NaN fails every comparison below.
|
|
287
|
+
if (Number.isNaN(issuedAt) || Number.isNaN(validUntil) || validUntil <= issuedAt || validUntil - issuedAt > MAX_WINDOW_MS) return refused('estate', 'estate-unreadable');
|
|
288
|
+
if (applied !== undefined) {
|
|
289
|
+
if (!isObject(applied) || !isRevision(applied.revision) || (applied.digest !== undefined && !isString(applied.digest))) return refused('estate', 'estate-unreadable');
|
|
290
|
+
// An older revision may still be inside its window and list a key the applied one revokes (a rollback).
|
|
291
|
+
if (record.revision < applied.revision) return refused('estate', 'estate-rolled-back');
|
|
292
|
+
if (record.revision === applied.revision && applied.digest !== undefined && record.digest !== applied.digest) return refused('estate', 'estate-forked');
|
|
293
|
+
}
|
|
294
|
+
// The window verifyEstateRecord admits the revision in: from 60 s before issuedAt to validUntil (DEC-0066).
|
|
295
|
+
if (now < issuedAt - CLOCK_SKEW_SECONDS * 1000 || now > validUntil) return refused('estate', 'estate-outside-window');
|
|
296
|
+
|
|
297
|
+
const revocations = [...(record.revocations ?? []), ...(extra ?? [])];
|
|
298
|
+
// A ref is compared exactly, never as a string conversion.
|
|
299
|
+
const revokes = (kind, ref) => revocations.some((entry) => isObject(entry) && entry.kind === kind && entry.ref === ref);
|
|
300
|
+
if (!isString(kid) || revokes('hub-key', kid)) return refused('token', 'kid-not-certified');
|
|
301
|
+
const holders = record.machines.filter((machine) => isObject(machine) && isObject(machine.hubKey) && machine.hubKey.kid === kid);
|
|
302
|
+
// Two machines under one kid break FAC-SEM-073; such a revision certifies neither.
|
|
303
|
+
if (holders.length !== 1) return refused('token', 'kid-not-certified');
|
|
304
|
+
const [machine] = holders;
|
|
305
|
+
// A missing x never equals a missing x: the published key must be the certified one.
|
|
306
|
+
if (!isString(x) || machine.hubKey.x !== x) return refused('token', 'kid-not-certified');
|
|
307
|
+
if (revokes('machine', machine.id)) return refused('token', 'kid-not-certified');
|
|
308
|
+
return { certified: true, machine: String(machine.id), revision: record.revision };
|
|
243
309
|
}
|
|
244
310
|
|
|
245
311
|
// ---- FAC-SEM-072…076 ----
|
|
@@ -396,6 +462,8 @@ function recordFindings(value) {
|
|
|
396
462
|
placements(value, flagger(findings, 'FAC-SEM-074'));
|
|
397
463
|
secretReferences(value, flagger(findings, 'FAC-SEM-075'));
|
|
398
464
|
grantsAndRevocations(value, flagger(findings, 'FAC-SEM-076'));
|
|
465
|
+
// FAC-SEM-092 (DEC-0063) on each passkey the record lists.
|
|
466
|
+
list(value.authorities).forEach((authority, index) => signingCredential(authority, `/authorities/${index}`, flagger(findings, 'FAC-SEM-092')));
|
|
399
467
|
return findings;
|
|
400
468
|
}
|
|
401
469
|
|