@cotal-ai/core 0.11.6 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acls.d.ts +31 -7
- package/dist/acls.d.ts.map +1 -1
- package/dist/acls.js +105 -17
- package/dist/acls.js.map +1 -1
- package/dist/agent-file.js +1 -1
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +90 -10
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js.map +1 -1
- package/dist/backup-config.d.ts +55 -0
- package/dist/backup-config.d.ts.map +1 -0
- package/dist/backup-config.js +243 -0
- package/dist/backup-config.js.map +1 -0
- package/dist/backup.d.ts +86 -0
- package/dist/backup.d.ts.map +1 -0
- package/dist/backup.js +497 -0
- package/dist/backup.js.map +1 -0
- package/dist/broker-floor.d.ts +34 -0
- package/dist/broker-floor.d.ts.map +1 -0
- package/dist/broker-floor.js +39 -0
- package/dist/broker-floor.js.map +1 -0
- package/dist/canonical.d.ts +24 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +159 -0
- package/dist/canonical.js.map +1 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +8 -1
- package/dist/channels.js.map +1 -1
- package/dist/connector.d.ts +4 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/endpoint-action.d.ts +424 -0
- package/dist/endpoint-action.d.ts.map +1 -0
- package/dist/endpoint-action.js +910 -0
- package/dist/endpoint-action.js.map +1 -0
- package/dist/endpoint-binding.d.ts +344 -0
- package/dist/endpoint-binding.d.ts.map +1 -0
- package/dist/endpoint-binding.js +753 -0
- package/dist/endpoint-binding.js.map +1 -0
- package/dist/endpoint-checkpoint.d.ts +292 -0
- package/dist/endpoint-checkpoint.d.ts.map +1 -0
- package/dist/endpoint-checkpoint.js +818 -0
- package/dist/endpoint-checkpoint.js.map +1 -0
- package/dist/endpoint-cluster.d.ts +95 -0
- package/dist/endpoint-cluster.d.ts.map +1 -0
- package/dist/endpoint-cluster.js +194 -0
- package/dist/endpoint-cluster.js.map +1 -0
- package/dist/endpoint-contract-store.d.ts +90 -0
- package/dist/endpoint-contract-store.d.ts.map +1 -0
- package/dist/endpoint-contract-store.js +348 -0
- package/dist/endpoint-contract-store.js.map +1 -0
- package/dist/endpoint-envelope.d.ts +165 -0
- package/dist/endpoint-envelope.d.ts.map +1 -0
- package/dist/endpoint-envelope.js +346 -0
- package/dist/endpoint-envelope.js.map +1 -0
- package/dist/endpoint-grants.d.ts +123 -0
- package/dist/endpoint-grants.d.ts.map +1 -0
- package/dist/endpoint-grants.js +215 -0
- package/dist/endpoint-grants.js.map +1 -0
- package/dist/endpoint-guard.d.ts +222 -0
- package/dist/endpoint-guard.d.ts.map +1 -0
- package/dist/endpoint-guard.js +468 -0
- package/dist/endpoint-guard.js.map +1 -0
- package/dist/endpoint-handle.d.ts +176 -0
- package/dist/endpoint-handle.d.ts.map +1 -0
- package/dist/endpoint-handle.js +608 -0
- package/dist/endpoint-handle.js.map +1 -0
- package/dist/endpoint-journal.d.ts +225 -0
- package/dist/endpoint-journal.d.ts.map +1 -0
- package/dist/endpoint-journal.js +431 -0
- package/dist/endpoint-journal.js.map +1 -0
- package/dist/endpoint-receipt.d.ts +225 -0
- package/dist/endpoint-receipt.d.ts.map +1 -0
- package/dist/endpoint-receipt.js +417 -0
- package/dist/endpoint-receipt.js.map +1 -0
- package/dist/endpoint-records.d.ts +243 -0
- package/dist/endpoint-records.d.ts.map +1 -0
- package/dist/endpoint-records.js +591 -0
- package/dist/endpoint-records.js.map +1 -0
- package/dist/endpoint-serve.d.ts +176 -0
- package/dist/endpoint-serve.d.ts.map +1 -0
- package/dist/endpoint-serve.js +446 -0
- package/dist/endpoint-serve.js.map +1 -0
- package/dist/endpoint-service.d.ts +518 -0
- package/dist/endpoint-service.d.ts.map +1 -0
- package/dist/endpoint-service.js +973 -0
- package/dist/endpoint-service.js.map +1 -0
- package/dist/endpoint-session.d.ts +398 -0
- package/dist/endpoint-session.d.ts.map +1 -0
- package/dist/endpoint-session.js +834 -0
- package/dist/endpoint-session.js.map +1 -0
- package/dist/endpoint-signing.d.ts +105 -0
- package/dist/endpoint-signing.d.ts.map +1 -0
- package/dist/endpoint-signing.js +190 -0
- package/dist/endpoint-signing.js.map +1 -0
- package/dist/endpoint-subjects.d.ts +222 -0
- package/dist/endpoint-subjects.d.ts.map +1 -0
- package/dist/endpoint-subjects.js +381 -0
- package/dist/endpoint-subjects.js.map +1 -0
- package/dist/endpoint-supervisor.d.ts +19 -0
- package/dist/endpoint-supervisor.d.ts.map +1 -0
- package/dist/endpoint-supervisor.js +12 -0
- package/dist/endpoint-supervisor.js.map +1 -0
- package/dist/endpoint-traits.d.ts +225 -0
- package/dist/endpoint-traits.d.ts.map +1 -0
- package/dist/endpoint-traits.js +469 -0
- package/dist/endpoint-traits.js.map +1 -0
- package/dist/endpoint-verbs.d.ts +277 -0
- package/dist/endpoint-verbs.d.ts.map +1 -0
- package/dist/endpoint-verbs.js +576 -0
- package/dist/endpoint-verbs.js.map +1 -0
- package/dist/endpoint-virtual.d.ts +308 -0
- package/dist/endpoint-virtual.d.ts.map +1 -0
- package/dist/endpoint-virtual.js +440 -0
- package/dist/endpoint-virtual.js.map +1 -0
- package/dist/endpoint-work.d.ts +304 -0
- package/dist/endpoint-work.d.ts.map +1 -0
- package/dist/endpoint-work.js +728 -0
- package/dist/endpoint-work.js.map +1 -0
- package/dist/endpoint.d.ts +66 -22
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +289 -89
- package/dist/endpoint.js.map +1 -1
- package/dist/evict.d.ts +95 -32
- package/dist/evict.d.ts.map +1 -1
- package/dist/evict.js +193 -3
- package/dist/evict.js.map +1 -1
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -1
- package/dist/lease.d.ts +4 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js.map +1 -1
- package/dist/members.d.ts +4 -4
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +12 -12
- package/dist/members.js.map +1 -1
- package/dist/provision.d.ts +78 -20
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +214 -37
- package/dist/provision.js.map +1 -1
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/safe-pattern.d.ts +33 -0
- package/dist/safe-pattern.d.ts.map +1 -0
- package/dist/safe-pattern.js +486 -0
- package/dist/safe-pattern.js.map +1 -0
- package/dist/schema-profile.d.ts +93 -0
- package/dist/schema-profile.d.ts.map +1 -0
- package/dist/schema-profile.js +284 -0
- package/dist/schema-profile.js.map +1 -0
- package/dist/secret-store.d.ts +46 -0
- package/dist/secret-store.d.ts.map +1 -0
- package/dist/secret-store.js +2 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/space-auth.d.ts +9 -0
- package/dist/space-auth.d.ts.map +1 -0
- package/dist/space-auth.js +126 -0
- package/dist/space-auth.js.map +1 -0
- package/dist/streams.d.ts +14 -10
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +47 -76
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +100 -40
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +136 -60
- package/dist/subjects.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -2
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CAPABILITY HANDLE (SPEC §13.6): the one passable reference type — a signed JSON grant,
|
|
3
|
+
* RFC 8785 canonical, Ed25519-signed by a key in the trust-anchor registry (§13.10).
|
|
4
|
+
*
|
|
5
|
+
* This module owns the transport-thin CORE of the handle: the artifact shape + closed-tuple
|
|
6
|
+
* validation, the NORMATIVE COMPILER (a grant entry → exactly the subjects the equivalent
|
|
7
|
+
* minted capability would receive, never wider — reads and instance rails included, every
|
|
8
|
+
* present signed component consumed), the ATTENUATION CONTAINMENT ORDER (a child handle MUST
|
|
9
|
+
* be ⊆ its parent), and CHAIN VERIFICATION (walk every parentDigest link to a registered
|
|
10
|
+
* anchor, fail closed on widening / unknown / revoked / expiry). Conferral — REDEMPTION
|
|
11
|
+
* through the trusted auth path, which fresh-checks the target triple against the current
|
|
12
|
+
* mapping and mints a ledgered short-lived credential — is the §9/§10 exchange's job (the D14
|
|
13
|
+
* auth-path slice); a handle grants NO broker reach here, only narrows.
|
|
14
|
+
*
|
|
15
|
+
* Verification discipline (§13.10 D28): the signature and the parentDigest identity are
|
|
16
|
+
* checked over the EXACT RAW presented artifacts, never a reconstructed projection — the
|
|
17
|
+
* parsed projection is for SEMANTICS only. Issuer authority is the anchor's STRUCTURED
|
|
18
|
+
* `handles` scope (ceiling entries in the handle-grant shape itself); coverage is the SAME
|
|
19
|
+
* §13.6 containment order (`handle.grants ⊆ anchor.scope`). A child link's issuer key must be
|
|
20
|
+
* lifecycle-bound to the parent's holder (owner text alone would let a recycled alias issue
|
|
21
|
+
* off its predecessor's handles). Every link — not only the leaf — is currency-checked
|
|
22
|
+
* (window, TTL ceilings clock-anchored at `now`, live-epoch), sturdy revocation is
|
|
23
|
+
* strict-`false`-only (an unreadable status is REVOKED), and the whole walk is bounded
|
|
24
|
+
* (chain length + ONE total await budget across the walk).
|
|
25
|
+
*
|
|
26
|
+
* Two uses, both fail-closed: ATTENUATION (presented in the `auth` slot — the handler enforces
|
|
27
|
+
* effective = presenter-cred ∩ handle.grants ∩ issuer-authority, never conferring reach) and
|
|
28
|
+
* CONFERRAL (redemption). Both rest on the same containment + chain verification here.
|
|
29
|
+
*/
|
|
30
|
+
import { contractDigest, isContractDigest, canonicalJson } from "./canonical.js";
|
|
31
|
+
import { EpEnvelopeError } from "./endpoint-envelope.js";
|
|
32
|
+
import { assertBoundedOwner, assertLifecycleToken, assertCommandToken, endpointToken, EP_AUTHZ_MODES } from "./endpoint-subjects.js";
|
|
33
|
+
import { verifyArtifactSignature, resolveAnchorForUse, assertArtifactCurrency } from "./endpoint-signing.js";
|
|
34
|
+
const isRec = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
35
|
+
function invalid(what) { throw new EpEnvelopeError("contract-invalid", `${what} (SPEC 13.6 capability handle)`); }
|
|
36
|
+
/** The §13.6 default validity ceilings (space-configurable): live ≤ 24h, sturdy default 30d. */
|
|
37
|
+
export const HANDLE_MAX_LIVE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
38
|
+
export const HANDLE_MAX_STURDY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
39
|
+
/** Verification bounds (fail loud past each; a bound reached is a refusal, never a truncation). */
|
|
40
|
+
export const HANDLE_MAX_CHAIN_LENGTH = 16;
|
|
41
|
+
export const HANDLE_MAX_BYTES = 64 * 1024;
|
|
42
|
+
export const HANDLE_MAX_GRANTS = 64;
|
|
43
|
+
export const HANDLE_MAX_COMMANDS_PER_GRANT = 64;
|
|
44
|
+
export const HANDLE_MAX_READS_PER_GRANT = 64;
|
|
45
|
+
/** Bounded canonical token grammars (§13.6 consuming boundary): id/space/keyId are bounded
|
|
46
|
+
* opaque tokens; the holder principal id is `owner` or `owner.actor` reverse-form. A garbled
|
|
47
|
+
* token is a CATALOG error (`contract-invalid`), never a raw validator throw. */
|
|
48
|
+
const HANDLE_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
49
|
+
function assertHandleToken(v, what) {
|
|
50
|
+
if (typeof v !== "string" || !HANDLE_TOKEN.test(v))
|
|
51
|
+
invalid(`${what} is not a bounded canonical token (1..128 of [A-Za-z0-9._:-], leading alnum)`);
|
|
52
|
+
return v;
|
|
53
|
+
}
|
|
54
|
+
function parseGrantCommand(raw) {
|
|
55
|
+
if (!isRec(raw))
|
|
56
|
+
invalid("a grant command is not an object");
|
|
57
|
+
const o = raw;
|
|
58
|
+
const allowed = new Set(["name", "authz", "targetOwner", "targetActor", "targetLifecycleUid"]);
|
|
59
|
+
for (const k of Object.keys(o))
|
|
60
|
+
if (!allowed.has(k))
|
|
61
|
+
invalid(`a grant command carries the unknown field "${k}" (closed schema)`);
|
|
62
|
+
if (typeof o.name !== "string")
|
|
63
|
+
invalid("a grant command has no string name");
|
|
64
|
+
assertCommandToken(o.name);
|
|
65
|
+
const hasOwner = o.targetOwner !== undefined, hasActor = o.targetActor !== undefined, hasUid = o.targetLifecycleUid !== undefined;
|
|
66
|
+
const hasAuthz = o.authz !== undefined;
|
|
67
|
+
// The closed set of three target shapes (§13.6). The parsed projection stays BYTE-FAITHFUL
|
|
68
|
+
// to the signed form (no default injection — `authz ?? "owner"` applies at USE time), so the
|
|
69
|
+
// projection can never diverge from the D28 signature input.
|
|
70
|
+
if (!hasOwner && !hasActor && !hasUid) {
|
|
71
|
+
// NO-TARGET: an `authz` field is schema-invalid.
|
|
72
|
+
if (hasAuthz)
|
|
73
|
+
invalid(`command "${o.name}" is a no-target entry but carries authz (schema-invalid: a no-target entry compiles to the untargeted/self form)`);
|
|
74
|
+
return { name: o.name };
|
|
75
|
+
}
|
|
76
|
+
if (hasOwner && !hasActor && !hasUid) {
|
|
77
|
+
// OWNER-DOMAIN: authz names owner|child|ledger (default owner); `any` is schema-invalid.
|
|
78
|
+
if (typeof o.targetOwner !== "string")
|
|
79
|
+
invalid(`command "${o.name}" targetOwner is not a string`);
|
|
80
|
+
assertBoundedOwner(o.targetOwner, `command "${o.name}" targetOwner`);
|
|
81
|
+
if (hasAuthz && o.authz !== "owner" && o.authz !== "child" && o.authz !== "ledger")
|
|
82
|
+
invalid(`command "${o.name}" authz "${String(o.authz)}" is not owner|child|ledger ("any" is operator-ceiling authority, never conferred through a handle)`);
|
|
83
|
+
return { name: o.name, ...(hasAuthz ? { authz: o.authz } : {}), targetOwner: o.targetOwner };
|
|
84
|
+
}
|
|
85
|
+
if (hasOwner && hasActor && hasUid) {
|
|
86
|
+
// ACTOR-PINNED FULL TRIPLE: `authz` is schema-invalid (the triple IS the mode).
|
|
87
|
+
if (hasAuthz)
|
|
88
|
+
invalid(`command "${o.name}" pins the full target triple but also carries authz (schema-invalid: the triple IS the handle mode)`);
|
|
89
|
+
if (typeof o.targetOwner !== "string" || typeof o.targetActor !== "string" || typeof o.targetLifecycleUid !== "string")
|
|
90
|
+
invalid(`command "${o.name}" target triple components must all be strings`);
|
|
91
|
+
assertBoundedOwner(o.targetOwner, `command "${o.name}" targetOwner`);
|
|
92
|
+
assertBoundedOwner(o.targetActor, `command "${o.name}" targetActor`);
|
|
93
|
+
assertLifecycleToken(o.targetLifecycleUid, `command "${o.name}" targetLifecycleUid`);
|
|
94
|
+
return { name: o.name, targetOwner: o.targetOwner, targetActor: o.targetActor, targetLifecycleUid: o.targetLifecycleUid };
|
|
95
|
+
}
|
|
96
|
+
// Every other combination is schema-invalid — in particular a partial tuple never weakens
|
|
97
|
+
// into a broader one (§13.6): targetActor without targetLifecycleUid, or targetLifecycleUid
|
|
98
|
+
// without targetActor, or owner+actor without uid.
|
|
99
|
+
invalid(`command "${o.name}" has a partial target tuple (${[hasOwner && "owner", hasActor && "actor", hasUid && "uid"].filter(Boolean).join("+")}); the legal shapes are no components, targetOwner alone, or the full triple; a partial tuple never weakens into a broader grant`);
|
|
100
|
+
}
|
|
101
|
+
/** A read scope names an exact record-key or event-topic SUBTREE (§13.6): dot-separated
|
|
102
|
+
* literal tokens in the ONE token grammar record keys are built from (the §3 subject charset,
|
|
103
|
+
* which the §13.7 key qualifiers use: [A-Za-z0-9_-], 1..64 per token), never a wildcard — the
|
|
104
|
+
* subtree semantics live in the containment order (dot-prefix), not in the entry. The grammar
|
|
105
|
+
* is exactly the key grammar on purpose: a wider charset would mint inert grants naming keys
|
|
106
|
+
* that can never exist (and that a conforming implementation refuses — a divergence in a
|
|
107
|
+
* normative compiler), while a narrower one would make legal keys (a `_`-leading goalId)
|
|
108
|
+
* unreachable by any grant. */
|
|
109
|
+
const READ_TOKEN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
110
|
+
function assertReadSubtree(r, endpoint) {
|
|
111
|
+
if (typeof r !== "string" || r.length === 0 || r.length > 256)
|
|
112
|
+
invalid(`grant entry for "${endpoint}" read scope is not a bounded string (1..256)`);
|
|
113
|
+
const tokens = r.split(".");
|
|
114
|
+
if (tokens.length > 16 || tokens.some((t) => !READ_TOKEN.test(t)))
|
|
115
|
+
invalid(`grant entry for "${endpoint}" read scope "${r}" is not a bounded literal dot-token subtree (at most 16 tokens of 1..64 [A-Za-z0-9_-]; no wildcards)`);
|
|
116
|
+
return r;
|
|
117
|
+
}
|
|
118
|
+
function parseGrant(raw) {
|
|
119
|
+
if (!isRec(raw))
|
|
120
|
+
invalid("a grant entry is not an object");
|
|
121
|
+
const o = raw;
|
|
122
|
+
for (const k of Object.keys(o))
|
|
123
|
+
if (!["endpoint", "instanceId", "commands", "reads"].includes(k))
|
|
124
|
+
invalid(`a grant entry carries the unknown field "${k}"`);
|
|
125
|
+
if (typeof o.endpoint !== "string")
|
|
126
|
+
invalid("a grant entry has no string endpoint");
|
|
127
|
+
endpointToken(o.endpoint);
|
|
128
|
+
if (o.instanceId !== undefined) {
|
|
129
|
+
try {
|
|
130
|
+
assertLifecycleToken(o.instanceId, "grant instanceId");
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
invalid(e.message);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(o.commands) || o.commands.length === 0)
|
|
137
|
+
invalid(`grant entry for "${o.endpoint}" has no commands`);
|
|
138
|
+
if (o.commands.length > HANDLE_MAX_COMMANDS_PER_GRANT)
|
|
139
|
+
invalid(`grant entry for "${o.endpoint}" exceeds ${HANDLE_MAX_COMMANDS_PER_GRANT} commands (bounded, never truncated)`);
|
|
140
|
+
const commands = o.commands.map(parseGrantCommand);
|
|
141
|
+
let reads;
|
|
142
|
+
if (o.reads !== undefined) {
|
|
143
|
+
if (!Array.isArray(o.reads))
|
|
144
|
+
invalid(`grant entry for "${o.endpoint}" has a non-array reads`);
|
|
145
|
+
if (o.reads.length > HANDLE_MAX_READS_PER_GRANT)
|
|
146
|
+
invalid(`grant entry for "${o.endpoint}" exceeds ${HANDLE_MAX_READS_PER_GRANT} read subtrees (bounded)`);
|
|
147
|
+
reads = o.reads.map((r) => assertReadSubtree(r, o.endpoint));
|
|
148
|
+
}
|
|
149
|
+
return { endpoint: o.endpoint, ...(o.instanceId !== undefined ? { instanceId: o.instanceId } : {}), commands, ...(reads !== undefined ? { reads } : {}) };
|
|
150
|
+
}
|
|
151
|
+
/** Validate a handle artifact's SHAPE (the closed-tuple rules + envelope), returning the typed
|
|
152
|
+
* frozen handle. This is the schema step; signature + chain + currency are the verify step
|
|
153
|
+
* ({@link verifyHandleChain}) — and those verify the RAW presented artifact, never this
|
|
154
|
+
* projection. A `sturdy: false` handle MUST carry `epoch`; a `sturdy: true` handle MUST NOT
|
|
155
|
+
* (§13.6: live binds the process epoch, sturdy binds the lifecycle UID). */
|
|
156
|
+
export function parseHandle(raw) {
|
|
157
|
+
if (!isRec(raw))
|
|
158
|
+
invalid("the handle is not an object");
|
|
159
|
+
const o = raw;
|
|
160
|
+
const allowed = new Set(["v", "id", "space", "issuer", "holder", "grants", "iat", "nbf", "exp", "parentDigest", "sturdy", "epoch", "sig"]);
|
|
161
|
+
for (const k of Object.keys(o))
|
|
162
|
+
if (!allowed.has(k))
|
|
163
|
+
invalid(`the handle carries the unknown field "${k}" (closed envelope)`);
|
|
164
|
+
if (o.v !== 1)
|
|
165
|
+
invalid("the handle version is not 1");
|
|
166
|
+
assertHandleToken(o.id, "the handle id");
|
|
167
|
+
assertHandleToken(o.space, "the handle space");
|
|
168
|
+
if (!isRec(o.issuer))
|
|
169
|
+
invalid("the handle issuer is not an object");
|
|
170
|
+
for (const k of Object.keys(o.issuer))
|
|
171
|
+
if (k !== "keyId")
|
|
172
|
+
invalid(`the handle issuer carries the unknown field "${k}" (closed schema)`);
|
|
173
|
+
assertHandleToken(o.issuer.keyId, "the handle issuer keyId");
|
|
174
|
+
if (!isRec(o.holder))
|
|
175
|
+
invalid("the handle holder is not an object");
|
|
176
|
+
for (const k of Object.keys(o.holder))
|
|
177
|
+
if (k !== "id" && k !== "lifecycleUid")
|
|
178
|
+
invalid(`the handle holder carries the unknown field "${k}" (closed schema)`);
|
|
179
|
+
assertHandleToken(o.holder.id, "the handle holder id");
|
|
180
|
+
{
|
|
181
|
+
const uid = o.holder.lifecycleUid;
|
|
182
|
+
if (typeof uid !== "string")
|
|
183
|
+
invalid("the handle holder lifecycleUid is not a string");
|
|
184
|
+
try {
|
|
185
|
+
assertLifecycleToken(uid, "the handle holder lifecycleUid");
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
invalid(e.message);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!Array.isArray(o.grants) || o.grants.length === 0)
|
|
192
|
+
invalid("the handle has no grants");
|
|
193
|
+
if (o.grants.length > HANDLE_MAX_GRANTS)
|
|
194
|
+
invalid(`the handle exceeds ${HANDLE_MAX_GRANTS} grant entries (bounded, never truncated)`);
|
|
195
|
+
// Any token-validator throw inside a grant becomes a CATALOG contract-invalid error (§13.6:
|
|
196
|
+
// the consuming boundary never surfaces a raw validator throw).
|
|
197
|
+
let grants;
|
|
198
|
+
try {
|
|
199
|
+
grants = o.grants.map(parseGrant);
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
if (e instanceof EpEnvelopeError)
|
|
203
|
+
throw e;
|
|
204
|
+
invalid(`a grant entry has an invalid token: ${e.message}`);
|
|
205
|
+
}
|
|
206
|
+
for (const f of ["iat", "exp"])
|
|
207
|
+
if (typeof o[f] !== "number" || !Number.isSafeInteger(o[f]) || o[f] < 0)
|
|
208
|
+
invalid(`the handle ${f} is not a non-negative safe integer`);
|
|
209
|
+
if (o.nbf !== undefined && (typeof o.nbf !== "number" || !Number.isSafeInteger(o.nbf) || o.nbf < 0))
|
|
210
|
+
invalid("the handle nbf is not a non-negative safe integer");
|
|
211
|
+
if (o.exp <= o.iat)
|
|
212
|
+
invalid("the handle exp is not after iat");
|
|
213
|
+
if (o.nbf !== undefined && o.nbf > o.exp)
|
|
214
|
+
invalid("the handle nbf is after exp (an empty validity window is garbled, not a short one)");
|
|
215
|
+
if (o.parentDigest !== undefined && (typeof o.parentDigest !== "string" || !isContractDigest(o.parentDigest)))
|
|
216
|
+
invalid("the handle parentDigest is not a sha256 digest");
|
|
217
|
+
if (typeof o.sturdy !== "boolean")
|
|
218
|
+
invalid("the handle sturdy flag is not a boolean");
|
|
219
|
+
if (o.sturdy === false && (typeof o.epoch !== "number" || !Number.isSafeInteger(o.epoch) || o.epoch < 0))
|
|
220
|
+
invalid("a LIVE handle (sturdy:false) MUST carry a non-negative integer epoch (it binds the process epoch)");
|
|
221
|
+
if (o.sturdy === true && o.epoch !== undefined)
|
|
222
|
+
invalid("a STURDY handle MUST NOT carry epoch (it binds the lifecycle UID, not a process epoch)");
|
|
223
|
+
if (typeof o.sig !== "string")
|
|
224
|
+
invalid("the handle has no sig");
|
|
225
|
+
return Object.freeze({
|
|
226
|
+
v: 1, id: o.id, space: o.space, issuer: { keyId: o.issuer.keyId },
|
|
227
|
+
holder: { id: o.holder.id, lifecycleUid: o.holder.lifecycleUid },
|
|
228
|
+
grants, iat: o.iat, ...(o.nbf !== undefined ? { nbf: o.nbf } : {}), exp: o.exp,
|
|
229
|
+
...(o.parentDigest !== undefined ? { parentDigest: o.parentDigest } : {}), sturdy: o.sturdy,
|
|
230
|
+
...(o.epoch !== undefined ? { epoch: o.epoch } : {}), sig: o.sig,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/** The handle's content address (`sha256:<hex>` over the full artifact incl. `sig`) — the
|
|
234
|
+
* identity a child's `parentDigest` references. Chain verification computes this over the
|
|
235
|
+
* RAW presented artifact ({@link verifyHandleChain}); this export is for issuance (building
|
|
236
|
+
* a child's `parentDigest` from the parent artifact you hold). */
|
|
237
|
+
export function handleDigest(handle) {
|
|
238
|
+
return contractDigest(handle);
|
|
239
|
+
}
|
|
240
|
+
/** Compile ONE grant command to the {@link EpTarget} the equivalent minted capability carries
|
|
241
|
+
* (never wider): a no-target command → the untargeted form or `.self` PER ITS CONTRACT; an
|
|
242
|
+
* owner-domain command → its authz mode pinning `targetOwner`; an actor-pinned command →
|
|
243
|
+
* `handle`-mode with the verified triple. Every present signed component is consumed. */
|
|
244
|
+
function compileTarget(cmd, contract) {
|
|
245
|
+
if (cmd.targetOwner === undefined)
|
|
246
|
+
return contract.noTargetForm === "self" ? { mode: "self" } : undefined;
|
|
247
|
+
if (cmd.targetActor !== undefined && cmd.targetLifecycleUid !== undefined)
|
|
248
|
+
return { mode: "handle", tOwner: cmd.targetOwner, tActor: cmd.targetActor, tUid: cmd.targetLifecycleUid };
|
|
249
|
+
return { mode: (cmd.authz ?? "owner"), tOwner: cmd.targetOwner };
|
|
250
|
+
}
|
|
251
|
+
/** Compile a handle's grants to the EpCapability set + read subtrees the equivalent minted
|
|
252
|
+
* capability would receive (§13.6). This is what a redemption mints and what attenuation
|
|
253
|
+
* intersects against; it NEVER widens and consumes EVERY signed component:
|
|
254
|
+
* - `routes` is set EXPLICITLY: an instance entry compiles to the exact `ep.inst` rails
|
|
255
|
+
* ONLY (`routes: []` — an instance pin never also grants the class rail); a class entry
|
|
256
|
+
* compiles to `routes: ["one"]` (scatter `all` is not expressible in a handle);
|
|
257
|
+
* - a NO-TARGET command's untargeted-vs-`.self` form and the `journal` rail come from the
|
|
258
|
+
* REQUIRED command-contract seam (the compiler never guesses either);
|
|
259
|
+
* - a JOURNAL-class command compiles to a journal-EXCLUSIVE capability (`routes: []`, no
|
|
260
|
+
* request rails): journal submissions ride ONLY `epj` (§13.9), so emitting a request row
|
|
261
|
+
* alongside would be equivalent-mint WIDENING. The frozen `epj` grammar has no instance
|
|
262
|
+
* coordinate, so an instance-pinned journal command is UNREPRESENTABLE and refuses — the
|
|
263
|
+
* signed instance pin is never silently dropped onto a class-wide journal row;
|
|
264
|
+
* - `reads` are returned alongside, endpoint-bound, never dropped. */
|
|
265
|
+
export function compileHandleGrants(handle, opts) {
|
|
266
|
+
if (typeof opts?.commandContract !== "function")
|
|
267
|
+
throw new EpEnvelopeError("failed-precondition", "compiling handle grants requires the command-contract seam; the compiler never guesses a command's no-target form or journal class (SPEC 13.6/13.9)");
|
|
268
|
+
const caps = [];
|
|
269
|
+
const reads = [];
|
|
270
|
+
for (const g of handle.grants) {
|
|
271
|
+
for (const cmd of g.commands) {
|
|
272
|
+
const contract = opts.commandContract(g.endpoint, cmd.name);
|
|
273
|
+
if (!contract || (contract.noTargetForm !== "untargeted" && contract.noTargetForm !== "self") || typeof contract.journal !== "boolean")
|
|
274
|
+
throw new EpEnvelopeError("internal", `the command-contract seam returned a malformed contract for ${g.endpoint}.${cmd.name}; a garbled contract never compiles (SPEC 13.6)`);
|
|
275
|
+
const target = compileTarget(cmd, contract);
|
|
276
|
+
if (contract.journal) {
|
|
277
|
+
if (g.instanceId !== undefined)
|
|
278
|
+
throw new EpEnvelopeError("permission-denied", `grant entry for "${g.endpoint}" pins instance "${g.instanceId}" but command "${cmd.name}" is journal-class; the frozen epj grammar has no instance coordinate, so the signed instance pin cannot be honored - unrepresentable, refused rather than widened (SPEC 13.6/13.9)`);
|
|
279
|
+
caps.push({
|
|
280
|
+
endpoint: g.endpoint, command: cmd.name,
|
|
281
|
+
routes: [],
|
|
282
|
+
...(target !== undefined ? { target } : {}),
|
|
283
|
+
journal: true,
|
|
284
|
+
});
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
caps.push({
|
|
288
|
+
endpoint: g.endpoint, command: cmd.name,
|
|
289
|
+
routes: g.instanceId !== undefined ? [] : ["one"],
|
|
290
|
+
...(g.instanceId !== undefined ? { instanceId: g.instanceId } : {}),
|
|
291
|
+
...(target !== undefined ? { target } : {}),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
for (const r of g.reads ?? [])
|
|
295
|
+
if (!reads.some((x) => x.endpoint === g.endpoint && x.subtree === r))
|
|
296
|
+
reads.push({ endpoint: g.endpoint, subtree: r });
|
|
297
|
+
}
|
|
298
|
+
return { caps, reads };
|
|
299
|
+
}
|
|
300
|
+
// ---- the attenuation containment order (§13.6: a child MUST be ⊆ its parent) ------------------
|
|
301
|
+
/** The command-mode lattice `self < owner < any`; `child`/`ledger`/`handle` are grantable in a
|
|
302
|
+
* child ONLY where the parent names the SAME mode (they are distinct validator-primary rails,
|
|
303
|
+
* never a widening of `owner`). */
|
|
304
|
+
const MODE_RANK = { self: 0, owner: 1, child: 1, ledger: 1, handle: 1, any: 2 };
|
|
305
|
+
function modeOf(cmd) {
|
|
306
|
+
if (cmd.targetOwner === undefined)
|
|
307
|
+
return "self"; // no-target ~ self/untargeted floor
|
|
308
|
+
if (cmd.targetActor !== undefined)
|
|
309
|
+
return "handle";
|
|
310
|
+
return (cmd.authz ?? "owner");
|
|
311
|
+
}
|
|
312
|
+
/** True iff a child command mode is ⊆ a parent command mode under the §13.6 order: never higher
|
|
313
|
+
* in `self < owner < any`, and `child`/`ledger`/`handle` only where the parent names the SAME
|
|
314
|
+
* mode. */
|
|
315
|
+
function modeContained(childMode, parentMode) {
|
|
316
|
+
if (childMode === parentMode)
|
|
317
|
+
return true;
|
|
318
|
+
if (childMode === "child" || childMode === "ledger" || childMode === "handle")
|
|
319
|
+
return false; // distinct rails: only same-mode
|
|
320
|
+
if (parentMode === "child" || parentMode === "ledger" || parentMode === "handle")
|
|
321
|
+
return false; // a distinct-rail parent confers only itself
|
|
322
|
+
return MODE_RANK[childMode] <= MODE_RANK[parentMode];
|
|
323
|
+
}
|
|
324
|
+
function targetComponentsContained(child, parent) {
|
|
325
|
+
// Each present PARENT component must be equal in the child; the child may NEWLY PIN a
|
|
326
|
+
// component the parent left open, never widen a pinned one to absent.
|
|
327
|
+
for (const f of ["targetOwner", "targetActor", "targetLifecycleUid"])
|
|
328
|
+
if (parent[f] !== undefined && parent[f] !== child[f])
|
|
329
|
+
return false;
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
function readsContained(childReads, parentReads) {
|
|
333
|
+
if (childReads === undefined || childReads.length === 0)
|
|
334
|
+
return true;
|
|
335
|
+
const parents = parentReads ?? [];
|
|
336
|
+
// Every child subtree must be subject-prefix-contained in some parent subtree (equal, or a
|
|
337
|
+
// strict `.`-delimited descendant — never a token-boundary-crossing string prefix).
|
|
338
|
+
return childReads.every((cr) => parents.some((pr) => cr === pr || cr.startsWith(pr + ".")));
|
|
339
|
+
}
|
|
340
|
+
/** The per-grant-entry half of the §13.6 containment order, shared by attenuation (child ⊆
|
|
341
|
+
* parent) and issuer-scope coverage (handle.grants ⊆ anchor.scope). Returns the FIRST
|
|
342
|
+
* widening as a why-string, or undefined when contained. */
|
|
343
|
+
function grantWidens(cg, ceiling) {
|
|
344
|
+
const pg = ceiling.find((p) => p.endpoint === cg.endpoint);
|
|
345
|
+
if (pg === undefined)
|
|
346
|
+
return `endpoint "${cg.endpoint}" is not granted`;
|
|
347
|
+
if (pg.instanceId !== undefined && pg.instanceId !== cg.instanceId)
|
|
348
|
+
return `endpoint "${cg.endpoint}" instance ${cg.instanceId ?? "(absent)"} != the pinned ${pg.instanceId}`;
|
|
349
|
+
if (!readsContained(cg.reads, pg.reads))
|
|
350
|
+
return `endpoint "${cg.endpoint}" reads are not subtree-contained`;
|
|
351
|
+
for (const cc of cg.commands) {
|
|
352
|
+
const pc = pg.commands.find((p) => p.name === cc.name);
|
|
353
|
+
if (pc === undefined)
|
|
354
|
+
return `command "${cg.endpoint}.${cc.name}" is not granted`;
|
|
355
|
+
if (!modeContained(modeOf(cc), modeOf(pc)))
|
|
356
|
+
return `command "${cg.endpoint}.${cc.name}" mode ${modeOf(cc)} is not ⊆ ${modeOf(pc)}`;
|
|
357
|
+
if (!targetComponentsContained(cc, pc))
|
|
358
|
+
return `command "${cg.endpoint}.${cc.name}" target components widen the granted ones`;
|
|
359
|
+
}
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
/** Assert a CHILD handle is ⊆ its PARENT under the §13.6 normative containment order. Per grant
|
|
363
|
+
* entry: endpoint equal (domain patterns are a future extension; this revision pins exact
|
|
364
|
+
* endpoints); `instanceId` equal or newly pinned (never widened to absent); commands a
|
|
365
|
+
* name-subset with per-command mode contained and target components equal-or-newly-pinned;
|
|
366
|
+
* reads subject-prefix-contained. Per envelope: same space; validity window within the
|
|
367
|
+
* parent's; `sturdy` only if the parent is sturdy. Throws `permission-denied` on any widening. */
|
|
368
|
+
export function assertHandleContainedIn(child, parent) {
|
|
369
|
+
const deny = (why) => { throw new EpEnvelopeError("permission-denied", `handle "${child.id}" widens its parent "${parent.id}": ${why} (SPEC 13.6 containment: a child MUST be ⊆ its parent)`); };
|
|
370
|
+
if (child.space !== parent.space)
|
|
371
|
+
deny(`space ${child.space} != ${parent.space}`);
|
|
372
|
+
if (child.exp > parent.exp)
|
|
373
|
+
deny(`exp ${child.exp} exceeds the parent's ${parent.exp}`);
|
|
374
|
+
const childNbf = child.nbf ?? child.iat, parentNbf = parent.nbf ?? parent.iat;
|
|
375
|
+
if (childNbf < parentNbf)
|
|
376
|
+
deny(`validity starts (${childNbf}) before the parent's (${parentNbf})`);
|
|
377
|
+
if (child.sturdy && !parent.sturdy)
|
|
378
|
+
deny("a sturdy child cannot descend from a live parent");
|
|
379
|
+
for (const cg of child.grants) {
|
|
380
|
+
const widens = grantWidens(cg, parent.grants);
|
|
381
|
+
if (widens !== undefined)
|
|
382
|
+
deny(widens);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// ---- issuer authority: the anchor's STRUCTURED handles scope (§13.10) -------------------------
|
|
386
|
+
/** Parse an anchor's `handles`-role scope: each entry is a canonical-JSON-encoded
|
|
387
|
+
* {@link HandleGrant} ceiling — "the full grant dimensions, in the handle-grant shape itself"
|
|
388
|
+
* (§13.10). An absent/empty dimension is CLOSED; a garbled entry never widens into an open
|
|
389
|
+
* ceiling. */
|
|
390
|
+
function parseAnchorHandleScope(anchor) {
|
|
391
|
+
const entries = anchor.scope?.handles;
|
|
392
|
+
if (entries === undefined || entries.length === 0)
|
|
393
|
+
throw new EpEnvelopeError("permission-denied", `signing key ${anchor.keyId} carries the "handles" role with no scope ceiling; an absent dimension is closed, not open (SPEC 13.10)`);
|
|
394
|
+
return entries.map((e) => {
|
|
395
|
+
let raw;
|
|
396
|
+
try {
|
|
397
|
+
raw = JSON.parse(e);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
throw new EpEnvelopeError("permission-denied", `signing key ${anchor.keyId} has a garbled handles-scope entry (not JSON); an unreadable ceiling never authorizes (SPEC 13.10)`);
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
return parseGrant(raw);
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
throw new EpEnvelopeError("permission-denied", `signing key ${anchor.keyId} has a garbled handles-scope entry (${err.message}); an unreadable ceiling never authorizes (SPEC 13.10)`);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/** Enforce §13.10 issuer authority for one handle: `handle.grants ⊆ anchor.scope` under the
|
|
411
|
+
* SAME §13.6 containment order — endpoints, per-command modes, target components, instance
|
|
412
|
+
* pins, and read subtrees are all ceiling dimensions; a flat endpoint/command list cannot
|
|
413
|
+
* express them and is exactly the laundering this refuses. */
|
|
414
|
+
export function assertIssuerScopeCoversHandle(anchor, handle) {
|
|
415
|
+
const ceiling = parseAnchorHandleScope(anchor);
|
|
416
|
+
for (const g of handle.grants) {
|
|
417
|
+
const widens = grantWidens(g, ceiling);
|
|
418
|
+
if (widens !== undefined)
|
|
419
|
+
throw new EpEnvelopeError("permission-denied", `signing key ${anchor.keyId}'s handles scope does not cover handle "${handle.id}": ${widens} (SPEC 13.10/13.6: handle.grants ⊆ anchor.scope, containment never widening)`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** Race an authority read against the REMAINING verification budget: a stuck registry/revocation
|
|
423
|
+
* authority is a bounded `unavailable` refusal, never a hung verification. The caller passes the
|
|
424
|
+
* remaining milliseconds of ONE total walk budget (never a per-await reset: sixteen links each
|
|
425
|
+
* burning a full fresh budget would multiply the ceiling); an exhausted budget refuses
|
|
426
|
+
* IMMEDIATELY, WITHOUT invoking the authority seam (the work is a THUNK, checked against the
|
|
427
|
+
* budget before it is started). Races `Promise.resolve(p)` unconditionally (a non-native
|
|
428
|
+
* thenable must not bypass the deadline), and NORMALIZES any lookup failure (a raw seam
|
|
429
|
+
* rejection) to a catalog `unavailable`, so retry classification is deterministic.
|
|
430
|
+
*
|
|
431
|
+
* Deferred (a shared-seam API change, tracked separately): on timeout the underlying in-flight
|
|
432
|
+
* read is not aborted (the {@link AnchorResolver} seam shared with signing/receipt/guard takes
|
|
433
|
+
* no AbortSignal). The verification still fails closed and bounds the CALLER; the residual is
|
|
434
|
+
* orphaned backend work, a resource concern, not an authorization one. */
|
|
435
|
+
async function withVerifyBudget(work, remainingMs, what) {
|
|
436
|
+
if (remainingMs <= 0)
|
|
437
|
+
throw new EpEnvelopeError("unavailable", `${what} found the verification budget already exhausted; the whole chain walk is ONE bounded operation and fails closed WITHOUT starting the read (SPEC 13.10)`);
|
|
438
|
+
let timer;
|
|
439
|
+
const deadline = new Promise((_, reject) => {
|
|
440
|
+
timer = setTimeout(() => reject(new EpEnvelopeError("unavailable", `${what} did not answer within the remaining ${remainingMs}ms verification budget; verification is bounded and fails closed (SPEC 13.10)`)), remainingMs);
|
|
441
|
+
});
|
|
442
|
+
try {
|
|
443
|
+
return await Promise.race([Promise.resolve(work()), deadline]); // work() invoked ONLY after the budget check
|
|
444
|
+
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
if (e instanceof EpEnvelopeError)
|
|
447
|
+
throw e;
|
|
448
|
+
throw new EpEnvelopeError("unavailable", `${what} failed: ${e?.message ?? String(e)}; a lookup failure is a bounded unavailable, never a raw error (SPEC 13.10)`);
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
clearTimeout(timer);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/** Verify a presented handle CHAIN inline (§13.6): the leaf plus every `parentDigest`-linked
|
|
455
|
+
* ancestor, presented together (no ambient fetch). Signature and digest identity are checked
|
|
456
|
+
* over the EXACT RAW presented artifacts (D28), never the parsed projection. For each link,
|
|
457
|
+
* leaf to root:
|
|
458
|
+
* - its `parentDigest` (if any) equals the digest of the NEXT presented RAW artifact (the
|
|
459
|
+
* chain is the one presented, not a forgeable claim);
|
|
460
|
+
* - each child is ⊆ its parent ({@link assertHandleContainedIn});
|
|
461
|
+
* - the ISSUER of a child is the PARENT's holder — anchor-registered with a `handles` role
|
|
462
|
+
* whose STRUCTURED scope covers the link ({@link assertIssuerScopeCoversHandle}) AND
|
|
463
|
+
* lifecycle-bound to the parent's holder (`ownerLifecycleUid`; owner text alone would let
|
|
464
|
+
* a recycled alias issue off its predecessor's handles — absent binding fails closed);
|
|
465
|
+
* - the signature verifies against the resolved anchor, within the anchor's window;
|
|
466
|
+
* - EVERY link is currency-checked: window (`nbf ≤ now ≤ exp`), no future `iat`, TTL span
|
|
467
|
+
* within the live/sturdy ceiling AND `exp ≤ now + ceiling` (clock-anchored: a backdated
|
|
468
|
+
* `nbf`/forward-dated `iat` cannot manufacture validity beyond the ceiling), space match;
|
|
469
|
+
* - EVERY sturdy link's revocation status is strict-`false`-checked (unreadable = revoked);
|
|
470
|
+
* - a LIVE leaf binds `presenterEpoch`; a LIVE ANCESTOR requires `resolveHolderEpoch` to
|
|
471
|
+
* fresh-check ITS holder's current epoch (a restarted intermediate kills the chain).
|
|
472
|
+
* The walk is bounded: chain length ≤ {@link HANDLE_MAX_CHAIN_LENGTH}, every await within
|
|
473
|
+
* `verifyBudgetMs` (default 5000). A ROOT handle (no parentDigest) is issued by an anchor
|
|
474
|
+
* whose owner is the handle's issuer principal. Returns the leaf and its compiled
|
|
475
|
+
* equivalent-mint bundle (never wider than any ancestor). */
|
|
476
|
+
export async function verifyHandleChain(chain, opts) {
|
|
477
|
+
if (!Array.isArray(chain) || chain.length === 0)
|
|
478
|
+
throw new EpEnvelopeError("permission-denied", "a handle chain is empty (SPEC 13.6: present the leaf plus every ancestor inline)");
|
|
479
|
+
if (chain.length > HANDLE_MAX_CHAIN_LENGTH)
|
|
480
|
+
throw new EpEnvelopeError("permission-denied", `the presented chain has ${chain.length} links; verification is bounded at ${HANDLE_MAX_CHAIN_LENGTH} (SPEC 13.6)`);
|
|
481
|
+
if (!Number.isSafeInteger(opts.now) || opts.now < 0)
|
|
482
|
+
throw new EpEnvelopeError("failed-precondition", `now must be a non-negative safe integer; got ${JSON.stringify(opts.now)}`);
|
|
483
|
+
const budget = opts.verifyBudgetMs ?? 5_000;
|
|
484
|
+
if (!Number.isSafeInteger(budget) || budget <= 0)
|
|
485
|
+
throw new EpEnvelopeError("failed-precondition", `verifyBudgetMs must be a positive integer; got ${JSON.stringify(opts.verifyBudgetMs)}`);
|
|
486
|
+
// Snapshot the FULL authority context into detached, frozen locals at entry, BEFORE the first
|
|
487
|
+
// await: a caller mutating opts.readRevocation / resolveAnchor / resolveHolderEpoch (or any
|
|
488
|
+
// scalar/presenter field) between links would otherwise swap the authority a single
|
|
489
|
+
// verification decides against. Every consuming read below uses ONLY these locals.
|
|
490
|
+
if (!isRec(opts.presenter) || typeof opts.presenter.id !== "string" || typeof opts.presenter.lifecycleUid !== "string")
|
|
491
|
+
throw new EpEnvelopeError("failed-precondition", "the presenter must carry a string id and lifecycleUid");
|
|
492
|
+
const A = Object.freeze({
|
|
493
|
+
now: opts.now,
|
|
494
|
+
space: opts.space,
|
|
495
|
+
presenter: Object.freeze({ id: opts.presenter.id, lifecycleUid: opts.presenter.lifecycleUid, epoch: opts.presenter.epoch }),
|
|
496
|
+
maxSturdyTtlMs: opts.maxSturdyTtlMs ?? HANDLE_MAX_STURDY_TTL_MS,
|
|
497
|
+
maxLiveTtlMs: opts.maxLiveTtlMs ?? HANDLE_MAX_LIVE_TTL_MS,
|
|
498
|
+
resolveHolderEpoch: opts.resolveHolderEpoch,
|
|
499
|
+
resolveAnchor: opts.resolveAnchor,
|
|
500
|
+
readRevocation: opts.readRevocation,
|
|
501
|
+
commandContract: opts.commandContract,
|
|
502
|
+
});
|
|
503
|
+
// ONE total walk budget on a MONOTONIC clock: every read races the REMAINING time, so sixteen
|
|
504
|
+
// links cannot each burn a fresh full budget (bounded as one operation, SPEC 13.10), and a
|
|
505
|
+
// wall-clock adjustment cannot extend the budget backward or exhaust it forward.
|
|
506
|
+
const startedAt = performance.now();
|
|
507
|
+
const remainingBudget = () => budget - (performance.now() - startedAt);
|
|
508
|
+
// Snapshot each RAW artifact to a detached, byte-bounded, deep-frozen copy at entry (D28: the
|
|
509
|
+
// signature and digest identity are checked over EXACTLY this snapshot; the parsed projection
|
|
510
|
+
// derives from the same bytes, so a projection/default can never be the signed value, and a
|
|
511
|
+
// mid-flight mutation of the caller's object cannot split verification from projection).
|
|
512
|
+
const raws = chain.map((r) => {
|
|
513
|
+
if (!isRec(r))
|
|
514
|
+
invalid("a chain element is not an object");
|
|
515
|
+
const bytes = new TextEncoder().encode(canonicalJson(r)); // throws on non-interchangeable I-JSON
|
|
516
|
+
if (bytes.length > HANDLE_MAX_BYTES)
|
|
517
|
+
invalid(`a chain artifact is ${bytes.length} bytes, over the ${HANDLE_MAX_BYTES} bound (bounded verification)`);
|
|
518
|
+
return Object.freeze(JSON.parse(new TextDecoder().decode(bytes)));
|
|
519
|
+
});
|
|
520
|
+
const handles = raws.map(parseHandle);
|
|
521
|
+
const leaf = handles[0];
|
|
522
|
+
// The presented chain must be leaf → … → root by parentDigest identity over the RAW
|
|
523
|
+
// artifacts (no ambient fetch, no forgeable parent claim, no reconstructed projection).
|
|
524
|
+
for (let i = 0; i < handles.length; i++) {
|
|
525
|
+
const h = handles[i];
|
|
526
|
+
const rawParent = raws[i + 1];
|
|
527
|
+
if (h.parentDigest === undefined) {
|
|
528
|
+
if (rawParent !== undefined)
|
|
529
|
+
throw new EpEnvelopeError("permission-denied", `handle "${h.id}" is a root (no parentDigest) but the chain presents further ancestors; a chain must terminate at its root (SPEC 13.6)`);
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
if (rawParent === undefined)
|
|
533
|
+
throw new EpEnvelopeError("permission-denied", `handle "${h.id}" names a parentDigest but no parent is presented; chains are presented inline in full (SPEC 13.6)`);
|
|
534
|
+
if (contractDigest(rawParent) !== h.parentDigest)
|
|
535
|
+
throw new EpEnvelopeError("permission-denied", `handle "${h.id}" parentDigest does not match the presented parent "${handles[i + 1].id}" (SPEC 13.6: the chain is the one presented, not a claim)`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// Leaf holder binding (the presenter IS the leaf's holder).
|
|
539
|
+
if (leaf.holder.id !== A.presenter.id || leaf.holder.lifecycleUid !== A.presenter.lifecycleUid)
|
|
540
|
+
throw new EpEnvelopeError("permission-denied", `handle "${leaf.id}" is holder-bound to ${leaf.holder.id}/${leaf.holder.lifecycleUid}; the presenter ${A.presenter.id}/${A.presenter.lifecycleUid} is not the holder; a recycled alias cannot present its predecessor's handles (SPEC 13.6)`);
|
|
541
|
+
// Walk every link: currency, containment, issuer authority, signature, revocation.
|
|
542
|
+
for (let i = 0; i < handles.length; i++) {
|
|
543
|
+
const h = handles[i];
|
|
544
|
+
const parent = handles[i + 1];
|
|
545
|
+
// Per-link currency (§13.6: expiry/ceilings fail closed on EVERY link, not only the leaf).
|
|
546
|
+
if (h.space !== A.space)
|
|
547
|
+
throw new EpEnvelopeError("permission-denied", `handle "${h.id}" is bound to space ${h.space}, not ${A.space} (SPEC 13.6)`);
|
|
548
|
+
// The shared §13.6/§13.10 window rules (assertArtifactCurrency); "opaque" because this runs
|
|
549
|
+
// BEFORE the link's signature check, so every refusal stays permission-denied.
|
|
550
|
+
assertArtifactCurrency({ iat: h.iat, ...(h.nbf !== undefined ? { nbf: h.nbf } : {}), exp: h.exp }, { now: A.now, ceilingMs: h.sturdy ? A.maxSturdyTtlMs : A.maxLiveTtlMs, what: `handle "${h.id}"`, ceilingName: h.sturdy ? "sturdy" : "live", refusals: "opaque" });
|
|
551
|
+
if (!h.sturdy) {
|
|
552
|
+
if (i === 0) {
|
|
553
|
+
if (h.epoch !== A.presenter.epoch)
|
|
554
|
+
throw new EpEnvelopeError("permission-denied", `live handle "${h.id}" binds process epoch ${h.epoch}; the presenter epoch is ${A.presenter.epoch} (live authority dies on restart, SPEC 13.1/13.6)`);
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
// A LIVE ANCESTOR binds ITS holder's process epoch: fresh-check it (a restarted
|
|
558
|
+
// intermediate kills every descendant, exactly like a revoked sturdy ancestor).
|
|
559
|
+
if (typeof A.resolveHolderEpoch !== "function")
|
|
560
|
+
throw new EpEnvelopeError("failed-precondition", `the chain contains a LIVE ancestor ("${h.id}") but no resolveHolderEpoch seam was supplied; a live link's epoch currency is never assumed (SPEC 13.6)`);
|
|
561
|
+
const resolveHolderEpoch = A.resolveHolderEpoch;
|
|
562
|
+
const current = await withVerifyBudget(() => resolveHolderEpoch(h.holder), remainingBudget(), `the holder-epoch resolver for "${h.id}"`);
|
|
563
|
+
if (current !== null && (typeof current !== "number" || !Number.isSafeInteger(current) || current < 0))
|
|
564
|
+
throw new EpEnvelopeError("internal", `the holder-epoch resolver returned ${JSON.stringify(current)}; a non-integer epoch never authorizes (SPEC 13.6)`);
|
|
565
|
+
if (current === null || current !== h.epoch)
|
|
566
|
+
throw new EpEnvelopeError("permission-denied", `live ancestor "${h.id}" binds its holder's process epoch ${h.epoch} but the current epoch is ${current === null ? "retired/unknown" : current}; live authority dies on restart, on every link (SPEC 13.6)`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (parent !== undefined)
|
|
570
|
+
assertHandleContainedIn(h, parent);
|
|
571
|
+
const anchor = await withVerifyBudget(() => resolveAnchorForUse(A.resolveAnchor, { keyId: h.issuer.keyId, role: "handles", at: A.now }), remainingBudget(), `the anchor-registry read for ${h.issuer.keyId}`);
|
|
572
|
+
// The issuer of a CHILD is the PARENT's holder — bound by LIFECYCLE, not owner text: a
|
|
573
|
+
// recycled alias (same id, new lifecycleUid) re-registering a key must never issue off its
|
|
574
|
+
// predecessor's handles. An anchor without the lifecycle binding fails closed here.
|
|
575
|
+
if (parent !== undefined) {
|
|
576
|
+
if (anchor.owner !== parent.holder.id)
|
|
577
|
+
throw new EpEnvelopeError("permission-denied", `handle "${h.id}" issuer key ${h.issuer.keyId} belongs to ${anchor.owner}, not the parent's holder ${parent.holder.id} (SPEC 13.6/13.10)`);
|
|
578
|
+
if (anchor.ownerLifecycleUid === undefined)
|
|
579
|
+
throw new EpEnvelopeError("permission-denied", `handle "${h.id}" issuer key ${h.issuer.keyId} is not lifecycle-bound in the anchor registry; child issuance requires the key's owning lifecycle so a recycled alias cannot issue off its predecessor's handles (SPEC 13.6/13.10: fail closed)`);
|
|
580
|
+
if (anchor.ownerLifecycleUid !== parent.holder.lifecycleUid)
|
|
581
|
+
throw new EpEnvelopeError("permission-denied", `handle "${h.id}" issuer key ${h.issuer.keyId} belongs to lifecycle ${anchor.ownerLifecycleUid}, not the parent holder's ${parent.holder.lifecycleUid}; a recycled alias cannot issue off its predecessor's handles (SPEC 13.6/13.10)`);
|
|
582
|
+
}
|
|
583
|
+
// A ROOT's issuer is the anchor's own owner (the root of trust): the handle carries no
|
|
584
|
+
// separate issuer-principal claim, so the root binding IS the resolved key — handles-roled,
|
|
585
|
+
// in-window, scope-covering; registry ownership semantics live in D18.
|
|
586
|
+
// The issuer key's STRUCTURED handles scope must cover this link's full grant dimensions.
|
|
587
|
+
assertIssuerScopeCoversHandle(anchor, h);
|
|
588
|
+
// D28: the signature verifies over the EXACT RAW presented artifact, never a projection.
|
|
589
|
+
verifyArtifactSignature(raws[i], anchor);
|
|
590
|
+
if (h.sturdy) {
|
|
591
|
+
const readRevocation = A.readRevocation;
|
|
592
|
+
const revoked = await withVerifyBudget(() => readRevocation(h.issuer.keyId, h.id), remainingBudget(), `the revocation read for "${h.id}"`);
|
|
593
|
+
if (revoked !== false)
|
|
594
|
+
throw new EpEnvelopeError("permission-denied", `a sturdy link in the chain is not provably unrevoked (handle "${h.id}", issuer ${h.issuer.keyId}, status ${JSON.stringify(revoked)}); ONLY a literal false is "not revoked"; an unreadable/undefined status fails closed, and every sturdy link is checked, not only the leaf (SPEC 13.6)`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return { leaf, compiled: compileHandleGrants(leaf, { commandContract: A.commandContract }) };
|
|
598
|
+
}
|
|
599
|
+
/** Serialize a handle to its canonical bytes (the wire/store form). Throws if the artifact is
|
|
600
|
+
* not interchangeable I-JSON (the strict canonical path), so a non-canonicalizable handle can
|
|
601
|
+
* never be persisted or presented. The parsed projection is byte-faithful to the signed form
|
|
602
|
+
* (parse injects nothing and drops nothing), so these bytes re-verify. */
|
|
603
|
+
export function serializeHandle(handle) {
|
|
604
|
+
return new TextEncoder().encode(canonicalJson(handle));
|
|
605
|
+
}
|
|
606
|
+
// re-export so a caller can spell the modes without reaching into subjects
|
|
607
|
+
export { EP_AUTHZ_MODES };
|
|
608
|
+
//# sourceMappingURL=endpoint-handle.js.map
|