@spfn/auth 0.2.0-beta.84 → 0.2.0-beta.85
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 +33 -2
- package/dist/client-proof.d.ts +412 -0
- package/dist/client-proof.js +1225 -0
- package/dist/client-proof.js.map +1 -0
- package/package.json +6 -1
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
// src/server/client-proof/canonical-json.ts
|
|
2
|
+
var CanonicalJsonError = class extends Error {
|
|
3
|
+
constructor(code) {
|
|
4
|
+
super(`canonical JSON: ${code}`);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = "CanonicalJsonError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
var INT64_MIN = -(2n ** 63n);
|
|
10
|
+
var INT64_MAX = 2n ** 63n - 1n;
|
|
11
|
+
function parseCanonicalJson(bytes) {
|
|
12
|
+
let text2;
|
|
13
|
+
try {
|
|
14
|
+
text2 = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
15
|
+
} catch {
|
|
16
|
+
throw new CanonicalJsonError("INVALID_UTF8");
|
|
17
|
+
}
|
|
18
|
+
const parser = new Parser(text2);
|
|
19
|
+
const value = parser.parseValue();
|
|
20
|
+
parser.skipWhitespace();
|
|
21
|
+
if (!parser.atEnd()) {
|
|
22
|
+
throw new CanonicalJsonError("TRAILING_CONTENT");
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function isCanonicalBytes(bytes, value) {
|
|
27
|
+
const encoded = encodeCanonicalJson(value);
|
|
28
|
+
if (encoded.length !== bytes.length) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
for (let i = 0; i < encoded.length; i++) {
|
|
32
|
+
if (encoded[i] !== bytes[i]) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
var Parser = class {
|
|
39
|
+
constructor(text2) {
|
|
40
|
+
this.text = text2;
|
|
41
|
+
}
|
|
42
|
+
pos = 0;
|
|
43
|
+
atEnd() {
|
|
44
|
+
return this.pos >= this.text.length;
|
|
45
|
+
}
|
|
46
|
+
skipWhitespace() {
|
|
47
|
+
while (!this.atEnd()) {
|
|
48
|
+
const c = this.text[this.pos];
|
|
49
|
+
if (c === " " || c === " " || c === "\n" || c === "\r") {
|
|
50
|
+
this.pos++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
parseValue() {
|
|
57
|
+
this.skipWhitespace();
|
|
58
|
+
if (this.atEnd()) {
|
|
59
|
+
throw new CanonicalJsonError("UNEXPECTED_END");
|
|
60
|
+
}
|
|
61
|
+
const c = this.text[this.pos];
|
|
62
|
+
if (c === "{") {
|
|
63
|
+
return this.parseObject();
|
|
64
|
+
}
|
|
65
|
+
if (c === "[") {
|
|
66
|
+
return this.parseArray();
|
|
67
|
+
}
|
|
68
|
+
if (c === '"') {
|
|
69
|
+
return this.parseString();
|
|
70
|
+
}
|
|
71
|
+
if (c === "-" || c >= "0" && c <= "9") {
|
|
72
|
+
return this.parseNumber();
|
|
73
|
+
}
|
|
74
|
+
if (this.text.startsWith("null", this.pos)) {
|
|
75
|
+
this.pos += 4;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (this.text.startsWith("true", this.pos)) {
|
|
79
|
+
this.pos += 4;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (this.text.startsWith("false", this.pos)) {
|
|
83
|
+
this.pos += 5;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
throw new CanonicalJsonError("INVALID_TOKEN");
|
|
87
|
+
}
|
|
88
|
+
parseObject() {
|
|
89
|
+
this.pos++;
|
|
90
|
+
const members2 = /* @__PURE__ */ new Map();
|
|
91
|
+
this.skipWhitespace();
|
|
92
|
+
if (this.atEnd()) {
|
|
93
|
+
throw new CanonicalJsonError("UNEXPECTED_END");
|
|
94
|
+
}
|
|
95
|
+
if (this.text[this.pos] === "}") {
|
|
96
|
+
this.pos++;
|
|
97
|
+
return members2;
|
|
98
|
+
}
|
|
99
|
+
for (; ; ) {
|
|
100
|
+
this.skipWhitespace();
|
|
101
|
+
if (this.atEnd()) {
|
|
102
|
+
throw new CanonicalJsonError("UNEXPECTED_END");
|
|
103
|
+
}
|
|
104
|
+
if (this.text[this.pos] !== '"') {
|
|
105
|
+
throw new CanonicalJsonError("INVALID_TOKEN");
|
|
106
|
+
}
|
|
107
|
+
const key = this.parseString();
|
|
108
|
+
if (members2.has(key)) {
|
|
109
|
+
throw new CanonicalJsonError("DUPLICATE_KEY");
|
|
110
|
+
}
|
|
111
|
+
this.skipWhitespace();
|
|
112
|
+
if (this.atEnd()) {
|
|
113
|
+
throw new CanonicalJsonError("UNEXPECTED_END");
|
|
114
|
+
}
|
|
115
|
+
if (this.text[this.pos] !== ":") {
|
|
116
|
+
throw new CanonicalJsonError("INVALID_TOKEN");
|
|
117
|
+
}
|
|
118
|
+
this.pos++;
|
|
119
|
+
members2.set(key, this.parseValue());
|
|
120
|
+
this.skipWhitespace();
|
|
121
|
+
if (this.atEnd()) {
|
|
122
|
+
throw new CanonicalJsonError("UNEXPECTED_END");
|
|
123
|
+
}
|
|
124
|
+
const next = this.text[this.pos];
|
|
125
|
+
if (next === ",") {
|
|
126
|
+
this.pos++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (next === "}") {
|
|
130
|
+
this.pos++;
|
|
131
|
+
return members2;
|
|
132
|
+
}
|
|
133
|
+
throw new CanonicalJsonError("INVALID_TOKEN");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
parseArray() {
|
|
137
|
+
this.pos++;
|
|
138
|
+
const items = [];
|
|
139
|
+
this.skipWhitespace();
|
|
140
|
+
if (this.atEnd()) {
|
|
141
|
+
throw new CanonicalJsonError("UNEXPECTED_END");
|
|
142
|
+
}
|
|
143
|
+
if (this.text[this.pos] === "]") {
|
|
144
|
+
this.pos++;
|
|
145
|
+
return items;
|
|
146
|
+
}
|
|
147
|
+
for (; ; ) {
|
|
148
|
+
items.push(this.parseValue());
|
|
149
|
+
this.skipWhitespace();
|
|
150
|
+
if (this.atEnd()) {
|
|
151
|
+
throw new CanonicalJsonError("UNEXPECTED_END");
|
|
152
|
+
}
|
|
153
|
+
const next = this.text[this.pos];
|
|
154
|
+
if (next === ",") {
|
|
155
|
+
this.pos++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (next === "]") {
|
|
159
|
+
this.pos++;
|
|
160
|
+
return items;
|
|
161
|
+
}
|
|
162
|
+
throw new CanonicalJsonError("INVALID_TOKEN");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
parseString() {
|
|
166
|
+
this.pos++;
|
|
167
|
+
let out = "";
|
|
168
|
+
for (; ; ) {
|
|
169
|
+
if (this.atEnd()) {
|
|
170
|
+
throw new CanonicalJsonError("UNEXPECTED_END");
|
|
171
|
+
}
|
|
172
|
+
const c = this.text[this.pos];
|
|
173
|
+
const code = this.text.charCodeAt(this.pos);
|
|
174
|
+
if (c === '"') {
|
|
175
|
+
this.pos++;
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
if (c === "\\") {
|
|
179
|
+
out += this.parseEscape();
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (code < 32) {
|
|
183
|
+
throw new CanonicalJsonError("INVALID_TOKEN");
|
|
184
|
+
}
|
|
185
|
+
out += c;
|
|
186
|
+
this.pos++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
parseEscape() {
|
|
190
|
+
this.pos++;
|
|
191
|
+
if (this.atEnd()) {
|
|
192
|
+
throw new CanonicalJsonError("UNEXPECTED_END");
|
|
193
|
+
}
|
|
194
|
+
const c = this.text[this.pos];
|
|
195
|
+
this.pos++;
|
|
196
|
+
switch (c) {
|
|
197
|
+
case '"':
|
|
198
|
+
return '"';
|
|
199
|
+
case "\\":
|
|
200
|
+
return "\\";
|
|
201
|
+
case "/":
|
|
202
|
+
return "/";
|
|
203
|
+
case "b":
|
|
204
|
+
return "\b";
|
|
205
|
+
case "f":
|
|
206
|
+
return "\f";
|
|
207
|
+
case "n":
|
|
208
|
+
return "\n";
|
|
209
|
+
case "r":
|
|
210
|
+
return "\r";
|
|
211
|
+
case "t":
|
|
212
|
+
return " ";
|
|
213
|
+
case "u":
|
|
214
|
+
return this.parseUnicodeEscape();
|
|
215
|
+
default:
|
|
216
|
+
throw new CanonicalJsonError("INVALID_ESCAPE");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
parseUnicodeEscape() {
|
|
220
|
+
const high = this.readHex4();
|
|
221
|
+
if (high >= 56320 && high <= 57343) {
|
|
222
|
+
throw new CanonicalJsonError("INVALID_ESCAPE");
|
|
223
|
+
}
|
|
224
|
+
if (high < 55296 || high > 56319) {
|
|
225
|
+
return String.fromCharCode(high);
|
|
226
|
+
}
|
|
227
|
+
if (this.text[this.pos] !== "\\" || this.text[this.pos + 1] !== "u") {
|
|
228
|
+
throw new CanonicalJsonError("INVALID_ESCAPE");
|
|
229
|
+
}
|
|
230
|
+
this.pos += 2;
|
|
231
|
+
const low = this.readHex4();
|
|
232
|
+
if (low < 56320 || low > 57343) {
|
|
233
|
+
throw new CanonicalJsonError("INVALID_ESCAPE");
|
|
234
|
+
}
|
|
235
|
+
return String.fromCharCode(high, low);
|
|
236
|
+
}
|
|
237
|
+
readHex4() {
|
|
238
|
+
if (this.pos + 4 > this.text.length) {
|
|
239
|
+
throw new CanonicalJsonError("UNEXPECTED_END");
|
|
240
|
+
}
|
|
241
|
+
const hex = this.text.slice(this.pos, this.pos + 4);
|
|
242
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) {
|
|
243
|
+
throw new CanonicalJsonError("INVALID_ESCAPE");
|
|
244
|
+
}
|
|
245
|
+
this.pos += 4;
|
|
246
|
+
return parseInt(hex, 16);
|
|
247
|
+
}
|
|
248
|
+
parseNumber() {
|
|
249
|
+
const start = this.pos;
|
|
250
|
+
if (this.text[this.pos] === "-") {
|
|
251
|
+
this.pos++;
|
|
252
|
+
}
|
|
253
|
+
if (this.atEnd()) {
|
|
254
|
+
throw new CanonicalJsonError("UNEXPECTED_END");
|
|
255
|
+
}
|
|
256
|
+
const first = this.text[this.pos];
|
|
257
|
+
if (first < "0" || first > "9") {
|
|
258
|
+
throw new CanonicalJsonError("INVALID_TOKEN");
|
|
259
|
+
}
|
|
260
|
+
if (first === "0") {
|
|
261
|
+
this.pos++;
|
|
262
|
+
} else {
|
|
263
|
+
while (!this.atEnd() && this.text[this.pos] >= "0" && this.text[this.pos] <= "9") {
|
|
264
|
+
this.pos++;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!this.atEnd()) {
|
|
268
|
+
const next = this.text[this.pos];
|
|
269
|
+
if (next >= "0" && next <= "9") {
|
|
270
|
+
throw new CanonicalJsonError("INVALID_TOKEN");
|
|
271
|
+
}
|
|
272
|
+
if (next === "." || next === "e" || next === "E") {
|
|
273
|
+
throw new CanonicalJsonError("NON_INTEGER_NUMBER");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const value = BigInt(this.text.slice(start, this.pos));
|
|
277
|
+
if (value < INT64_MIN || value > INT64_MAX) {
|
|
278
|
+
throw new CanonicalJsonError("INTEGER_OUT_OF_RANGE");
|
|
279
|
+
}
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
function encodeCanonicalJson(value) {
|
|
284
|
+
return new TextEncoder().encode(encodeToString(value));
|
|
285
|
+
}
|
|
286
|
+
function encodeToString(value) {
|
|
287
|
+
if (value === null) {
|
|
288
|
+
return "null";
|
|
289
|
+
}
|
|
290
|
+
if (typeof value === "boolean") {
|
|
291
|
+
return value ? "true" : "false";
|
|
292
|
+
}
|
|
293
|
+
if (typeof value === "bigint") {
|
|
294
|
+
return value.toString();
|
|
295
|
+
}
|
|
296
|
+
if (typeof value === "string") {
|
|
297
|
+
return encodeString(value);
|
|
298
|
+
}
|
|
299
|
+
if (Array.isArray(value)) {
|
|
300
|
+
return `[${value.map(encodeToString).join(",")}]`;
|
|
301
|
+
}
|
|
302
|
+
const keys = [...value.keys()].sort(compareByCodePoints);
|
|
303
|
+
const members2 = keys.map((key) => `${encodeString(key)}:${encodeToString(value.get(key))}`);
|
|
304
|
+
return `{${members2.join(",")}}`;
|
|
305
|
+
}
|
|
306
|
+
function compareByCodePoints(a, b) {
|
|
307
|
+
let i = 0;
|
|
308
|
+
let j = 0;
|
|
309
|
+
while (i < a.length && j < b.length) {
|
|
310
|
+
const ca = a.codePointAt(i);
|
|
311
|
+
const cb = b.codePointAt(j);
|
|
312
|
+
if (ca !== cb) {
|
|
313
|
+
return ca - cb;
|
|
314
|
+
}
|
|
315
|
+
i += ca > 65535 ? 2 : 1;
|
|
316
|
+
j += cb > 65535 ? 2 : 1;
|
|
317
|
+
}
|
|
318
|
+
return a.length - i - (b.length - j);
|
|
319
|
+
}
|
|
320
|
+
function encodeString(value) {
|
|
321
|
+
let out = '"';
|
|
322
|
+
for (const ch of value) {
|
|
323
|
+
const code = ch.codePointAt(0);
|
|
324
|
+
if (ch === '"') {
|
|
325
|
+
out += '\\"';
|
|
326
|
+
} else if (ch === "\\") {
|
|
327
|
+
out += "\\\\";
|
|
328
|
+
} else if (code === 8) {
|
|
329
|
+
out += "\\b";
|
|
330
|
+
} else if (code === 12) {
|
|
331
|
+
out += "\\f";
|
|
332
|
+
} else if (code === 10) {
|
|
333
|
+
out += "\\n";
|
|
334
|
+
} else if (code === 13) {
|
|
335
|
+
out += "\\r";
|
|
336
|
+
} else if (code === 9) {
|
|
337
|
+
out += "\\t";
|
|
338
|
+
} else if (code < 32) {
|
|
339
|
+
out += `\\u00${code.toString(16).padStart(2, "0")}`;
|
|
340
|
+
} else {
|
|
341
|
+
out += ch;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return out + '"';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/server/client-proof/proof.ts
|
|
348
|
+
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
349
|
+
var CLIENT_PROOF_PROFILE = "clientProofV1";
|
|
350
|
+
var ABSENT_BODY_SHA256 = "0".repeat(64);
|
|
351
|
+
var DEFAULT_REPLAY_WINDOW_MILLIS = 3e5;
|
|
352
|
+
var ProofInputError = class extends Error {
|
|
353
|
+
constructor() {
|
|
354
|
+
super("proof input field contains a C0 control character");
|
|
355
|
+
this.name = "ProofInputError";
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
function canonicalProofInput(input) {
|
|
359
|
+
const fields = [
|
|
360
|
+
CLIENT_PROOF_PROFILE,
|
|
361
|
+
input.method,
|
|
362
|
+
input.path,
|
|
363
|
+
input.clientId,
|
|
364
|
+
input.keyId,
|
|
365
|
+
input.nonce,
|
|
366
|
+
input.issuedAtMillis.toString(),
|
|
367
|
+
input.bodySha256
|
|
368
|
+
];
|
|
369
|
+
for (const field of fields) {
|
|
370
|
+
for (const ch of field) {
|
|
371
|
+
if (ch.codePointAt(0) < 32) {
|
|
372
|
+
throw new ProofInputError();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return fields.join("\n");
|
|
377
|
+
}
|
|
378
|
+
function computeClientProof(input, key) {
|
|
379
|
+
return createHmac("sha256", key).update(canonicalProofInput(input), "utf8").digest("hex");
|
|
380
|
+
}
|
|
381
|
+
function sha256Hex(bytes) {
|
|
382
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
383
|
+
}
|
|
384
|
+
function constantTimeEqualsProof(expected, presented) {
|
|
385
|
+
const a = Buffer.from(expected, "utf8");
|
|
386
|
+
const b = Buffer.from(presented, "utf8");
|
|
387
|
+
if (a.length !== b.length) {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
return timingSafeEqual(a, b);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// src/server/client-proof/refusal.ts
|
|
394
|
+
import { randomBytes } from "crypto";
|
|
395
|
+
var HTTP_STATUS = {
|
|
396
|
+
PROOF_INVALID: 401,
|
|
397
|
+
PROOF_REPLAYED: 401,
|
|
398
|
+
PROOF_EXPIRED: 401,
|
|
399
|
+
SESSION_REVOKED: 401,
|
|
400
|
+
PROFILE_REJECTED: 400,
|
|
401
|
+
CONTRACT_UNSUPPORTED: 409
|
|
402
|
+
};
|
|
403
|
+
function newHexId() {
|
|
404
|
+
return randomBytes(16).toString("hex");
|
|
405
|
+
}
|
|
406
|
+
var ClientProofRefusal = class _ClientProofRefusal {
|
|
407
|
+
constructor(code, message) {
|
|
408
|
+
this.code = code;
|
|
409
|
+
this.message = message;
|
|
410
|
+
}
|
|
411
|
+
get httpStatus() {
|
|
412
|
+
return HTTP_STATUS[this.code];
|
|
413
|
+
}
|
|
414
|
+
/** The canonical bytes of `{"error":{"code":…,"message":…,"requestId":…}}`. */
|
|
415
|
+
envelopeBytes(requestId) {
|
|
416
|
+
const error = /* @__PURE__ */ new Map([
|
|
417
|
+
["code", this.code],
|
|
418
|
+
["message", this.message],
|
|
419
|
+
["requestId", requestId]
|
|
420
|
+
]);
|
|
421
|
+
return encodeCanonicalJson(/* @__PURE__ */ new Map([["error", error]]));
|
|
422
|
+
}
|
|
423
|
+
/** Nothing request-derived reaches a log through this. */
|
|
424
|
+
toString() {
|
|
425
|
+
return `ClientProofRefusal(${this.code})`;
|
|
426
|
+
}
|
|
427
|
+
// ---- shape: what arrived is not the contract (rule 2) -------------------
|
|
428
|
+
static unroutable() {
|
|
429
|
+
return contractViolation("no operation in this contract answers that method and path");
|
|
430
|
+
}
|
|
431
|
+
static malformedHeaders() {
|
|
432
|
+
return contractViolation("the request does not carry the contract header fields exactly once each");
|
|
433
|
+
}
|
|
434
|
+
static missingContentType() {
|
|
435
|
+
return contractViolation("a request that carries a body must declare the contract content type");
|
|
436
|
+
}
|
|
437
|
+
static bodyTooLarge() {
|
|
438
|
+
return contractViolation("the request body exceeds the size this server accepts");
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* The body parsed but its bytes are not the canonical form of what it
|
|
442
|
+
* parsed to. Not PROOF_INVALID even though it is discovered next to the
|
|
443
|
+
* proof: the proof over these bytes verifies perfectly well, and an
|
|
444
|
+
* auth-family answer would tell the client to re-handshake and send the
|
|
445
|
+
* same non-canonical bytes again.
|
|
446
|
+
*/
|
|
447
|
+
static bodyNotCanonical() {
|
|
448
|
+
return contractViolation("the request body is not the canonical JSON form of the value it encodes");
|
|
449
|
+
}
|
|
450
|
+
static bodyNotTheDeclaredType() {
|
|
451
|
+
return contractViolation("the request body is not the request type this operation declares");
|
|
452
|
+
}
|
|
453
|
+
static sessionHeaderMisplaced() {
|
|
454
|
+
return contractViolation("the session header is present exactly on the operations that require one");
|
|
455
|
+
}
|
|
456
|
+
static unprocessable() {
|
|
457
|
+
return contractViolation("the request could not be processed");
|
|
458
|
+
}
|
|
459
|
+
// ---- the profile allowlist ----------------------------------------------
|
|
460
|
+
static profileRejected() {
|
|
461
|
+
return new _ClientProofRefusal("PROFILE_REJECTED", "the named auth profile is not on this contract's allowlist");
|
|
462
|
+
}
|
|
463
|
+
// ---- auth: a new session might clear it (rule 1) -------------------------
|
|
464
|
+
static sessionRevoked() {
|
|
465
|
+
return new _ClientProofRefusal("SESSION_REVOKED", "the key or session was revoked");
|
|
466
|
+
}
|
|
467
|
+
static proofExpired() {
|
|
468
|
+
return new _ClientProofRefusal("PROOF_EXPIRED", "issuedAtMillis falls outside the replay window");
|
|
469
|
+
}
|
|
470
|
+
static proofReplayed() {
|
|
471
|
+
return new _ClientProofRefusal("PROOF_REPLAYED", "the nonce was already used inside the replay window");
|
|
472
|
+
}
|
|
473
|
+
static proofInvalid() {
|
|
474
|
+
return new _ClientProofRefusal("PROOF_INVALID", "the client proof did not verify");
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
function contractViolation(message) {
|
|
478
|
+
return new ClientProofRefusal("CONTRACT_UNSUPPORTED", message);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/server/client-proof/state.ts
|
|
482
|
+
function systemClock() {
|
|
483
|
+
return { nowMillis: () => Date.now() };
|
|
484
|
+
}
|
|
485
|
+
var TestClock = class {
|
|
486
|
+
constructor(millis) {
|
|
487
|
+
this.millis = millis;
|
|
488
|
+
}
|
|
489
|
+
nowMillis() {
|
|
490
|
+
return this.millis;
|
|
491
|
+
}
|
|
492
|
+
advance(byMillis) {
|
|
493
|
+
this.millis += byMillis;
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
var DEFAULT_SESSION_TTL_MILLIS = 6e5;
|
|
497
|
+
function replayKeyOf(clientId, nonce) {
|
|
498
|
+
return `${clientId}${nonce}`;
|
|
499
|
+
}
|
|
500
|
+
var ClientProofState = class {
|
|
501
|
+
replayWindowMillis;
|
|
502
|
+
clock;
|
|
503
|
+
keys = /* @__PURE__ */ new Map();
|
|
504
|
+
sessions = /* @__PURE__ */ new Map();
|
|
505
|
+
/** replayKeyOf(...) → the issuedAtMillis it was spent at. */
|
|
506
|
+
spentNonces = /* @__PURE__ */ new Map();
|
|
507
|
+
revokedKeyIds = /* @__PURE__ */ new Set();
|
|
508
|
+
holds = /* @__PURE__ */ new Map();
|
|
509
|
+
initialSessionTtlMillis;
|
|
510
|
+
sessionTtlMillis;
|
|
511
|
+
requestCount = 0;
|
|
512
|
+
handshakeCount = 0;
|
|
513
|
+
echoCount = 0;
|
|
514
|
+
itemsListCount = 0;
|
|
515
|
+
refusalCount = 0;
|
|
516
|
+
constructor(options) {
|
|
517
|
+
this.clock = options.clock ?? systemClock();
|
|
518
|
+
this.initialSessionTtlMillis = options.sessionTtlMillis ?? DEFAULT_SESSION_TTL_MILLIS;
|
|
519
|
+
this.sessionTtlMillis = this.initialSessionTtlMillis;
|
|
520
|
+
this.replayWindowMillis = options.replayWindowMillis ?? DEFAULT_REPLAY_WINDOW_MILLIS;
|
|
521
|
+
for (const [keyId, key] of Object.entries(options.keys)) {
|
|
522
|
+
this.keys.set(keyId, typeof key === "string" ? new TextEncoder().encode(key) : key);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// ---- admission ---------------------------------------------------------
|
|
526
|
+
/**
|
|
527
|
+
* Runs the contract's checks in the contract's order and returns the
|
|
528
|
+
* refusal, or null when the request is admitted (spending its nonce).
|
|
529
|
+
*/
|
|
530
|
+
admit(args) {
|
|
531
|
+
const now = this.clock.nowMillis();
|
|
532
|
+
this.prune(now);
|
|
533
|
+
if (this.revokedKeyIds.has(args.keyId)) {
|
|
534
|
+
return ClientProofRefusal.sessionRevoked();
|
|
535
|
+
}
|
|
536
|
+
if (args.requiresSession) {
|
|
537
|
+
const session = args.presentedSessionId === null ? void 0 : this.sessions.get(args.presentedSessionId);
|
|
538
|
+
if (session === void 0 || session.expiresAtMillis <= now || session.keyId !== args.keyId || session.clientId !== args.clientId) {
|
|
539
|
+
return ClientProofRefusal.sessionRevoked();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const age = now - Number(args.proofInput.issuedAtMillis);
|
|
543
|
+
if (age < 0 || age > this.replayWindowMillis) {
|
|
544
|
+
return ClientProofRefusal.proofExpired();
|
|
545
|
+
}
|
|
546
|
+
const replayKey = replayKeyOf(args.clientId, args.proofInput.nonce);
|
|
547
|
+
if (this.spentNonces.has(replayKey)) {
|
|
548
|
+
return ClientProofRefusal.proofReplayed();
|
|
549
|
+
}
|
|
550
|
+
const key = this.keys.get(args.keyId);
|
|
551
|
+
if (key === void 0) {
|
|
552
|
+
return ClientProofRefusal.proofInvalid();
|
|
553
|
+
}
|
|
554
|
+
if (!constantTimeEqualsProof(computeClientProof(args.proofInput, key), args.presentedProof)) {
|
|
555
|
+
return ClientProofRefusal.proofInvalid();
|
|
556
|
+
}
|
|
557
|
+
this.spentNonces.set(replayKey, Number(args.proofInput.issuedAtMillis));
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
// ---- sessions ----------------------------------------------------------
|
|
561
|
+
/** Opens a session and returns its id and the expiry the server advertises. */
|
|
562
|
+
openSession(clientId, keyId) {
|
|
563
|
+
const now = this.clock.nowMillis();
|
|
564
|
+
this.prune(now);
|
|
565
|
+
const sessionId = newHexId();
|
|
566
|
+
const expiresAtMillis = now + this.sessionTtlMillis;
|
|
567
|
+
this.sessions.set(sessionId, { clientId, keyId, expiresAtMillis });
|
|
568
|
+
return { sessionId, expiresAtMillis };
|
|
569
|
+
}
|
|
570
|
+
/** Test hook: installs a session with a chosen id (wire-fixture replays). */
|
|
571
|
+
seedSession(sessionId, clientId, keyId, expiresAtMillis) {
|
|
572
|
+
this.sessions.set(sessionId, { clientId, keyId, expiresAtMillis });
|
|
573
|
+
}
|
|
574
|
+
/** Drops every session, as a restart would. Advertised expiries stay told. */
|
|
575
|
+
expireSessions() {
|
|
576
|
+
this.sessions.clear();
|
|
577
|
+
}
|
|
578
|
+
/** Revokes a key and drops the sessions it opened. */
|
|
579
|
+
revokeKey(keyId) {
|
|
580
|
+
this.revokedKeyIds.add(keyId);
|
|
581
|
+
for (const [sessionId, session] of this.sessions) {
|
|
582
|
+
if (session.keyId === keyId) {
|
|
583
|
+
this.sessions.delete(sessionId);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
setSessionTtlMillis(millis) {
|
|
588
|
+
this.sessionTtlMillis = millis;
|
|
589
|
+
}
|
|
590
|
+
/** Returns the state to how it started, counters included. */
|
|
591
|
+
reset() {
|
|
592
|
+
this.sessions.clear();
|
|
593
|
+
this.spentNonces.clear();
|
|
594
|
+
this.revokedKeyIds.clear();
|
|
595
|
+
this.holds.clear();
|
|
596
|
+
this.sessionTtlMillis = this.initialSessionTtlMillis;
|
|
597
|
+
this.requestCount = 0;
|
|
598
|
+
this.handshakeCount = 0;
|
|
599
|
+
this.echoCount = 0;
|
|
600
|
+
this.itemsListCount = 0;
|
|
601
|
+
this.refusalCount = 0;
|
|
602
|
+
}
|
|
603
|
+
// ---- delays (dev/test only) --------------------------------------------
|
|
604
|
+
/** Makes the next `count` requests to `path` wait `millis` before processing. */
|
|
605
|
+
holdPath(path, millis, count) {
|
|
606
|
+
this.holds.set(path, { millis, remaining: count });
|
|
607
|
+
}
|
|
608
|
+
/** Consumes one configured delay for `path`; returns how long to wait, or 0. */
|
|
609
|
+
takeHoldMillis(path) {
|
|
610
|
+
const hold2 = this.holds.get(path);
|
|
611
|
+
if (hold2 === void 0) {
|
|
612
|
+
return 0;
|
|
613
|
+
}
|
|
614
|
+
hold2.remaining -= 1;
|
|
615
|
+
if (hold2.remaining <= 0) {
|
|
616
|
+
this.holds.delete(path);
|
|
617
|
+
}
|
|
618
|
+
return hold2.millis;
|
|
619
|
+
}
|
|
620
|
+
// ---- counters ----------------------------------------------------------
|
|
621
|
+
recordRequest() {
|
|
622
|
+
this.requestCount += 1;
|
|
623
|
+
}
|
|
624
|
+
recordOperation(operationId) {
|
|
625
|
+
if (operationId === "auth.clientProof.handshake") {
|
|
626
|
+
this.handshakeCount += 1;
|
|
627
|
+
} else if (operationId === "echo.send") {
|
|
628
|
+
this.echoCount += 1;
|
|
629
|
+
} else if (operationId === "items.list") {
|
|
630
|
+
this.itemsListCount += 1;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
recordRefusal() {
|
|
634
|
+
this.refusalCount += 1;
|
|
635
|
+
}
|
|
636
|
+
stats() {
|
|
637
|
+
this.prune(this.clock.nowMillis());
|
|
638
|
+
return {
|
|
639
|
+
requestCount: this.requestCount,
|
|
640
|
+
handshakeCount: this.handshakeCount,
|
|
641
|
+
echoCount: this.echoCount,
|
|
642
|
+
itemsListCount: this.itemsListCount,
|
|
643
|
+
refusalCount: this.refusalCount,
|
|
644
|
+
liveSessionCount: this.sessions.size,
|
|
645
|
+
spentNonceCount: this.spentNonces.size
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
nowMillis() {
|
|
649
|
+
return this.clock.nowMillis();
|
|
650
|
+
}
|
|
651
|
+
/** The clock, exposed for the dev control surface's advance-clock route. */
|
|
652
|
+
get clockRef() {
|
|
653
|
+
return this.clock;
|
|
654
|
+
}
|
|
655
|
+
// ---- housekeeping ------------------------------------------------------
|
|
656
|
+
/**
|
|
657
|
+
* Drops what can no longer affect an answer. The nonce predicate is the
|
|
658
|
+
* exact negation of the window check in `admit`: an entry is dropped only
|
|
659
|
+
* once a proof carrying that issuedAtMillis would be refused as expired
|
|
660
|
+
* anyway. Dropping one moment earlier would let a nonce inside the window
|
|
661
|
+
* be spent twice.
|
|
662
|
+
*/
|
|
663
|
+
prune(nowMillis) {
|
|
664
|
+
for (const [sessionId, session] of this.sessions) {
|
|
665
|
+
if (session.expiresAtMillis <= nowMillis) {
|
|
666
|
+
this.sessions.delete(sessionId);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
for (const [key, issuedAtMillis] of this.spentNonces) {
|
|
670
|
+
if (nowMillis - issuedAtMillis > this.replayWindowMillis) {
|
|
671
|
+
this.spentNonces.delete(key);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// src/server/client-proof/admission.ts
|
|
678
|
+
var CLIENT_PROOF_HEADERS = {
|
|
679
|
+
profile: "x-spfn-auth-profile",
|
|
680
|
+
clientId: "x-spfn-client-id",
|
|
681
|
+
keyId: "x-spfn-key-id",
|
|
682
|
+
nonce: "x-spfn-nonce",
|
|
683
|
+
issuedAtMillis: "x-spfn-issued-at",
|
|
684
|
+
proof: "x-spfn-proof",
|
|
685
|
+
session: "x-spfn-session"
|
|
686
|
+
};
|
|
687
|
+
var CLIENT_PROOF_CONTENT_TYPE = "application/json";
|
|
688
|
+
var INT64_MIN2 = -(2n ** 63n);
|
|
689
|
+
var INT64_MAX2 = 2n ** 63n - 1n;
|
|
690
|
+
function admitClientProofRequest(args) {
|
|
691
|
+
const credentials = readCredentials(args.headers);
|
|
692
|
+
if (credentials === null) {
|
|
693
|
+
return refused(ClientProofRefusal.malformedHeaders());
|
|
694
|
+
}
|
|
695
|
+
if (credentials.profile !== CLIENT_PROOF_PROFILE) {
|
|
696
|
+
return refused(ClientProofRefusal.profileRejected());
|
|
697
|
+
}
|
|
698
|
+
if (!isRequestContentType(args.headers.get("content-type"))) {
|
|
699
|
+
return refused(ClientProofRefusal.missingContentType());
|
|
700
|
+
}
|
|
701
|
+
if (args.requiresSession !== (credentials.sessionId !== null)) {
|
|
702
|
+
return refused(ClientProofRefusal.sessionHeaderMisplaced());
|
|
703
|
+
}
|
|
704
|
+
let value;
|
|
705
|
+
try {
|
|
706
|
+
value = parseCanonicalJson(args.body);
|
|
707
|
+
} catch {
|
|
708
|
+
return refused(ClientProofRefusal.bodyNotCanonical());
|
|
709
|
+
}
|
|
710
|
+
if (!isCanonicalBytes(args.body, value)) {
|
|
711
|
+
return refused(ClientProofRefusal.bodyNotCanonical());
|
|
712
|
+
}
|
|
713
|
+
const proofInput = {
|
|
714
|
+
method: args.method,
|
|
715
|
+
path: args.path,
|
|
716
|
+
clientId: credentials.clientId,
|
|
717
|
+
keyId: credentials.keyId,
|
|
718
|
+
nonce: credentials.nonce,
|
|
719
|
+
issuedAtMillis: credentials.issuedAtMillis,
|
|
720
|
+
bodySha256: sha256Hex(args.body)
|
|
721
|
+
};
|
|
722
|
+
let refusal;
|
|
723
|
+
try {
|
|
724
|
+
refusal = args.state.admit({
|
|
725
|
+
clientId: credentials.clientId,
|
|
726
|
+
keyId: credentials.keyId,
|
|
727
|
+
presentedSessionId: credentials.sessionId,
|
|
728
|
+
requiresSession: args.requiresSession,
|
|
729
|
+
proofInput,
|
|
730
|
+
presentedProof: credentials.proof
|
|
731
|
+
});
|
|
732
|
+
} catch {
|
|
733
|
+
return refused(ClientProofRefusal.unprocessable());
|
|
734
|
+
}
|
|
735
|
+
if (refusal !== null) {
|
|
736
|
+
return refused(refusal);
|
|
737
|
+
}
|
|
738
|
+
return { admitted: true, value, credentials };
|
|
739
|
+
}
|
|
740
|
+
function refused(refusal) {
|
|
741
|
+
return { admitted: false, refusal };
|
|
742
|
+
}
|
|
743
|
+
function readCredentials(headers) {
|
|
744
|
+
const profile = headers.get(CLIENT_PROOF_HEADERS.profile);
|
|
745
|
+
const clientId = headers.get(CLIENT_PROOF_HEADERS.clientId);
|
|
746
|
+
const keyId = headers.get(CLIENT_PROOF_HEADERS.keyId);
|
|
747
|
+
const nonce = headers.get(CLIENT_PROOF_HEADERS.nonce);
|
|
748
|
+
const issuedAtRaw = headers.get(CLIENT_PROOF_HEADERS.issuedAtMillis);
|
|
749
|
+
const proof = headers.get(CLIENT_PROOF_HEADERS.proof);
|
|
750
|
+
if (profile === null || clientId === null || keyId === null || nonce === null || issuedAtRaw === null || proof === null) {
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
const issuedAtMillis = parseInt64(issuedAtRaw);
|
|
754
|
+
if (issuedAtMillis === null) {
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
profile,
|
|
759
|
+
clientId,
|
|
760
|
+
keyId,
|
|
761
|
+
nonce,
|
|
762
|
+
issuedAtMillis,
|
|
763
|
+
proof,
|
|
764
|
+
sessionId: headers.get(CLIENT_PROOF_HEADERS.session)
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function parseInt64(raw) {
|
|
768
|
+
if (!/^[+-]?\d{1,19}$/.test(raw)) {
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
const value = BigInt(raw);
|
|
772
|
+
if (value < INT64_MIN2 || value > INT64_MAX2) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
return value;
|
|
776
|
+
}
|
|
777
|
+
function isRequestContentType(value) {
|
|
778
|
+
if (value === null) {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
return value.split(";")[0].trim().toLowerCase() === CLIENT_PROOF_CONTENT_TYPE;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// src/server/client-proof/contract-types.ts
|
|
785
|
+
var CONTRACT_OPERATIONS = [
|
|
786
|
+
{ id: "auth.clientProof.handshake", method: "POST", path: "/v1/auth/client-proof/handshake", requiresSession: false },
|
|
787
|
+
{ id: "echo.send", method: "POST", path: "/v1/echo", requiresSession: true },
|
|
788
|
+
{ id: "items.list", method: "POST", path: "/v1/items/list", requiresSession: true }
|
|
789
|
+
];
|
|
790
|
+
var ContractTypeError = class extends Error {
|
|
791
|
+
constructor() {
|
|
792
|
+
super("not the declared contract type");
|
|
793
|
+
this.name = "ContractTypeError";
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
function decodeHandshakeRequest(value) {
|
|
797
|
+
const members2 = objectWithKeys(value, ["clientId", "keyId", "nonce", "issuedAtMillis"], []);
|
|
798
|
+
return {
|
|
799
|
+
clientId: text(members2.get("clientId")),
|
|
800
|
+
keyId: text(members2.get("keyId")),
|
|
801
|
+
nonce: text(members2.get("nonce")),
|
|
802
|
+
issuedAtMillis: integer(members2.get("issuedAtMillis"))
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
function decodeEchoRequest(value) {
|
|
806
|
+
const members2 = objectWithKeys(value, ["message", "sequence"], []);
|
|
807
|
+
return {
|
|
808
|
+
message: text(members2.get("message")),
|
|
809
|
+
sequence: integer(members2.get("sequence"))
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
function decodeListItemsRequest(value) {
|
|
813
|
+
const members2 = objectWithKeys(value, ["limit"], ["cursor"]);
|
|
814
|
+
const request = { limit: integer(members2.get("limit")) };
|
|
815
|
+
if (members2.has("cursor")) {
|
|
816
|
+
request.cursor = text(members2.get("cursor"));
|
|
817
|
+
}
|
|
818
|
+
return request;
|
|
819
|
+
}
|
|
820
|
+
function objectWithKeys(value, required, optional) {
|
|
821
|
+
if (!(value instanceof Map)) {
|
|
822
|
+
throw new ContractTypeError();
|
|
823
|
+
}
|
|
824
|
+
for (const key of required) {
|
|
825
|
+
if (!value.has(key)) {
|
|
826
|
+
throw new ContractTypeError();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
for (const key of value.keys()) {
|
|
830
|
+
if (!required.includes(key) && !optional.includes(key)) {
|
|
831
|
+
throw new ContractTypeError();
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return value;
|
|
835
|
+
}
|
|
836
|
+
function text(value) {
|
|
837
|
+
if (typeof value !== "string") {
|
|
838
|
+
throw new ContractTypeError();
|
|
839
|
+
}
|
|
840
|
+
return value;
|
|
841
|
+
}
|
|
842
|
+
function integer(value) {
|
|
843
|
+
if (typeof value !== "bigint") {
|
|
844
|
+
throw new ContractTypeError();
|
|
845
|
+
}
|
|
846
|
+
return value;
|
|
847
|
+
}
|
|
848
|
+
function encodeHandshakeResponse(sessionId, expiresAtMillis) {
|
|
849
|
+
return /* @__PURE__ */ new Map([
|
|
850
|
+
["sessionId", sessionId],
|
|
851
|
+
["expiresAtMillis", expiresAtMillis]
|
|
852
|
+
]);
|
|
853
|
+
}
|
|
854
|
+
function encodeEchoResponse(message, sequence, serverTimeMillis) {
|
|
855
|
+
return /* @__PURE__ */ new Map([
|
|
856
|
+
["message", message],
|
|
857
|
+
["sequence", sequence],
|
|
858
|
+
["serverTimeMillis", serverTimeMillis]
|
|
859
|
+
]);
|
|
860
|
+
}
|
|
861
|
+
function encodeListItemsResponse(items, nextCursor) {
|
|
862
|
+
const encodedItems = items.map((item) => /* @__PURE__ */ new Map([
|
|
863
|
+
["id", item.id],
|
|
864
|
+
["name", item.name],
|
|
865
|
+
["updatedAtMillis", item.updatedAtMillis]
|
|
866
|
+
]));
|
|
867
|
+
const members2 = /* @__PURE__ */ new Map([["items", encodedItems]]);
|
|
868
|
+
if (nextCursor !== null) {
|
|
869
|
+
members2.set("nextCursor", nextCursor);
|
|
870
|
+
}
|
|
871
|
+
return members2;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// src/server/client-proof/dev-control.ts
|
|
875
|
+
var CONTROL_PREFIX = "/control/";
|
|
876
|
+
var CONTROL_TOKEN_HEADER = "x-spfn-reference-control";
|
|
877
|
+
var HTTP_OK = 200;
|
|
878
|
+
var HTTP_BAD_REQUEST = 400;
|
|
879
|
+
var HTTP_FORBIDDEN = 403;
|
|
880
|
+
var HTTP_NOT_FOUND = 404;
|
|
881
|
+
var HTTP_CONFLICT = 409;
|
|
882
|
+
var MAX_CONTROL_BODY_BYTES = 4096;
|
|
883
|
+
async function handleControlRequest(state, controlToken, path, request) {
|
|
884
|
+
if (path === "/control/health") {
|
|
885
|
+
return answer(HTTP_OK, /* @__PURE__ */ new Map([["status", "ok"]]));
|
|
886
|
+
}
|
|
887
|
+
if (request.headers.get(CONTROL_TOKEN_HEADER) !== controlToken) {
|
|
888
|
+
return answer(HTTP_FORBIDDEN, failure("control token"));
|
|
889
|
+
}
|
|
890
|
+
const raw = new Uint8Array(await request.arrayBuffer());
|
|
891
|
+
const body = raw.length > MAX_CONTROL_BODY_BYTES ? raw.slice(0, MAX_CONTROL_BODY_BYTES) : raw;
|
|
892
|
+
switch (path) {
|
|
893
|
+
case "/control/stats":
|
|
894
|
+
return stats(state);
|
|
895
|
+
case "/control/reset":
|
|
896
|
+
state.reset();
|
|
897
|
+
return ok();
|
|
898
|
+
case "/control/expire-sessions":
|
|
899
|
+
state.expireSessions();
|
|
900
|
+
return ok();
|
|
901
|
+
case "/control/revoke-key":
|
|
902
|
+
return revokeKey(state, body);
|
|
903
|
+
case "/control/session-ttl":
|
|
904
|
+
return sessionTtl(state, body);
|
|
905
|
+
case "/control/hold":
|
|
906
|
+
return hold(state, body);
|
|
907
|
+
case "/control/advance-clock":
|
|
908
|
+
return advanceClock(state, body);
|
|
909
|
+
default:
|
|
910
|
+
return answer(HTTP_NOT_FOUND, failure("unknown control route"));
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
function stats(state) {
|
|
914
|
+
const counters = state.stats();
|
|
915
|
+
return answer(HTTP_OK, withOk(/* @__PURE__ */ new Map([
|
|
916
|
+
["echoCount", BigInt(counters.echoCount)],
|
|
917
|
+
["handshakeCount", BigInt(counters.handshakeCount)],
|
|
918
|
+
["itemsListCount", BigInt(counters.itemsListCount)],
|
|
919
|
+
["liveSessionCount", BigInt(counters.liveSessionCount)],
|
|
920
|
+
["refusalCount", BigInt(counters.refusalCount)],
|
|
921
|
+
["requestCount", BigInt(counters.requestCount)],
|
|
922
|
+
["spentNonceCount", BigInt(counters.spentNonceCount)]
|
|
923
|
+
])));
|
|
924
|
+
}
|
|
925
|
+
function revokeKey(state, body) {
|
|
926
|
+
const keyId = stringField(body, "keyId");
|
|
927
|
+
if (keyId === null) {
|
|
928
|
+
return badRequest("keyId");
|
|
929
|
+
}
|
|
930
|
+
state.revokeKey(keyId);
|
|
931
|
+
return ok();
|
|
932
|
+
}
|
|
933
|
+
function sessionTtl(state, body) {
|
|
934
|
+
const ttlMillis = integerField(body, "ttlMillis");
|
|
935
|
+
if (ttlMillis === null) {
|
|
936
|
+
return badRequest("ttlMillis");
|
|
937
|
+
}
|
|
938
|
+
state.setSessionTtlMillis(Number(ttlMillis));
|
|
939
|
+
return ok();
|
|
940
|
+
}
|
|
941
|
+
function hold(state, body) {
|
|
942
|
+
const path = stringField(body, "path");
|
|
943
|
+
const millis = integerField(body, "millis");
|
|
944
|
+
const count = integerField(body, "count");
|
|
945
|
+
if (path === null) {
|
|
946
|
+
return badRequest("path");
|
|
947
|
+
}
|
|
948
|
+
if (millis === null) {
|
|
949
|
+
return badRequest("millis");
|
|
950
|
+
}
|
|
951
|
+
if (count === null) {
|
|
952
|
+
return badRequest("count");
|
|
953
|
+
}
|
|
954
|
+
state.holdPath(path, Number(millis), Number(count));
|
|
955
|
+
return ok();
|
|
956
|
+
}
|
|
957
|
+
function advanceClock(state, body) {
|
|
958
|
+
const clock = state.clockRef;
|
|
959
|
+
if (!(clock instanceof TestClock)) {
|
|
960
|
+
return answer(HTTP_CONFLICT, failure("server is running on the system clock"));
|
|
961
|
+
}
|
|
962
|
+
const millis = integerField(body, "millis");
|
|
963
|
+
if (millis === null) {
|
|
964
|
+
return badRequest("millis");
|
|
965
|
+
}
|
|
966
|
+
clock.advance(Number(millis));
|
|
967
|
+
return ok();
|
|
968
|
+
}
|
|
969
|
+
function members(body) {
|
|
970
|
+
if (body.length === 0) {
|
|
971
|
+
return /* @__PURE__ */ new Map();
|
|
972
|
+
}
|
|
973
|
+
let parsed;
|
|
974
|
+
try {
|
|
975
|
+
parsed = parseCanonicalJson(body);
|
|
976
|
+
} catch {
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
return parsed instanceof Map ? parsed : null;
|
|
980
|
+
}
|
|
981
|
+
function stringField(body, field) {
|
|
982
|
+
const value = members(body)?.get(field);
|
|
983
|
+
return typeof value === "string" ? value : null;
|
|
984
|
+
}
|
|
985
|
+
function integerField(body, field) {
|
|
986
|
+
const value = members(body)?.get(field);
|
|
987
|
+
return typeof value === "bigint" ? value : null;
|
|
988
|
+
}
|
|
989
|
+
function badRequest(field) {
|
|
990
|
+
return answer(HTTP_BAD_REQUEST, failure(`missing or malformed field: ${field}`));
|
|
991
|
+
}
|
|
992
|
+
function ok() {
|
|
993
|
+
return answer(HTTP_OK, withOk(/* @__PURE__ */ new Map()));
|
|
994
|
+
}
|
|
995
|
+
function failure(reason) {
|
|
996
|
+
return /* @__PURE__ */ new Map([["ok", false], ["reason", reason]]);
|
|
997
|
+
}
|
|
998
|
+
function withOk(extra) {
|
|
999
|
+
extra.set("ok", true);
|
|
1000
|
+
return extra;
|
|
1001
|
+
}
|
|
1002
|
+
function answer(status, value) {
|
|
1003
|
+
const bytes = encodeCanonicalJson(value);
|
|
1004
|
+
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
1005
|
+
return new Response(buffer, { status, headers: { "content-type": "application/json" } });
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// src/server/client-proof/dev-handler.ts
|
|
1009
|
+
var MAX_BODY_BYTES = 1 << 20;
|
|
1010
|
+
var HTTP_OK2 = 200;
|
|
1011
|
+
var DEV_CATALOGUE = [
|
|
1012
|
+
{ id: "item-0001", name: "alpha", updatedAtMillis: 1750000000001n },
|
|
1013
|
+
{ id: "item-0002", name: "bravo", updatedAtMillis: 1750000000002n },
|
|
1014
|
+
{ id: "item-0003", name: "charlie", updatedAtMillis: 1750000000003n },
|
|
1015
|
+
{ id: "item-0004", name: "delta", updatedAtMillis: 1750000000004n },
|
|
1016
|
+
{ id: "item-0005", name: "echo", updatedAtMillis: 1750000000005n }
|
|
1017
|
+
];
|
|
1018
|
+
var DEV_MAX_LIMIT = 100n;
|
|
1019
|
+
function createClientProofDevHandler(options) {
|
|
1020
|
+
const state = new ClientProofState(options);
|
|
1021
|
+
const controlToken = options.controlToken ?? newHexId();
|
|
1022
|
+
const enableControl = options.enableControl ?? true;
|
|
1023
|
+
const log = options.log ?? (() => void 0);
|
|
1024
|
+
async function dispatch(request) {
|
|
1025
|
+
state.recordRequest();
|
|
1026
|
+
const url = new URL(request.url);
|
|
1027
|
+
if (enableControl && url.pathname.startsWith(CONTROL_PREFIX)) {
|
|
1028
|
+
return handleControlRequest(state, controlToken, url.pathname, request);
|
|
1029
|
+
}
|
|
1030
|
+
const operation = url.search === "" ? CONTRACT_OPERATIONS.find((op) => op.path === url.pathname && op.method === request.method) : void 0;
|
|
1031
|
+
if (operation === void 0) {
|
|
1032
|
+
return refuse(ClientProofRefusal.unroutable());
|
|
1033
|
+
}
|
|
1034
|
+
const body = await readBodyCapped(request);
|
|
1035
|
+
if (body === null) {
|
|
1036
|
+
return refuse(ClientProofRefusal.bodyTooLarge());
|
|
1037
|
+
}
|
|
1038
|
+
await waitOutHold(url.pathname);
|
|
1039
|
+
const admission = admitClientProofRequest({
|
|
1040
|
+
state,
|
|
1041
|
+
headers: request.headers,
|
|
1042
|
+
method: operation.method,
|
|
1043
|
+
path: operation.path,
|
|
1044
|
+
requiresSession: operation.requiresSession,
|
|
1045
|
+
body
|
|
1046
|
+
});
|
|
1047
|
+
if (!admission.admitted) {
|
|
1048
|
+
return refuse(admission.refusal);
|
|
1049
|
+
}
|
|
1050
|
+
return apply(operation, admission);
|
|
1051
|
+
}
|
|
1052
|
+
function apply(operation, admission) {
|
|
1053
|
+
let value;
|
|
1054
|
+
try {
|
|
1055
|
+
if (operation.id === "auth.clientProof.handshake") {
|
|
1056
|
+
const request = decodeHandshakeRequest(admission.value);
|
|
1057
|
+
if (request.clientId !== admission.credentials.clientId || request.keyId !== admission.credentials.keyId) {
|
|
1058
|
+
return refuse(ClientProofRefusal.bodyNotTheDeclaredType());
|
|
1059
|
+
}
|
|
1060
|
+
const opened = state.openSession(request.clientId, request.keyId);
|
|
1061
|
+
value = encodeHandshakeResponse(opened.sessionId, BigInt(opened.expiresAtMillis));
|
|
1062
|
+
} else if (operation.id === "echo.send") {
|
|
1063
|
+
const request = decodeEchoRequest(admission.value);
|
|
1064
|
+
value = encodeEchoResponse(request.message, request.sequence, BigInt(state.nowMillis()));
|
|
1065
|
+
} else {
|
|
1066
|
+
const listed = listItems(decodeListItemsRequest(admission.value));
|
|
1067
|
+
if (listed === null) {
|
|
1068
|
+
return refuse(ClientProofRefusal.bodyNotTheDeclaredType());
|
|
1069
|
+
}
|
|
1070
|
+
value = listed;
|
|
1071
|
+
}
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
if (error instanceof ContractTypeError) {
|
|
1074
|
+
return refuse(ClientProofRefusal.bodyNotTheDeclaredType());
|
|
1075
|
+
}
|
|
1076
|
+
return refuse(ClientProofRefusal.unprocessable());
|
|
1077
|
+
}
|
|
1078
|
+
state.recordOperation(operation.id);
|
|
1079
|
+
return contractResponse(HTTP_OK2, encodeCanonicalJson(value));
|
|
1080
|
+
}
|
|
1081
|
+
function refuse(refusal) {
|
|
1082
|
+
state.recordRefusal();
|
|
1083
|
+
return contractResponse(refusal.httpStatus, refusal.envelopeBytes(newHexId()));
|
|
1084
|
+
}
|
|
1085
|
+
async function waitOutHold(path) {
|
|
1086
|
+
const millis = state.takeHoldMillis(path);
|
|
1087
|
+
if (millis > 0) {
|
|
1088
|
+
await new Promise((resolve) => setTimeout(resolve, millis));
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return {
|
|
1092
|
+
state,
|
|
1093
|
+
controlToken,
|
|
1094
|
+
fetch: async (request) => {
|
|
1095
|
+
try {
|
|
1096
|
+
const response = await dispatch(request);
|
|
1097
|
+
log(`${request.method} ${new URL(request.url).pathname} -> ${response.status}`);
|
|
1098
|
+
return response;
|
|
1099
|
+
} catch {
|
|
1100
|
+
return refuse(ClientProofRefusal.unprocessable());
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
function listItems(request) {
|
|
1106
|
+
if (request.limit < 1n || request.limit > DEV_MAX_LIMIT) {
|
|
1107
|
+
return null;
|
|
1108
|
+
}
|
|
1109
|
+
let start = 0;
|
|
1110
|
+
if (request.cursor !== void 0) {
|
|
1111
|
+
const index = DEV_CATALOGUE.findIndex((item) => item.id === request.cursor);
|
|
1112
|
+
if (index < 0) {
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
start = index + 1;
|
|
1116
|
+
}
|
|
1117
|
+
const end = Math.min(DEV_CATALOGUE.length, start + Number(request.limit));
|
|
1118
|
+
const page = DEV_CATALOGUE.slice(start, end);
|
|
1119
|
+
const nextCursor = end < DEV_CATALOGUE.length && page.length > 0 ? page[page.length - 1].id : null;
|
|
1120
|
+
return encodeListItemsResponse([...page], nextCursor);
|
|
1121
|
+
}
|
|
1122
|
+
function contractResponse(status, body) {
|
|
1123
|
+
return new Response(toArrayBuffer(body), {
|
|
1124
|
+
status,
|
|
1125
|
+
headers: { "content-type": "application/json" }
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
async function readBodyCapped(request) {
|
|
1129
|
+
if (request.body === null) {
|
|
1130
|
+
return new Uint8Array(0);
|
|
1131
|
+
}
|
|
1132
|
+
const reader = request.body.getReader();
|
|
1133
|
+
const chunks = [];
|
|
1134
|
+
let total = 0;
|
|
1135
|
+
for (; ; ) {
|
|
1136
|
+
const { done, value } = await reader.read();
|
|
1137
|
+
if (done) {
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
total += value.length;
|
|
1141
|
+
if (total > MAX_BODY_BYTES) {
|
|
1142
|
+
await reader.cancel();
|
|
1143
|
+
return null;
|
|
1144
|
+
}
|
|
1145
|
+
chunks.push(value);
|
|
1146
|
+
}
|
|
1147
|
+
const body = new Uint8Array(total);
|
|
1148
|
+
let offset = 0;
|
|
1149
|
+
for (const chunk of chunks) {
|
|
1150
|
+
body.set(chunk, offset);
|
|
1151
|
+
offset += chunk.length;
|
|
1152
|
+
}
|
|
1153
|
+
return body;
|
|
1154
|
+
}
|
|
1155
|
+
function toArrayBuffer(bytes) {
|
|
1156
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// src/server/client-proof/guard.ts
|
|
1160
|
+
function createClientProofGuard(state, options = {}) {
|
|
1161
|
+
return async (c, next) => {
|
|
1162
|
+
const body = new Uint8Array(await c.req.arrayBuffer());
|
|
1163
|
+
const admission = admitClientProofRequest({
|
|
1164
|
+
state,
|
|
1165
|
+
headers: c.req.raw.headers,
|
|
1166
|
+
method: c.req.method,
|
|
1167
|
+
path: options.contractPath ?? c.req.path,
|
|
1168
|
+
requiresSession: true,
|
|
1169
|
+
body
|
|
1170
|
+
});
|
|
1171
|
+
if (!admission.admitted) {
|
|
1172
|
+
state.recordRefusal();
|
|
1173
|
+
const bytes = admission.refusal.envelopeBytes(newHexId());
|
|
1174
|
+
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
1175
|
+
return c.newResponse(buffer, admission.refusal.httpStatus, {
|
|
1176
|
+
"content-type": "application/json"
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
c.set("clientType", "mobile");
|
|
1180
|
+
c.set("clientProof", {
|
|
1181
|
+
credentials: admission.credentials,
|
|
1182
|
+
value: admission.value
|
|
1183
|
+
});
|
|
1184
|
+
await next();
|
|
1185
|
+
return void 0;
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
export {
|
|
1189
|
+
ABSENT_BODY_SHA256,
|
|
1190
|
+
CLIENT_PROOF_CONTENT_TYPE,
|
|
1191
|
+
CLIENT_PROOF_HEADERS,
|
|
1192
|
+
CLIENT_PROOF_PROFILE,
|
|
1193
|
+
CONTRACT_OPERATIONS,
|
|
1194
|
+
CONTROL_PREFIX,
|
|
1195
|
+
CONTROL_TOKEN_HEADER,
|
|
1196
|
+
CanonicalJsonError,
|
|
1197
|
+
ClientProofRefusal,
|
|
1198
|
+
ClientProofState,
|
|
1199
|
+
ContractTypeError,
|
|
1200
|
+
DEFAULT_REPLAY_WINDOW_MILLIS,
|
|
1201
|
+
DEFAULT_SESSION_TTL_MILLIS,
|
|
1202
|
+
DEV_CATALOGUE,
|
|
1203
|
+
DEV_MAX_LIMIT,
|
|
1204
|
+
ProofInputError,
|
|
1205
|
+
TestClock,
|
|
1206
|
+
admitClientProofRequest,
|
|
1207
|
+
canonicalProofInput,
|
|
1208
|
+
computeClientProof,
|
|
1209
|
+
constantTimeEqualsProof,
|
|
1210
|
+
createClientProofDevHandler,
|
|
1211
|
+
createClientProofGuard,
|
|
1212
|
+
decodeEchoRequest,
|
|
1213
|
+
decodeHandshakeRequest,
|
|
1214
|
+
decodeListItemsRequest,
|
|
1215
|
+
encodeCanonicalJson,
|
|
1216
|
+
encodeEchoResponse,
|
|
1217
|
+
encodeHandshakeResponse,
|
|
1218
|
+
encodeListItemsResponse,
|
|
1219
|
+
isCanonicalBytes,
|
|
1220
|
+
newHexId,
|
|
1221
|
+
parseCanonicalJson,
|
|
1222
|
+
sha256Hex,
|
|
1223
|
+
systemClock
|
|
1224
|
+
};
|
|
1225
|
+
//# sourceMappingURL=client-proof.js.map
|