haechi 0.9.0 → 1.0.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/README.ko.md +15 -10
- package/README.md +15 -10
- package/docs/current/api-stability.ko.md +87 -41
- package/docs/current/api-stability.md +87 -41
- package/docs/current/release-1.0-implementation-scope.ko.md +170 -0
- package/docs/current/release-1.0-implementation-scope.md +164 -0
- package/docs/current/risk-register-release-gate.ko.md +18 -6
- package/docs/current/risk-register-release-gate.md +18 -6
- package/docs/current/threat-model.ko.md +14 -1
- package/docs/current/threat-model.md +14 -1
- package/package.json +4 -3
- package/packages/audit/index.mjs +13 -1
- package/packages/auth/index.mjs +173 -0
- package/packages/cli/runtime.mjs +184 -5
- package/packages/core/index.mjs +19 -4
- package/packages/plugin/index.mjs +83 -17
- package/packages/plugin/sandbox.mjs +608 -0
- package/packages/plugin/signing.mjs +393 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
// The worker-isolated authProvider sandbox (Haechi 1.0 §2.3/§2.4/§7.4).
|
|
2
|
+
//
|
|
3
|
+
// HONEST MODEL (read packages/../docs/current/release-1.0-implementation-scope.md
|
|
4
|
+
// §1): node:worker_threads is NOT a capability sandbox. A worker shares the
|
|
5
|
+
// process and a malicious *signed* plugin can still use fs/net/process.env — that
|
|
6
|
+
// residual is accepted and gated ONLY by the PR2 signing/trust gate, never by the
|
|
7
|
+
// worker. What the worker DOES give us, and all this module claims:
|
|
8
|
+
// - V8-heap memory isolation (the plugin cannot read the host's crypto key,
|
|
9
|
+
// token vault, or audit sink — only a typed JSON-string message crosses);
|
|
10
|
+
// - crash/hang containment via resourceLimits + a per-call timeout that
|
|
11
|
+
// terminates the worker (a hang fails closed → deny);
|
|
12
|
+
// - data minimization (the worker receives ONLY the credential slice, never the
|
|
13
|
+
// request body / key / sink; the HOST builds the keyed-HMAC identity);
|
|
14
|
+
// - a narrow, audited, correlation-id'd contract.
|
|
15
|
+
//
|
|
16
|
+
// Zero runtime dependency: node:worker_threads + node:crypto + node:fs only, plus
|
|
17
|
+
// in-repo haechi/plugin (PR2 verify) and haechi/auth (identity + conformance).
|
|
18
|
+
|
|
19
|
+
import { Worker } from "node:worker_threads";
|
|
20
|
+
import { lstatSync, readFileSync, statSync } from "node:fs";
|
|
21
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
22
|
+
import { dirname, resolve as resolvePath, sep as pathSep } from "node:path";
|
|
23
|
+
import { verifySignedPlugin } from "./signing.mjs";
|
|
24
|
+
import { validatePluginManifest } from "./index.mjs";
|
|
25
|
+
import { assertAuthProviderConformance, buildExternalIdentity } from "../auth/index.mjs";
|
|
26
|
+
|
|
27
|
+
// The only own-enumerable keys the host accepts back from the worker. Anything
|
|
28
|
+
// else (incl. __proto__/constructor/prototype) is dropped at the boundary.
|
|
29
|
+
const CLAIM_ALLOWLIST = ["subject", "issuer", "type", "scopes", "labels"];
|
|
30
|
+
// Defensive bounds so a hostile claims object cannot blow up the host build.
|
|
31
|
+
const MAX_SCOPES = 64;
|
|
32
|
+
const MAX_LABELS = 32;
|
|
33
|
+
const MAX_STRING_LEN = 1024;
|
|
34
|
+
|
|
35
|
+
// The wire harness the host wraps around the worker so a generic codeString plugin
|
|
36
|
+
// only has to .on("message")/.postMessage JSON strings. Each plugin entry exports
|
|
37
|
+
// (default or named) `authenticate(credential) -> claims | { deny: true } | null`.
|
|
38
|
+
// We inline the harness as a string (NOT a path import) because the worker runs
|
|
39
|
+
// from the in-memory verified bytes — it has no module graph back to this repo,
|
|
40
|
+
// and a shipped packages/ file must never import a tests/ or scripts/ helper.
|
|
41
|
+
function workerHarness(entrySource) {
|
|
42
|
+
return [
|
|
43
|
+
"const { parentPort } = require('worker_threads');",
|
|
44
|
+
"let __plugin = null;",
|
|
45
|
+
"async function __load() {",
|
|
46
|
+
" if (__plugin) return __plugin;",
|
|
47
|
+
" const mod = await import('data:text/javascript;base64,' + " +
|
|
48
|
+
JSON.stringify(Buffer.from(entrySource, "utf8").toString("base64")) + ");",
|
|
49
|
+
" __plugin = (typeof mod.default === 'function') ? mod.default",
|
|
50
|
+
" : (typeof mod.authenticate === 'function') ? mod.authenticate",
|
|
51
|
+
" : (mod.default && typeof mod.default.authenticate === 'function') ? mod.default.authenticate",
|
|
52
|
+
" : null;",
|
|
53
|
+
" if (typeof __plugin !== 'function') throw new Error('plugin entry must export an authenticate function');",
|
|
54
|
+
" return __plugin;",
|
|
55
|
+
"}",
|
|
56
|
+
"parentPort.on('message', async (raw) => {",
|
|
57
|
+
" let cid = null;",
|
|
58
|
+
" try {",
|
|
59
|
+
" const msg = JSON.parse(raw);",
|
|
60
|
+
" cid = msg.cid;",
|
|
61
|
+
" const authenticate = await __load();",
|
|
62
|
+
" const out = await authenticate(msg.credential);",
|
|
63
|
+
" if (!out || out.deny === true || typeof out !== 'object') {",
|
|
64
|
+
" parentPort.postMessage(JSON.stringify({ cid, deny: true }));",
|
|
65
|
+
" return;",
|
|
66
|
+
" }",
|
|
67
|
+
" parentPort.postMessage(JSON.stringify({ cid, claims: out }));",
|
|
68
|
+
" } catch (err) {",
|
|
69
|
+
// A plugin throw NEVER propagates: it surfaces to the host as a deny.
|
|
70
|
+
" parentPort.postMessage(JSON.stringify({ cid, deny: true }));",
|
|
71
|
+
" }",
|
|
72
|
+
"});"
|
|
73
|
+
].join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sha256Hex(bytes) {
|
|
77
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Same parsing the bearer provider uses — ONLY the Authorization header, never
|
|
81
|
+
// the request body. Returns the bearer token slice (the credential) or null.
|
|
82
|
+
function bearerCredentialFromRequest(request) {
|
|
83
|
+
const header = request?.headers?.authorization ?? request?.headers?.Authorization;
|
|
84
|
+
if (typeof header !== "string") {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
88
|
+
return match ? match[1].trim() : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Reconstruct the PR2 signed envelope ({ payload, signerKeyId, alg, signature })
|
|
92
|
+
// from a worker-isolated manifest's stored fields. Authors produce this with
|
|
93
|
+
// signPluginManifest; the manifest persists it under haechiPlugin.signed and
|
|
94
|
+
// mirrors the flat fields so validatePluginManifest can check the shape.
|
|
95
|
+
function envelopeFromManifest(plugin) {
|
|
96
|
+
if (plugin.signed && typeof plugin.signed === "object") {
|
|
97
|
+
return plugin.signed;
|
|
98
|
+
}
|
|
99
|
+
// Fallback: assemble from the flat manifest fields (signature/signerKeyId +
|
|
100
|
+
// the signed payload mirror under haechiPlugin.signedPayload).
|
|
101
|
+
return {
|
|
102
|
+
payload: plugin.signedPayload,
|
|
103
|
+
signerKeyId: plugin.signerKeyId,
|
|
104
|
+
alg: plugin.alg ?? "ed25519",
|
|
105
|
+
signature: plugin.signature
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Host-side claims sanitizer. The reply is parsed, then ONLY the allowlisted
|
|
110
|
+
// own-enumerable keys are copied onto a null-prototype object — __proto__/
|
|
111
|
+
// constructor/prototype can never reach buildExternalIdentity, and array/string
|
|
112
|
+
// sizes are bounded. Returns a plain {subject,issuer,type,scopes,labels} or
|
|
113
|
+
// throws (→ deny) on a structurally invalid claim.
|
|
114
|
+
function sanitizeClaims(rawClaims) {
|
|
115
|
+
if (!rawClaims || typeof rawClaims !== "object" || Array.isArray(rawClaims)) {
|
|
116
|
+
throw new Error("claims must be an object");
|
|
117
|
+
}
|
|
118
|
+
const out = Object.create(null);
|
|
119
|
+
for (const key of CLAIM_ALLOWLIST) {
|
|
120
|
+
// Own-enumerable only; never walk the prototype.
|
|
121
|
+
if (!Object.prototype.hasOwnProperty.call(rawClaims, key)) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
out[key] = rawClaims[key];
|
|
125
|
+
}
|
|
126
|
+
// type-validate / bound each value at the boundary.
|
|
127
|
+
if (typeof out.subject !== "string" || out.subject.length === 0 || out.subject.length > MAX_STRING_LEN) {
|
|
128
|
+
throw new Error("claims.subject must be a bounded non-empty string");
|
|
129
|
+
}
|
|
130
|
+
if (typeof out.issuer !== "string" || out.issuer.length === 0 || out.issuer.length > MAX_STRING_LEN) {
|
|
131
|
+
throw new Error("claims.issuer must be a bounded non-empty string");
|
|
132
|
+
}
|
|
133
|
+
if (out.type !== undefined && typeof out.type !== "string") {
|
|
134
|
+
throw new Error("claims.type must be a string");
|
|
135
|
+
}
|
|
136
|
+
if (out.scopes !== undefined) {
|
|
137
|
+
if (!Array.isArray(out.scopes) || out.scopes.length > MAX_SCOPES
|
|
138
|
+
|| !out.scopes.every((s) => typeof s === "string" && s.length > 0 && s.length <= MAX_STRING_LEN)) {
|
|
139
|
+
throw new Error("claims.scopes must be a bounded array of non-empty strings");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (out.labels !== undefined) {
|
|
143
|
+
if (!out.labels || typeof out.labels !== "object" || Array.isArray(out.labels)) {
|
|
144
|
+
throw new Error("claims.labels must be an object");
|
|
145
|
+
}
|
|
146
|
+
const labelKeys = Object.keys(out.labels);
|
|
147
|
+
if (labelKeys.length > MAX_LABELS) {
|
|
148
|
+
throw new Error("claims.labels exceeds the size bound");
|
|
149
|
+
}
|
|
150
|
+
const bounded = Object.create(null);
|
|
151
|
+
for (const k of labelKeys) {
|
|
152
|
+
const v = out.labels[k];
|
|
153
|
+
if (typeof v !== "string" || v.length === 0 || v.length > MAX_STRING_LEN) {
|
|
154
|
+
throw new Error(`claims.labels.${k} must be a bounded non-empty string`);
|
|
155
|
+
}
|
|
156
|
+
bounded[k] = v;
|
|
157
|
+
}
|
|
158
|
+
out.labels = bounded;
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function createSandboxedAuthProviderHandle({
|
|
164
|
+
manifestPath,
|
|
165
|
+
trustAnchors,
|
|
166
|
+
allowCapabilities = [],
|
|
167
|
+
pin = null,
|
|
168
|
+
revoked = {},
|
|
169
|
+
versionFloor = {},
|
|
170
|
+
cryptoProvider,
|
|
171
|
+
auditSink,
|
|
172
|
+
timeoutMs,
|
|
173
|
+
maxPendingCalls = 8,
|
|
174
|
+
maxMessageBytes = 16384,
|
|
175
|
+
resourceLimits,
|
|
176
|
+
coreVersion = null,
|
|
177
|
+
now = Date.now,
|
|
178
|
+
allowedLabelKeys
|
|
179
|
+
} = {}) {
|
|
180
|
+
if (!manifestPath || typeof manifestPath !== "string") {
|
|
181
|
+
throw new Error("createSandboxedAuthProvider requires a manifestPath string");
|
|
182
|
+
}
|
|
183
|
+
if (typeof cryptoProvider?.hmac !== "function") {
|
|
184
|
+
throw new Error("createSandboxedAuthProvider requires a cryptoProvider with hmac()");
|
|
185
|
+
}
|
|
186
|
+
if (!auditSink || typeof auditSink.record !== "function") {
|
|
187
|
+
throw new Error("createSandboxedAuthProvider requires an auditSink with record()");
|
|
188
|
+
}
|
|
189
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
|
|
190
|
+
throw new Error("createSandboxedAuthProvider requires a positive integer timeoutMs");
|
|
191
|
+
}
|
|
192
|
+
if (!Number.isInteger(maxPendingCalls) || maxPendingCalls < 1) {
|
|
193
|
+
throw new Error("maxPendingCalls must be a positive integer");
|
|
194
|
+
}
|
|
195
|
+
if (!Number.isInteger(maxMessageBytes) || maxMessageBytes < 1) {
|
|
196
|
+
throw new Error("maxMessageBytes must be a positive integer");
|
|
197
|
+
}
|
|
198
|
+
const nowFn = typeof now === "function" ? now : () => now;
|
|
199
|
+
|
|
200
|
+
// Fire-and-forget audit; lifecycle audit must never make the auth path throw.
|
|
201
|
+
const audit = (event) => {
|
|
202
|
+
try {
|
|
203
|
+
const out = auditSink.record(event);
|
|
204
|
+
if (out && typeof out.then === "function") {
|
|
205
|
+
out.catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// swallow — auditing is best-effort and never blocks fail-closed behavior
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Read+validate the manifest, resolve the entry path, read the entry bytes into
|
|
213
|
+
// memory, and run the FULL PR2 gate. Returns { verified, entrySource,
|
|
214
|
+
// entrySha256, pluginId }. Throws (after emitting plugin.load.refused) on any
|
|
215
|
+
// refusal. Re-run on every (re)spawn — the gate is not a one-time check.
|
|
216
|
+
function loadAndVerify() {
|
|
217
|
+
let manifestRaw;
|
|
218
|
+
let plugin;
|
|
219
|
+
let entryPath;
|
|
220
|
+
let entrySource;
|
|
221
|
+
let entrySha256;
|
|
222
|
+
let pluginIdForAudit;
|
|
223
|
+
let signerKeyIdForAudit;
|
|
224
|
+
try {
|
|
225
|
+
manifestRaw = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
226
|
+
plugin = manifestRaw?.haechiPlugin;
|
|
227
|
+
pluginIdForAudit = plugin?.id;
|
|
228
|
+
const validation = validatePluginManifest(manifestRaw);
|
|
229
|
+
if (!validation.valid) {
|
|
230
|
+
throw refuse("manifest-invalid", `manifest invalid: ${validation.errors.join("; ")}`);
|
|
231
|
+
}
|
|
232
|
+
if (plugin.runtime !== "worker-isolated") {
|
|
233
|
+
throw refuse("manifest-invalid", "sandbox requires runtime worker-isolated");
|
|
234
|
+
}
|
|
235
|
+
if (plugin.kind !== "authProvider") {
|
|
236
|
+
throw refuse("manifest-invalid", "sandbox requires kind authProvider");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Resolve the entry against the manifest dir. Reject a symlinked entry
|
|
240
|
+
// (anti-TOCTOU / swap): we hash and spawn from the in-memory bytes only.
|
|
241
|
+
const manifestDir = resolvePath(dirname(resolvePath(manifestPath)));
|
|
242
|
+
entryPath = resolvePath(manifestDir, plugin.entrypoint);
|
|
243
|
+
|
|
244
|
+
// FIX C — entrypoint confinement: the resolved entry path MUST be inside
|
|
245
|
+
// the manifest directory. An absolute path or a `../`-escaping value
|
|
246
|
+
// resolves outside manifestDir and is an arbitrary-file-read primitive
|
|
247
|
+
// (code execution is still blocked by the entrySha256 hash check, but
|
|
248
|
+
// reading an arbitrary host file into memory is unintended).
|
|
249
|
+
// We check BEFORE lstatSync / readFileSync so no I/O occurs on the path.
|
|
250
|
+
if (!entryPath.startsWith(manifestDir + pathSep) && entryPath !== manifestDir) {
|
|
251
|
+
throw refuse("manifest-invalid", `entry path escapes the manifest directory: ${plugin.entrypoint}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const st = lstatSync(entryPath);
|
|
255
|
+
if (st.isSymbolicLink()) {
|
|
256
|
+
throw refuse("tampered-entry", "entry path is a symlink (refused)");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// FIX C — max-size bound: refuse to read an unreasonably large entry into
|
|
260
|
+
// memory. A few MiB is generous for any auth plugin; beyond that it is
|
|
261
|
+
// almost certainly a mistake or an attempt to exhaust host memory.
|
|
262
|
+
const MAX_ENTRY_BYTES = 4 * 1024 * 1024; // 4 MiB
|
|
263
|
+
const entrySize = statSync(entryPath).size;
|
|
264
|
+
if (entrySize > MAX_ENTRY_BYTES) {
|
|
265
|
+
throw refuse("manifest-invalid", `entry file exceeds maximum size (${entrySize} > ${MAX_ENTRY_BYTES} bytes)`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const entryBytes = readFileSync(entryPath); // INTO MEMORY — read exactly once.
|
|
269
|
+
entrySource = entryBytes.toString("utf8");
|
|
270
|
+
entrySha256 = sha256Hex(entryBytes);
|
|
271
|
+
|
|
272
|
+
const envelope = envelopeFromManifest(plugin);
|
|
273
|
+
signerKeyIdForAudit = envelope?.signerKeyId;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error?.__haechiRefusal) {
|
|
276
|
+
throw error.cause;
|
|
277
|
+
}
|
|
278
|
+
// A read/parse error before validation runs as a manifest refusal.
|
|
279
|
+
const refusal = refuse("manifest-invalid", `manifest load failed: ${error.message}`, pluginIdForAudit);
|
|
280
|
+
throw refusal.cause;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The PR2 gate (signature + anchor + revocation + tamper + window + floor +
|
|
284
|
+
// pin + capability allowlist + coreVersionRange). Any failure throws a
|
|
285
|
+
// PluginLoadError whose .reason is the audit reason.
|
|
286
|
+
let verified;
|
|
287
|
+
try {
|
|
288
|
+
verified = verifySignedPlugin({
|
|
289
|
+
signed: envelopeFromManifest(plugin),
|
|
290
|
+
entryBytes: Buffer.from(entrySource, "utf8"),
|
|
291
|
+
trustAnchors,
|
|
292
|
+
revoked,
|
|
293
|
+
pin,
|
|
294
|
+
versionFloor,
|
|
295
|
+
allowCapabilities,
|
|
296
|
+
coreVersion,
|
|
297
|
+
now: nowFn()
|
|
298
|
+
});
|
|
299
|
+
} catch (error) {
|
|
300
|
+
const reason = typeof error?.reason === "string" ? error.reason : "manifest-invalid";
|
|
301
|
+
audit({ type: "plugin.load.refused", decision: "plugin.load.refused", reason, pluginId: pluginIdForAudit, signerKeyId: signerKeyIdForAudit });
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
verified,
|
|
307
|
+
entrySource,
|
|
308
|
+
entrySha256,
|
|
309
|
+
pluginId: verified.pluginId,
|
|
310
|
+
signerKeyId: envelopeFromManifest(plugin).signerKeyId
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// A tagged-throw helper so loadAndVerify can emit the refused audit at one site.
|
|
315
|
+
function refuse(reason, message, pluginId) {
|
|
316
|
+
const err = new Error(message);
|
|
317
|
+
err.reason = reason;
|
|
318
|
+
audit({ type: "plugin.load.refused", decision: "plugin.load.refused", reason, pluginId });
|
|
319
|
+
return { __haechiRefusal: true, cause: err };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---- worker lifecycle ----------------------------------------------------
|
|
323
|
+
|
|
324
|
+
let worker = null;
|
|
325
|
+
let pluginId = null;
|
|
326
|
+
let closed = false;
|
|
327
|
+
// cid -> settle(reply). Drops late/duplicate/unmatched replies by cid. Only one
|
|
328
|
+
// entry is ever live at a time (single-occupancy via the serialization chain).
|
|
329
|
+
const pending = new Map();
|
|
330
|
+
let respawning = null; // single-flight respawn guard
|
|
331
|
+
// Serialization chain: worker round-trips run ONE AT A TIME (single-occupancy),
|
|
332
|
+
// so a per-call timeout-terminate can never kill a sibling. queueDepth bounds
|
|
333
|
+
// how many calls may be waiting+running before the worker; excess → deny.
|
|
334
|
+
let chain = Promise.resolve();
|
|
335
|
+
let queueDepth = 0;
|
|
336
|
+
|
|
337
|
+
function spawnFromVerified({ entrySource, pluginId: pid }) {
|
|
338
|
+
const code = workerHarness(entrySource);
|
|
339
|
+
const w = new Worker(code, {
|
|
340
|
+
eval: true,
|
|
341
|
+
resourceLimits,
|
|
342
|
+
// NO host secrets, NO key, NO sink, NO request body cross the boundary.
|
|
343
|
+
workerData: {}
|
|
344
|
+
});
|
|
345
|
+
w.on("message", (raw) => {
|
|
346
|
+
let parsed;
|
|
347
|
+
try {
|
|
348
|
+
parsed = JSON.parse(typeof raw === "string" ? raw : String(raw));
|
|
349
|
+
} catch {
|
|
350
|
+
return; // unparseable → drop
|
|
351
|
+
}
|
|
352
|
+
const cid = parsed?.cid;
|
|
353
|
+
const settle = pending.get(cid);
|
|
354
|
+
if (!settle) {
|
|
355
|
+
return; // unmatched / duplicate / late → drop
|
|
356
|
+
}
|
|
357
|
+
pending.delete(cid);
|
|
358
|
+
settle(parsed);
|
|
359
|
+
});
|
|
360
|
+
// FIX D — stale-worker error race: guard with `worker === w` (same as the
|
|
361
|
+
// exit handler) so a late async error from an already-terminated worker
|
|
362
|
+
// (e.g. from the previous incarnation after a timeout-terminate and respawn)
|
|
363
|
+
// cannot spuriously terminate the live replacement worker.
|
|
364
|
+
w.on("error", () => { if (worker === w) terminateWorker("crash"); });
|
|
365
|
+
w.on("exit", (exitCode) => {
|
|
366
|
+
if (exitCode !== 0 && worker === w) {
|
|
367
|
+
terminateWorker("crash");
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
worker = w;
|
|
371
|
+
pluginId = pid;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Drop the live worker (audit the cause), failing any matched in-flight call
|
|
375
|
+
// closed. Respawn happens lazily on the next call (re-running the full gate).
|
|
376
|
+
function terminateWorker(cause) {
|
|
377
|
+
const terminated = worker;
|
|
378
|
+
worker = null;
|
|
379
|
+
if (terminated) {
|
|
380
|
+
audit({ type: "plugin.worker.terminated", decision: "plugin.worker.terminated", pluginId, cause });
|
|
381
|
+
try { terminated.terminate(); } catch { /* already gone */ }
|
|
382
|
+
}
|
|
383
|
+
for (const [, settle] of pending) {
|
|
384
|
+
settle(null);
|
|
385
|
+
}
|
|
386
|
+
pending.clear();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// LAZY (re)spawn behind a single-flight guard that RE-RUNS THE FULL PR2 GATE
|
|
390
|
+
// (re-verify signature + anchor + pin + revocation + capabilities + window).
|
|
391
|
+
async function ensureWorker() {
|
|
392
|
+
if (worker || closed) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (respawning) {
|
|
396
|
+
return respawning;
|
|
397
|
+
}
|
|
398
|
+
respawning = (async () => {
|
|
399
|
+
const loaded = loadAndVerify();
|
|
400
|
+
spawnFromVerified(loaded);
|
|
401
|
+
})();
|
|
402
|
+
try {
|
|
403
|
+
await respawning;
|
|
404
|
+
} finally {
|
|
405
|
+
respawning = null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// One serialized worker round-trip. Resolves to the parsed reply, null (crash /
|
|
410
|
+
// spawn failure), or { __timeout: true }. Runs alone — single-occupancy.
|
|
411
|
+
async function roundTrip(credential) {
|
|
412
|
+
await ensureWorker();
|
|
413
|
+
if (!worker) {
|
|
414
|
+
return null; // spawn failed → fail closed
|
|
415
|
+
}
|
|
416
|
+
const cid = randomUUID();
|
|
417
|
+
const message = JSON.stringify({ cid, credential });
|
|
418
|
+
if (Buffer.byteLength(message, "utf8") > maxMessageBytes) {
|
|
419
|
+
return { __oversized: true };
|
|
420
|
+
}
|
|
421
|
+
return new Promise((resolve) => {
|
|
422
|
+
let done = false;
|
|
423
|
+
const settle = (value) => {
|
|
424
|
+
if (done) return;
|
|
425
|
+
done = true;
|
|
426
|
+
clearTimeout(timer);
|
|
427
|
+
resolve(value);
|
|
428
|
+
};
|
|
429
|
+
const timer = setTimeout(() => {
|
|
430
|
+
pending.delete(cid);
|
|
431
|
+
// Timeout → terminate the worker (audited), deny. Respawn lazily.
|
|
432
|
+
terminateWorker("timeout");
|
|
433
|
+
settle({ __timeout: true });
|
|
434
|
+
}, timeoutMs);
|
|
435
|
+
pending.set(cid, settle);
|
|
436
|
+
try {
|
|
437
|
+
worker.postMessage(message);
|
|
438
|
+
} catch {
|
|
439
|
+
pending.delete(cid);
|
|
440
|
+
settle(null); // worker already dead → fail closed
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// The sandboxed provider as the conformance harness / proxy see it. It proxies
|
|
446
|
+
// authenticate() into the worker, then the HOST sanitizes + builds the identity.
|
|
447
|
+
// NEVER throws into the caller (catch-all → null).
|
|
448
|
+
async function authenticate(request) {
|
|
449
|
+
try {
|
|
450
|
+
const credential = bearerCredentialFromRequest(request);
|
|
451
|
+
if (credential === null) {
|
|
452
|
+
return null; // missing credential → deny (no worker round-trip needed)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Pending cap: bound concurrency so a burst can never queue unbounded.
|
|
456
|
+
if (queueDepth >= maxPendingCalls) {
|
|
457
|
+
audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "over-capacity" });
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Serialize: single-occupancy worker. Each call waits its turn; distinct
|
|
462
|
+
// cids guarantee replies never cross even though calls are queued.
|
|
463
|
+
queueDepth += 1;
|
|
464
|
+
const myTurn = chain;
|
|
465
|
+
let release;
|
|
466
|
+
chain = new Promise((r) => { release = r; });
|
|
467
|
+
let reply;
|
|
468
|
+
try {
|
|
469
|
+
await myTurn;
|
|
470
|
+
reply = await roundTrip(credential);
|
|
471
|
+
} finally {
|
|
472
|
+
queueDepth -= 1;
|
|
473
|
+
release();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (reply && reply.__oversized) {
|
|
477
|
+
audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "oversized" });
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
if (!reply || reply.__timeout) {
|
|
481
|
+
if (reply && reply.__timeout) {
|
|
482
|
+
audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "timeout" });
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
if (reply.deny === true || reply.claims === undefined) {
|
|
487
|
+
audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "deny" });
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let claims;
|
|
492
|
+
try {
|
|
493
|
+
claims = sanitizeClaims(reply.claims);
|
|
494
|
+
} catch {
|
|
495
|
+
audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "invalid-claims" });
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The HOST builds the keyed-HMAC identity. The key NEVER crossed to the
|
|
500
|
+
// worker; PII-safety is (re-)enforced here on every call.
|
|
501
|
+
try {
|
|
502
|
+
return await buildExternalIdentity({
|
|
503
|
+
provider: `plugin:${pluginId}`,
|
|
504
|
+
subject: claims.subject,
|
|
505
|
+
issuer: claims.issuer,
|
|
506
|
+
type: claims.type ?? "user",
|
|
507
|
+
scopes: claims.scopes ?? [],
|
|
508
|
+
labels: claims.labels ?? {},
|
|
509
|
+
...(allowedLabelKeys ? { allowedLabelKeys } : {})
|
|
510
|
+
}, cryptoProvider);
|
|
511
|
+
} catch {
|
|
512
|
+
audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "invalid-claims" });
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
} catch {
|
|
516
|
+
// Catch-all: authenticate NEVER throws into the caller.
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function close() {
|
|
522
|
+
closed = true;
|
|
523
|
+
const terminated = worker;
|
|
524
|
+
worker = null;
|
|
525
|
+
pending.clear();
|
|
526
|
+
if (terminated) {
|
|
527
|
+
try { await terminated.terminate(); } catch { /* already gone */ }
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ---- construct: synchronous load+verify+spawn (PR2 gate throws here), then a
|
|
532
|
+
// one-time async conformance gate. The sync gate runs eagerly so a refused load
|
|
533
|
+
// throws at construction; conformance runs through the SAME worker wire.
|
|
534
|
+
|
|
535
|
+
const initial = loadAndVerify();
|
|
536
|
+
spawnFromVerified(initial);
|
|
537
|
+
|
|
538
|
+
const provider = { id: `plugin:${initial.pluginId}`, authenticate, close };
|
|
539
|
+
|
|
540
|
+
// The conformance run, executed once. Emits load.accepted on pass; on fail it
|
|
541
|
+
// emits load.refused{conformance-failed}, closes the worker, and rejects.
|
|
542
|
+
const conformance = assertAuthProviderConformance(provider, { now: nowFn() })
|
|
543
|
+
.then((result) => {
|
|
544
|
+
if (!result.ok) {
|
|
545
|
+
audit({
|
|
546
|
+
type: "plugin.load.refused",
|
|
547
|
+
decision: "plugin.load.refused",
|
|
548
|
+
reason: "conformance-failed",
|
|
549
|
+
pluginId: initial.pluginId,
|
|
550
|
+
signerKeyId: initial.signerKeyId
|
|
551
|
+
});
|
|
552
|
+
return close().then(() => {
|
|
553
|
+
throw new Error(`plugin conformance failed: ${result.failures.join("; ")}`);
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
audit({
|
|
557
|
+
type: "plugin.load.accepted",
|
|
558
|
+
decision: "plugin.load.accepted",
|
|
559
|
+
pluginId: initial.pluginId,
|
|
560
|
+
version: initial.verified.version,
|
|
561
|
+
entrySha256: initial.entrySha256,
|
|
562
|
+
signerKeyId: initial.signerKeyId,
|
|
563
|
+
capabilitiesGranted: Object.entries(initial.verified.capabilities)
|
|
564
|
+
.filter(([, v]) => v === true)
|
|
565
|
+
.map(([k]) => k)
|
|
566
|
+
});
|
|
567
|
+
return provider;
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// ready resolves when conformance passes / rejects when it fails — the runtime
|
|
571
|
+
// (sync) path awaits this lazily; direct callers await the returned promise.
|
|
572
|
+
provider.ready = conformance;
|
|
573
|
+
return { provider, conformance, pluginId: initial.pluginId, entrySha256: initial.entrySha256, signerKeyId: initial.signerKeyId };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Async factory: resolves to the live provider AFTER conformance passes, rejects
|
|
577
|
+
// on ANY load failure (PR2 gate or conformance). Direct (test) callers await this.
|
|
578
|
+
export async function createSandboxedAuthProvider(options) {
|
|
579
|
+
const { conformance } = createSandboxedAuthProviderHandle(options);
|
|
580
|
+
return conformance;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Synchronous factory for the runtime composition root: the PR2 gate runs eagerly
|
|
584
|
+
// (so a refused load throws at createRuntime time), and conformance is gated
|
|
585
|
+
// lazily behind provider.ready — authenticate() awaits readiness and fails closed
|
|
586
|
+
// (null) if conformance rejected. Returns the host-side authProvider immediately.
|
|
587
|
+
export function createSandboxedAuthProviderSync(options) {
|
|
588
|
+
const { provider, conformance } = createSandboxedAuthProviderHandle(options);
|
|
589
|
+
// Gate readiness on conformance WITHOUT mutating provider.authenticate — the
|
|
590
|
+
// conformance run itself calls provider.authenticate, so wrapping that method
|
|
591
|
+
// in place would make conformance await itself (deadlock). Return a NEW object
|
|
592
|
+
// whose authenticate awaits readiness then delegates to the (untouched) raw
|
|
593
|
+
// provider.authenticate.
|
|
594
|
+
const ready = conformance.then(() => true, () => false);
|
|
595
|
+
return {
|
|
596
|
+
id: provider.id,
|
|
597
|
+
async authenticate(request) {
|
|
598
|
+
if (!(await ready)) {
|
|
599
|
+
return null; // conformance failed → permanently fail closed
|
|
600
|
+
}
|
|
601
|
+
return provider.authenticate(request);
|
|
602
|
+
},
|
|
603
|
+
close() {
|
|
604
|
+
return provider.close();
|
|
605
|
+
},
|
|
606
|
+
ready
|
|
607
|
+
};
|
|
608
|
+
}
|