@questpie/sandbox 3.15.2 → 3.17.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.md +203 -18
- package/dist/adapter-http-Coy4jqsj.mjs +865 -0
- package/dist/adapter-http-DDeL-Ym3.d.mts +259 -0
- package/dist/adapter.d.mts +3 -2
- package/dist/adapter.mjs +3 -2
- package/dist/custom-tools-CgfgyVNv.d.mts +55 -0
- package/dist/custom-tools-DxI7JvUn.mjs +407 -0
- package/dist/index.d.mts +40 -115
- package/dist/index.mjs +11 -14
- package/dist/modules/sandbox.d.mts +40 -0
- package/dist/modules/sandbox.mjs +211 -0
- package/package.json +20 -5
- package/src/broker-wire.ts +94 -0
- package/src/egress-firewall.ts +9 -3
- package/src/guest-bindings.ts +43 -1
- package/src/guest-entry.ts +132 -22
- package/src/guest-runtime-source.ts +38 -0
- package/src/net-validation.ts +204 -51
- package/src/sandbox-server.ts +860 -144
- package/src/server-internals.ts +195 -12
- package/src/types.ts +38 -10
- package/src/workload-admission.ts +289 -0
- package/src/workload.ts +441 -0
- package/dist/adapter-http-7ypWHBck.d.mts +0 -43
- package/dist/adapter-http-w7SjAfcC.mjs +0 -298
package/src/server-internals.ts
CHANGED
|
@@ -45,13 +45,187 @@ export interface RunOutcome {
|
|
|
45
45
|
timedOut?: boolean;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const MAX_RESULT_DEPTH = 32;
|
|
49
|
+
const MAX_RESULT_NODES = 10_000;
|
|
50
|
+
const MAX_RESULT_JSON_BYTES = 1_048_576;
|
|
51
|
+
const MAX_RESULT_LOGS = 1_000;
|
|
52
|
+
const MAX_RESULT_LOG_BYTES = 4_000;
|
|
53
|
+
const MAX_TOTAL_LOG_BYTES = 1_048_576;
|
|
54
|
+
|
|
55
|
+
function exactKeys(
|
|
56
|
+
value: Record<string, unknown>,
|
|
57
|
+
required: readonly string[],
|
|
58
|
+
optional: readonly string[],
|
|
59
|
+
): boolean {
|
|
60
|
+
const keys = Object.keys(value);
|
|
61
|
+
return (
|
|
62
|
+
required.every((key) => keys.includes(key)) &&
|
|
63
|
+
keys.every((key) => required.includes(key) || optional.includes(key))
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isBoundedJson(
|
|
68
|
+
value: unknown,
|
|
69
|
+
budget = { nodes: 0, bytes: 0 },
|
|
70
|
+
depth = 0,
|
|
71
|
+
maximumBytes = MAX_RESULT_JSON_BYTES,
|
|
72
|
+
): boolean {
|
|
73
|
+
if (depth > MAX_RESULT_DEPTH) return false;
|
|
74
|
+
budget.nodes += 1;
|
|
75
|
+
if (budget.nodes > MAX_RESULT_NODES) return false;
|
|
76
|
+
if (value === null || typeof value === "boolean") {
|
|
77
|
+
budget.bytes += 5;
|
|
78
|
+
} else if (typeof value === "number") {
|
|
79
|
+
if (!Number.isFinite(value)) return false;
|
|
80
|
+
budget.bytes += 24;
|
|
81
|
+
} else if (typeof value === "string") {
|
|
82
|
+
budget.bytes += new TextEncoder().encode(value).byteLength;
|
|
83
|
+
} else if (Array.isArray(value)) {
|
|
84
|
+
budget.bytes += 2;
|
|
85
|
+
for (const item of value) {
|
|
86
|
+
if (!isBoundedJson(item, budget, depth + 1, maximumBytes)) return false;
|
|
87
|
+
}
|
|
88
|
+
} else if (typeof value === "object" && value !== null) {
|
|
89
|
+
budget.bytes += 2;
|
|
90
|
+
for (const [key, item] of Object.entries(value)) {
|
|
91
|
+
budget.bytes += new TextEncoder().encode(key).byteLength;
|
|
92
|
+
if (!isBoundedJson(item, budget, depth + 1, maximumBytes)) return false;
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return budget.bytes <= maximumBytes;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseSandboxRunResult(
|
|
101
|
+
value: unknown,
|
|
102
|
+
): (RunOutcome & { ms?: number }) | null {
|
|
103
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
104
|
+
const result = value as Record<string, unknown>;
|
|
105
|
+
if (
|
|
106
|
+
!exactKeys(result, ["ok", "logs"], ["output", "error", "timedOut", "ms"]) ||
|
|
107
|
+
typeof result.ok !== "boolean" ||
|
|
108
|
+
!Array.isArray(result.logs) ||
|
|
109
|
+
result.logs.length > MAX_RESULT_LOGS ||
|
|
110
|
+
(result.error !== undefined && typeof result.error !== "string") ||
|
|
111
|
+
(result.timedOut !== undefined && typeof result.timedOut !== "boolean") ||
|
|
112
|
+
(result.ms !== undefined &&
|
|
113
|
+
(typeof result.ms !== "number" ||
|
|
114
|
+
!Number.isFinite(result.ms) ||
|
|
115
|
+
result.ms < 0))
|
|
116
|
+
) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (
|
|
120
|
+
(result.ok === true &&
|
|
121
|
+
(Object.hasOwn(result, "error") || Object.hasOwn(result, "timedOut"))) ||
|
|
122
|
+
(result.ok === false &&
|
|
123
|
+
(typeof result.error !== "string" ||
|
|
124
|
+
Object.hasOwn(result, "output") ||
|
|
125
|
+
(Object.hasOwn(result, "timedOut") && result.timedOut !== true)))
|
|
126
|
+
) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
let logBytes = 0;
|
|
130
|
+
for (const log of result.logs) {
|
|
131
|
+
if (typeof log !== "string") return null;
|
|
132
|
+
const bytes = new TextEncoder().encode(log).byteLength;
|
|
133
|
+
logBytes += bytes;
|
|
134
|
+
if (bytes > MAX_RESULT_LOG_BYTES || logBytes > MAX_TOTAL_LOG_BYTES) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (result.output !== undefined && !isBoundedJson(result.output)) return null;
|
|
139
|
+
return {
|
|
140
|
+
ok: result.ok,
|
|
141
|
+
...(result.output !== undefined ? { output: result.output } : {}),
|
|
142
|
+
...(typeof result.error === "string" ? { error: result.error } : {}),
|
|
143
|
+
logs: result.logs as string[],
|
|
144
|
+
...(typeof result.timedOut === "boolean"
|
|
145
|
+
? { timedOut: result.timedOut }
|
|
146
|
+
: {}),
|
|
147
|
+
...(typeof result.ms === "number" ? { ms: result.ms } : {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type BrokerRelayResult =
|
|
152
|
+
| { ok: true; value?: unknown }
|
|
153
|
+
| {
|
|
154
|
+
ok: false;
|
|
155
|
+
error: { code: string; message: string; correlationId?: string };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const BROKER_ERROR_MESSAGES: Readonly<Record<string, string>> = Object.freeze({
|
|
159
|
+
bad_method: "invalid sandbox binding method",
|
|
160
|
+
bad_args: "invalid sandbox binding arguments",
|
|
161
|
+
unauthorized: "sandbox binding authorization failed",
|
|
162
|
+
forbidden: "sandbox binding operation forbidden",
|
|
163
|
+
not_implemented: "sandbox binding operation unavailable",
|
|
164
|
+
execution_error: "sandbox binding operation failed",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
export function parseBrokerResponse(
|
|
168
|
+
value: unknown,
|
|
169
|
+
maxValueBytes = MAX_RESULT_JSON_BYTES,
|
|
170
|
+
): BrokerRelayResult | null {
|
|
171
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
172
|
+
const response = value as Record<string, unknown>;
|
|
173
|
+
if (response.ok === true) {
|
|
174
|
+
if (!exactKeys(response, ["ok", "value"], [])) return null;
|
|
175
|
+
if (!isBoundedJson(response.value, undefined, 0, maxValueBytes))
|
|
176
|
+
return null;
|
|
177
|
+
return { ok: true, value: response.value };
|
|
178
|
+
}
|
|
179
|
+
if (
|
|
180
|
+
response.ok !== false ||
|
|
181
|
+
!exactKeys(response, ["ok", "error"], []) ||
|
|
182
|
+
!response.error ||
|
|
183
|
+
typeof response.error !== "object" ||
|
|
184
|
+
Array.isArray(response.error)
|
|
185
|
+
) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const error = response.error as Record<string, unknown>;
|
|
189
|
+
if (
|
|
190
|
+
!exactKeys(error, ["code", "message"], ["correlationId"]) ||
|
|
191
|
+
typeof error.code !== "string" ||
|
|
192
|
+
typeof error.message !== "string" ||
|
|
193
|
+
(error.correlationId !== undefined &&
|
|
194
|
+
(typeof error.correlationId !== "string" ||
|
|
195
|
+
!/^[\w-]{1,128}$/.test(error.correlationId)))
|
|
196
|
+
) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const code = Object.hasOwn(BROKER_ERROR_MESSAGES, error.code)
|
|
200
|
+
? error.code
|
|
201
|
+
: "execution_error";
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
error: {
|
|
205
|
+
code,
|
|
206
|
+
message: BROKER_ERROR_MESSAGES[code]!,
|
|
207
|
+
...(typeof error.correlationId === "string"
|
|
208
|
+
? { correlationId: error.correlationId }
|
|
209
|
+
: {}),
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
48
214
|
/** Normalize a broker URL for comparison (drop a trailing slash; ignore case of origin). */
|
|
49
215
|
export function normalizeBrokerUrl(raw: string): string | null {
|
|
50
216
|
try {
|
|
51
217
|
const u = new URL(raw);
|
|
218
|
+
if (
|
|
219
|
+
(u.protocol !== "http:" && u.protocol !== "https:") ||
|
|
220
|
+
u.username.length > 0 ||
|
|
221
|
+
u.password.length > 0 ||
|
|
222
|
+
u.hash.length > 0
|
|
223
|
+
) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
52
226
|
// Compare origin (lowercased by URL) + pathname without a trailing slash.
|
|
53
227
|
const path = u.pathname.replace(/\/$/, "");
|
|
54
|
-
return `${u.origin}${path}`;
|
|
228
|
+
return `${u.origin}${path}${u.search}`;
|
|
55
229
|
} catch {
|
|
56
230
|
return null;
|
|
57
231
|
}
|
|
@@ -60,10 +234,16 @@ export function normalizeBrokerUrl(raw: string): string | null {
|
|
|
60
234
|
/**
|
|
61
235
|
* Reject a guest's bindings target URL unless it matches the supervisor's
|
|
62
236
|
* configured expected broker URL. Returns an error string to surface, or
|
|
63
|
-
* `null` when the URL is acceptable
|
|
237
|
+
* `null` only when the URL is acceptable. Bindings fail closed when the
|
|
238
|
+
* supervisor has no canonical broker target configured.
|
|
64
239
|
*/
|
|
65
|
-
export function brokerUrlRejection(
|
|
66
|
-
|
|
240
|
+
export function brokerUrlRejection(
|
|
241
|
+
url: unknown,
|
|
242
|
+
expected: string | undefined,
|
|
243
|
+
): string | null {
|
|
244
|
+
if (!expected || normalizeBrokerUrl(expected) === null) {
|
|
245
|
+
return "sandbox broker URL is not configured";
|
|
246
|
+
}
|
|
67
247
|
if (typeof url !== "string" || url.length === 0) {
|
|
68
248
|
return "bindings.url is missing";
|
|
69
249
|
}
|
|
@@ -76,7 +256,12 @@ export function brokerUrlRejection(url: unknown, expected: string | undefined):
|
|
|
76
256
|
return null;
|
|
77
257
|
}
|
|
78
258
|
|
|
79
|
-
export function clampInt(
|
|
259
|
+
export function clampInt(
|
|
260
|
+
v: unknown,
|
|
261
|
+
def: number,
|
|
262
|
+
min: number,
|
|
263
|
+
max: number,
|
|
264
|
+
): number {
|
|
80
265
|
const n = Number(v);
|
|
81
266
|
if (!Number.isFinite(n)) return def;
|
|
82
267
|
return Math.min(Math.max(Math.round(n), min), max);
|
|
@@ -107,7 +292,10 @@ export function importFlags(hosts: string[]): string[] {
|
|
|
107
292
|
}
|
|
108
293
|
|
|
109
294
|
/** Pull the marked JSON result line out of the guest's stdout. */
|
|
110
|
-
export function extractResult(
|
|
295
|
+
export function extractResult(
|
|
296
|
+
stdout: string,
|
|
297
|
+
marker: string,
|
|
298
|
+
): RunOutcome | null {
|
|
111
299
|
const idx = stdout.lastIndexOf(marker);
|
|
112
300
|
if (idx === -1) return null;
|
|
113
301
|
const after = stdout.slice(idx + marker.length);
|
|
@@ -115,12 +303,7 @@ export function extractResult(stdout: string, marker: string): RunOutcome | null
|
|
|
115
303
|
const json = newline === -1 ? after : after.slice(0, newline);
|
|
116
304
|
try {
|
|
117
305
|
const parsed = JSON.parse(json);
|
|
118
|
-
return
|
|
119
|
-
ok: !!parsed.ok,
|
|
120
|
-
output: parsed.output,
|
|
121
|
-
error: parsed.error,
|
|
122
|
-
logs: Array.isArray(parsed.logs) ? parsed.logs : [],
|
|
123
|
-
};
|
|
306
|
+
return parseSandboxRunResult(parsed);
|
|
124
307
|
} catch {
|
|
125
308
|
return null;
|
|
126
309
|
}
|
package/src/types.ts
CHANGED
|
@@ -46,6 +46,8 @@ export interface SandboxBindings {
|
|
|
46
46
|
|
|
47
47
|
/** POST /run request body. */
|
|
48
48
|
export interface SandboxRunRequest {
|
|
49
|
+
/** Explicit transport admission. The supervisor rejects omitted/unknown modes. */
|
|
50
|
+
mode: "workload" | "host";
|
|
49
51
|
/** Guest TypeScript source. Must `export default` a `function(input)`. */
|
|
50
52
|
source: string;
|
|
51
53
|
/** Payload passed to the guest entry function. */
|
|
@@ -65,16 +67,35 @@ export interface SandboxRunRequest {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/** POST /run response body — also the `ExecutorAdapter.run` result shape. */
|
|
68
|
-
export
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
70
|
+
export type SandboxRunResult =
|
|
71
|
+
| {
|
|
72
|
+
ok: true;
|
|
73
|
+
output?: unknown;
|
|
74
|
+
logs: string[];
|
|
75
|
+
error?: never;
|
|
76
|
+
timedOut?: never;
|
|
77
|
+
/** Wall-clock duration in ms (server-measured). */
|
|
78
|
+
ms?: number;
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
ok: false;
|
|
82
|
+
error: string;
|
|
83
|
+
logs: string[];
|
|
84
|
+
output?: never;
|
|
85
|
+
timedOut?: never;
|
|
86
|
+
/** Wall-clock duration in ms (server-measured). */
|
|
87
|
+
ms?: number;
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
ok: false;
|
|
91
|
+
error: string;
|
|
92
|
+
logs: string[];
|
|
93
|
+
output?: never;
|
|
94
|
+
/** True when the run was killed by the wall-clock timeout. */
|
|
95
|
+
timedOut: true;
|
|
96
|
+
/** Wall-clock duration in ms (server-measured). */
|
|
97
|
+
ms?: number;
|
|
98
|
+
};
|
|
78
99
|
|
|
79
100
|
// ──────────────────────────────────────────────────────────────────────────
|
|
80
101
|
// FRAMED stdio protocol (guest subprocess ⇄ supervisor) for the bindings path.
|
|
@@ -96,10 +117,17 @@ export const FRAME_MARKER = "__QP_SANDBOX_MSG__";
|
|
|
96
117
|
*/
|
|
97
118
|
export const BINDINGS_TOKEN_HEADER = "x-questpie-sandbox-token";
|
|
98
119
|
|
|
120
|
+
/** Host-issued admission binding an authorized workload to one exact request. */
|
|
121
|
+
export const WORKLOAD_ADMISSION_HEADER = "x-questpie-workload-admission";
|
|
122
|
+
|
|
123
|
+
/** Trusted service identity for the structurally separate direct-host path. */
|
|
124
|
+
export const HOST_ADMISSION_HEADER = "x-questpie-sandbox-host-admission";
|
|
125
|
+
|
|
99
126
|
/** Structured error returned by the broker (mirrors `questpie` `BindingError`). */
|
|
100
127
|
export interface SandboxBindingError {
|
|
101
128
|
code: string;
|
|
102
129
|
message: string;
|
|
130
|
+
correlationId?: string;
|
|
103
131
|
}
|
|
104
132
|
|
|
105
133
|
/** Guest → supervisor: a binding RPC awaiting a brokered result. */
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
const ADMISSION_VERSION = "qpsw1";
|
|
2
|
+
const MINIMUM_SECRET_BYTES = 32;
|
|
3
|
+
const MAXIMUM_ADMISSION_TTL_MS = 5_000;
|
|
4
|
+
|
|
5
|
+
export interface SandboxWorkloadAdmissionClaims {
|
|
6
|
+
readonly kind: "sandbox_workload_admission";
|
|
7
|
+
readonly version: 1;
|
|
8
|
+
readonly admissionId: string;
|
|
9
|
+
readonly supervisorInstanceId: string;
|
|
10
|
+
readonly expiresAt: string;
|
|
11
|
+
readonly requestSha256: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SandboxWorkloadAdmissionKey {
|
|
15
|
+
readonly keyId: string;
|
|
16
|
+
readonly secret: Uint8Array;
|
|
17
|
+
/** Unique per supervisor process; rotate on every restart. */
|
|
18
|
+
readonly instanceId: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SandboxWorkloadAdmissionDenialReason =
|
|
22
|
+
| "invalid"
|
|
23
|
+
| "wrong_instance"
|
|
24
|
+
| "expired"
|
|
25
|
+
| "body_mismatch";
|
|
26
|
+
|
|
27
|
+
export type SandboxWorkloadAdmissionVerification =
|
|
28
|
+
| {
|
|
29
|
+
readonly ok: true;
|
|
30
|
+
readonly claims: SandboxWorkloadAdmissionClaims;
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
readonly ok: false;
|
|
34
|
+
readonly reason: SandboxWorkloadAdmissionDenialReason;
|
|
35
|
+
readonly claims?: SandboxWorkloadAdmissionClaims;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
interface ReplayExpiry {
|
|
39
|
+
readonly admissionId: string;
|
|
40
|
+
readonly expiresAt: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class SandboxWorkloadReplayCache {
|
|
44
|
+
private readonly consumed = new Map<string, number>();
|
|
45
|
+
private readonly expiries: ReplayExpiry[] = [];
|
|
46
|
+
|
|
47
|
+
constructor(private readonly maximumEntries: number) {
|
|
48
|
+
if (!Number.isInteger(maximumEntries) || maximumEntries < 1) {
|
|
49
|
+
throw new Error("Invalid sandbox workload replay cache size.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
consume(
|
|
54
|
+
admission: SandboxWorkloadAdmissionClaims,
|
|
55
|
+
now = Date.now(),
|
|
56
|
+
): boolean {
|
|
57
|
+
this.deleteExpired(now);
|
|
58
|
+
if (this.consumed.has(admission.admissionId)) return false;
|
|
59
|
+
if (this.consumed.size >= this.maximumEntries) return false;
|
|
60
|
+
const expiresAt = Date.parse(admission.expiresAt);
|
|
61
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now) return false;
|
|
62
|
+
this.consumed.set(admission.admissionId, expiresAt);
|
|
63
|
+
this.push({ admissionId: admission.admissionId, expiresAt });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private deleteExpired(now: number): void {
|
|
68
|
+
while (this.expiries[0] && this.expiries[0].expiresAt <= now) {
|
|
69
|
+
const expired = this.pop()!;
|
|
70
|
+
if (this.consumed.get(expired.admissionId) === expired.expiresAt) {
|
|
71
|
+
this.consumed.delete(expired.admissionId);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private push(value: ReplayExpiry): void {
|
|
77
|
+
let index = this.expiries.push(value) - 1;
|
|
78
|
+
while (index > 0) {
|
|
79
|
+
const parent = Math.floor((index - 1) / 2);
|
|
80
|
+
if (this.expiries[parent]!.expiresAt <= value.expiresAt) break;
|
|
81
|
+
this.expiries[index] = this.expiries[parent]!;
|
|
82
|
+
index = parent;
|
|
83
|
+
}
|
|
84
|
+
this.expiries[index] = value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private pop(): ReplayExpiry | undefined {
|
|
88
|
+
const root = this.expiries[0];
|
|
89
|
+
const last = this.expiries.pop();
|
|
90
|
+
if (!root || !last || this.expiries.length === 0) return root;
|
|
91
|
+
let index = 0;
|
|
92
|
+
while (true) {
|
|
93
|
+
const left = index * 2 + 1;
|
|
94
|
+
if (left >= this.expiries.length) break;
|
|
95
|
+
const right = left + 1;
|
|
96
|
+
const child =
|
|
97
|
+
right < this.expiries.length &&
|
|
98
|
+
this.expiries[right]!.expiresAt < this.expiries[left]!.expiresAt
|
|
99
|
+
? right
|
|
100
|
+
: left;
|
|
101
|
+
if (this.expiries[child]!.expiresAt >= last.expiresAt) break;
|
|
102
|
+
this.expiries[index] = this.expiries[child]!;
|
|
103
|
+
index = child;
|
|
104
|
+
}
|
|
105
|
+
this.expiries[index] = last;
|
|
106
|
+
return root;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const encoder = new TextEncoder();
|
|
111
|
+
const decoder = new TextDecoder();
|
|
112
|
+
|
|
113
|
+
function encodeBase64Url(value: Uint8Array): string {
|
|
114
|
+
let binary = "";
|
|
115
|
+
for (const byte of value) binary += String.fromCharCode(byte);
|
|
116
|
+
return btoa(binary)
|
|
117
|
+
.replaceAll("+", "-")
|
|
118
|
+
.replaceAll("/", "_")
|
|
119
|
+
.replace(/=+$/, "");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function decodeBase64Url(value: string): Uint8Array | null {
|
|
123
|
+
try {
|
|
124
|
+
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
125
|
+
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
|
126
|
+
const binary = atob(padded);
|
|
127
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isIdentity(value: unknown): value is string {
|
|
134
|
+
return (
|
|
135
|
+
typeof value === "string" &&
|
|
136
|
+
value.length <= 128 &&
|
|
137
|
+
/^[A-Za-z0-9_-]+$/.test(value)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function assertSandboxWorkloadAdmissionKey(
|
|
142
|
+
key: SandboxWorkloadAdmissionKey,
|
|
143
|
+
): void {
|
|
144
|
+
if (
|
|
145
|
+
!isIdentity(key.keyId) ||
|
|
146
|
+
!isIdentity(key.instanceId) ||
|
|
147
|
+
key.secret.byteLength < MINIMUM_SECRET_BYTES
|
|
148
|
+
) {
|
|
149
|
+
throw new Error("Invalid sandbox workload admission configuration.");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function importHmacKey(
|
|
154
|
+
key: SandboxWorkloadAdmissionKey,
|
|
155
|
+
usage: "sign" | "verify",
|
|
156
|
+
): Promise<CryptoKey> {
|
|
157
|
+
assertSandboxWorkloadAdmissionKey(key);
|
|
158
|
+
return crypto.subtle.importKey(
|
|
159
|
+
"raw",
|
|
160
|
+
Uint8Array.from(key.secret),
|
|
161
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
162
|
+
false,
|
|
163
|
+
[usage],
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function sha256(value: string): Promise<string> {
|
|
168
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
|
169
|
+
return [...new Uint8Array(digest)]
|
|
170
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
171
|
+
.join("");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function exactKeys(value: object, keys: readonly string[]): boolean {
|
|
175
|
+
const actual = Object.keys(value).sort();
|
|
176
|
+
const expected = [...keys].sort();
|
|
177
|
+
return (
|
|
178
|
+
actual.length === expected.length &&
|
|
179
|
+
actual.every((key, index) => key === expected[index])
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseClaims(value: unknown): SandboxWorkloadAdmissionClaims | null {
|
|
184
|
+
if (
|
|
185
|
+
typeof value !== "object" ||
|
|
186
|
+
value === null ||
|
|
187
|
+
Array.isArray(value) ||
|
|
188
|
+
!exactKeys(value, [
|
|
189
|
+
"kind",
|
|
190
|
+
"version",
|
|
191
|
+
"admissionId",
|
|
192
|
+
"supervisorInstanceId",
|
|
193
|
+
"expiresAt",
|
|
194
|
+
"requestSha256",
|
|
195
|
+
])
|
|
196
|
+
) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const claims = value as Record<string, unknown>;
|
|
200
|
+
if (
|
|
201
|
+
claims.kind !== "sandbox_workload_admission" ||
|
|
202
|
+
claims.version !== 1 ||
|
|
203
|
+
!isIdentity(claims.admissionId) ||
|
|
204
|
+
!isIdentity(claims.supervisorInstanceId) ||
|
|
205
|
+
typeof claims.expiresAt !== "string" ||
|
|
206
|
+
!Number.isFinite(Date.parse(claims.expiresAt)) ||
|
|
207
|
+
typeof claims.requestSha256 !== "string" ||
|
|
208
|
+
!/^[a-f0-9]{64}$/.test(claims.requestSha256)
|
|
209
|
+
) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
return claims as unknown as SandboxWorkloadAdmissionClaims;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function sealSandboxWorkloadAdmission(
|
|
216
|
+
key: SandboxWorkloadAdmissionKey,
|
|
217
|
+
claims: Omit<SandboxWorkloadAdmissionClaims, "requestSha256">,
|
|
218
|
+
requestBody: string,
|
|
219
|
+
): Promise<string> {
|
|
220
|
+
const payload: SandboxWorkloadAdmissionClaims = {
|
|
221
|
+
...claims,
|
|
222
|
+
requestSha256: await sha256(requestBody),
|
|
223
|
+
};
|
|
224
|
+
const encodedKeyId = encodeBase64Url(encoder.encode(key.keyId));
|
|
225
|
+
const encodedPayload = encodeBase64Url(
|
|
226
|
+
encoder.encode(JSON.stringify(payload)),
|
|
227
|
+
);
|
|
228
|
+
const signed = `${ADMISSION_VERSION}.${encodedKeyId}.${encodedPayload}`;
|
|
229
|
+
const cryptoKey = await importHmacKey(key, "sign");
|
|
230
|
+
const signature = await crypto.subtle.sign(
|
|
231
|
+
"HMAC",
|
|
232
|
+
cryptoKey,
|
|
233
|
+
encoder.encode(signed),
|
|
234
|
+
);
|
|
235
|
+
return `${signed}.${encodeBase64Url(new Uint8Array(signature))}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function verifySandboxWorkloadAdmission(
|
|
239
|
+
key: SandboxWorkloadAdmissionKey,
|
|
240
|
+
envelope: string,
|
|
241
|
+
requestBody: string,
|
|
242
|
+
now = new Date(),
|
|
243
|
+
): Promise<SandboxWorkloadAdmissionVerification> {
|
|
244
|
+
const parts = envelope.split(".");
|
|
245
|
+
if (parts.length !== 4 || parts[0] !== ADMISSION_VERSION) {
|
|
246
|
+
return { ok: false, reason: "invalid" };
|
|
247
|
+
}
|
|
248
|
+
const encodedKeyId = encodeBase64Url(encoder.encode(key.keyId));
|
|
249
|
+
if (parts[1] !== encodedKeyId) return { ok: false, reason: "invalid" };
|
|
250
|
+
const signature = decodeBase64Url(parts[3]!);
|
|
251
|
+
if (!signature) return { ok: false, reason: "invalid" };
|
|
252
|
+
const signed = `${parts[0]}.${parts[1]}.${parts[2]}`;
|
|
253
|
+
const cryptoKey = await importHmacKey(key, "verify");
|
|
254
|
+
if (
|
|
255
|
+
!(await crypto.subtle.verify(
|
|
256
|
+
"HMAC",
|
|
257
|
+
cryptoKey,
|
|
258
|
+
Uint8Array.from(signature),
|
|
259
|
+
encoder.encode(signed),
|
|
260
|
+
))
|
|
261
|
+
) {
|
|
262
|
+
return { ok: false, reason: "invalid" };
|
|
263
|
+
}
|
|
264
|
+
const encodedPayload = decodeBase64Url(parts[2]!);
|
|
265
|
+
if (!encodedPayload) return { ok: false, reason: "invalid" };
|
|
266
|
+
let decoded: unknown;
|
|
267
|
+
try {
|
|
268
|
+
decoded = JSON.parse(decoder.decode(encodedPayload));
|
|
269
|
+
} catch {
|
|
270
|
+
return { ok: false, reason: "invalid" };
|
|
271
|
+
}
|
|
272
|
+
const claims = parseClaims(decoded);
|
|
273
|
+
if (!claims) return { ok: false, reason: "invalid" };
|
|
274
|
+
const frozenClaims = Object.freeze(claims);
|
|
275
|
+
if (claims.supervisorInstanceId !== key.instanceId) {
|
|
276
|
+
return { ok: false, reason: "wrong_instance", claims: frozenClaims };
|
|
277
|
+
}
|
|
278
|
+
const expiresAt = Date.parse(claims.expiresAt);
|
|
279
|
+
if (
|
|
280
|
+
expiresAt <= now.getTime() ||
|
|
281
|
+
expiresAt > now.getTime() + MAXIMUM_ADMISSION_TTL_MS
|
|
282
|
+
) {
|
|
283
|
+
return { ok: false, reason: "expired", claims: frozenClaims };
|
|
284
|
+
}
|
|
285
|
+
if (claims.requestSha256 !== (await sha256(requestBody))) {
|
|
286
|
+
return { ok: false, reason: "body_mismatch", claims: frozenClaims };
|
|
287
|
+
}
|
|
288
|
+
return { ok: true, claims: frozenClaims };
|
|
289
|
+
}
|