ALL

Solo para leer y verificar. No puedes copiar, usar, modificar ni redistribuir este código. Aviso completo en la página principal.

contracts/ts/verify.ts

SHA-256: 0ec03a86377cfa815a7bcf5c7faaddb06c80b3b35001447f96efae1249a1374a
773 líneas · 39127 bytes · Ver en GitHub

1// ALL 2.0 contracts, TypeScript reference verifier (the contract).
2//
3// The same checks as contracts/e2e_contracts.py and tunnel/contracts_verify_test.go, written independently: all three must give the same verdict
4// AND the same error word on every golden vector of contracts/vectors/ (the Worker contract test runs this one). Spec and
5// fixtures only: the control plane (WP02, WP03, WP04) writes its own handlers and tests them with these functions.
6//
7// It runs in Node (the test) and in a Worker: WebCrypto only (crypto.subtle), no node: imports, and it receives the policy, the schemas and
8// the wire grammar as data (createContracts), so nothing here is typed twice. Erasable syntax only (no enums, no parameter properties), so
9// Node runs the file as it is.
10
11type Json = any;
12type Dict = Record<string, Json>;
13
14export type Verdict = { ok: boolean; error?: string; claims?: Dict; jti?: string };
15export type Parsed = Dict & { verb: string };
16export type ContractInput = { policy: Dict; schemas: Record<string, Json>; grammar: Dict };
17
18export class ProtocolError extends Error {
19 code: string;
20 constructor(code: string) {
21 super(code);
22 this.code = code;
23 }
24}
25
26class Reject extends Error {
27 code: string;
28 constructor(code: string) {
29 super(code);
30 this.code = code;
31 }
32}
33
34const encoder = new TextEncoder();
35
36/** Own keys only: `in` also finds what Object.prototype carries (constructor, toString, __proto__...), and the other verifiers have no such keys. */
37const has = (value: object, key: string): boolean => Object.prototype.hasOwnProperty.call(value, key);
38
39// ---------------------------------------------------------------------------------------------------------------------------- JSON
40
41/** One JSON value: a repeated object key, a stray character after the value or an invalid escape is an error (JSON.parse accepts a repeated key). */
42export function parseJson(text: string): Json {
43 let i = 0;
44 const fail = (): never => {
45 throw new Error("json");
46 };
47 const ws = () => {
48 while (i < text.length && " \t\n\r".includes(text[i])) i++;
49 };
50 const string = (): string => {
51 i++; // opening quote
52 let out = "";
53 for (;;) {
54 if (i >= text.length) fail();
55 const ch = text[i++];
56 if (ch === '"') return out;
57 if (ch.charCodeAt(0) < 0x20) fail();
58 if (ch !== "\\") {
59 out += ch;
60 continue;
61 }
62 const esc = text[i++];
63 const simple: Dict = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
64 if (esc in simple) out += simple[esc];
65 else if (esc === "u") {
66 const hex = text.slice(i, i + 4);
67 if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail();
68 out += String.fromCharCode(parseInt(hex, 16));
69 i += 4;
70 } else fail();
71 }
72 };
73 const value = (): Json => {
74 ws();
75 const ch = text[i];
76 if (ch === "{") {
77 i++;
78 const out: Dict = {};
79 ws();
80 if (text[i] === "}") {
81 i++;
82 return out;
83 }
84 for (;;) {
85 ws();
86 if (text[i] !== '"') fail();
87 const key = string();
88 if (Object.prototype.hasOwnProperty.call(out, key)) fail();
89 ws();
90 if (text[i++] !== ":") fail();
91 Object.defineProperty(out, key, { value: value(), enumerable: true, writable: true, configurable: true });
92 ws();
93 const next = text[i++];
94 if (next === "}") return out;
95 if (next !== ",") fail();
96 }
97 }
98 if (ch === "[") {
99 i++;
100 const out: Json[] = [];
101 ws();
102 if (text[i] === "]") {
103 i++;
104 return out;
105 }
106 for (;;) {
107 out.push(value());
108 ws();
109 const next = text[i++];
110 if (next === "]") return out;
111 if (next !== ",") fail();
112 }
113 }
114 if (ch === '"') return string();
115 for (const [word, literal] of [["true", true], ["false", false], ["null", null]] as const) {
116 if (text.startsWith(word, i)) {
117 i += word.length;
118 return literal;
119 }
120 }
121 const number = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(text.slice(i));
122 if (!number) return fail();
123 i += number[0].length;
124 return Number(number[0]);
125 };
126 const result = value();
127 ws();
128 if (i !== text.length) fail();
129 return result;
130}
131
132function noFloats(value: Json): boolean {
133 if (typeof value === "number") return Number.isInteger(value);
134 if (Array.isArray(value)) return value.every(noFloats);
135 if (value !== null && typeof value === "object") return Object.values(value).every(noFloats);
136 return true;
137}
138
139function isObject(value: Json): value is Dict {
140 return value !== null && typeof value === "object" && !Array.isArray(value);
141}
142
143/** The form of a JSON body on a wire line: keys sorted, no whitespace, ASCII only. */
144export function canonicalJson(value: Json): string {
145 if (value === null) return "null";
146 if (typeof value === "boolean") return value ? "true" : "false";
147 if (typeof value === "number") return String(value);
148 if (typeof value === "string") {
149 let out = '"';
150 for (let k = 0; k < value.length; k++) {
151 const code = value.charCodeAt(k);
152 const ch = value[k];
153 if (ch === '"') out += '\\"';
154 else if (ch === "\\") out += "\\\\";
155 else if (ch === "\n") out += "\\n";
156 else if (ch === "\r") out += "\\r";
157 else if (ch === "\t") out += "\\t";
158 else if (ch === "\b") out += "\\b";
159 else if (ch === "\f") out += "\\f";
160 else if (code < 0x20 || code > 0x7e) out += "\\u" + code.toString(16).padStart(4, "0");
161 else out += ch;
162 }
163 return out + '"';
164 }
165 if (Array.isArray(value)) return "[" + value.map(canonicalJson).join(",") + "]";
166 return "{" + Object.keys(value).sort().map((key) => canonicalJson(key) + ":" + canonicalJson(value[key])).join(",") + "}";
167}
168
169function equal(a: Json, b: Json): boolean {
170 if (a === null || b === null) return a === b;
171 if (typeof a !== typeof b) return false;
172 if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((item, k) => equal(item, b[k]));
173 if (typeof a === "object") {
174 const keys = Object.keys(a);
175 return !Array.isArray(b) && keys.length === Object.keys(b).length && keys.every((key) => key in b && equal(a[key], b[key]));
176 }
177 return a === b;
178}
179
180// ---------------------------------------------------------------------------------------------------------------------------- bytes, hashes
181
182function b64uDecode(text: string): Uint8Array {
183 if (typeof text !== "string" || !/^[A-Za-z0-9_-]+$/.test(text)) throw new Error("base64url");
184 const raw = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (text.length % 4)) % 4));
185 const bytes = Uint8Array.from(raw, (c) => c.charCodeAt(0));
186 if (b64uEncode(bytes) !== text) throw new Error("base64url");
187 return bytes;
188}
189
190function b64uEncode(bytes: Uint8Array): string {
191 let binary = "";
192 for (const byte of bytes) binary += String.fromCharCode(byte);
193 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
194}
195
196async function sha256B64(data: Uint8Array | string): Promise<string> {
197 return b64uEncode(new Uint8Array(await crypto.subtle.digest("SHA-256", typeof data === "string" ? encoder.encode(data) : data)));
198}
199
200async function hmacB64(key: Uint8Array, message: string): Promise<string> {
201 const imported = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
202 return b64uEncode(new Uint8Array(await crypto.subtle.sign("HMAC", imported, encoder.encode(message))));
203}
204
205function sameText(a: string, b: string): boolean {
206 if (a.length !== b.length) return false;
207 let diff = 0;
208 for (let k = 0; k < a.length; k++) diff |= a.charCodeAt(k) ^ b.charCodeAt(k);
209 return diff === 0;
210}
211
212async function ed25519Ok(x: string, signature: Uint8Array, input: string): Promise<boolean> {
213 if (signature.length !== 64) return false;
214 try {
215 const key = await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x }, "Ed25519", false, ["verify"]);
216 return await crypto.subtle.verify("Ed25519", key, signature, encoder.encode(input));
217 } catch {
218 return false;
219 }
220}
221
222// ---------------------------------------------------------------------------------------------------------------------------- addresses
223
224const V4 = /^(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}$/;
225
226/** `format: ip`: a textual IPv4 or IPv6 address, lower case, no zone, no brackets, no spaces (RFC 4291 text forms, dotted-quad tail allowed). */
227export function validIp(value: Json): boolean {
228 if (typeof value !== "string" || value === "" || value !== value.toLowerCase() || /[%[\] \t\r\n/]/.test(value)) return false;
229 if (!value.includes(":")) return V4.test(value);
230 const halves = value.split("::");
231 if (halves.length > 2) return false;
232 const groups = (part: string): string[] => (part === "" ? [] : part.split(":"));
233 const all = halves.length === 2 ? [...groups(halves[0]), ...groups(halves[1])] : groups(halves[0]);
234 let count = 0;
235 for (let k = 0; k < all.length; k++) {
236 const group = all[k];
237 // a dotted-quad tail must end the whole address: "1.2.3.4::" is not an address
238 if (k === all.length - 1 && group.includes(".") && (halves.length === 1 || halves[1] !== "")) {
239 if (!V4.test(group)) return false;
240 count += 2;
241 } else {
242 if (!/^[0-9a-f]{1,4}$/.test(group)) return false;
243 count += 1;
244 }
245 }
246 return halves.length === 2 ? count <= 7 : count === 8;
247}
248
249// ---------------------------------------------------------------------------------------------------------------------------- the contracts
250
251export function createContracts(input: ContractInput) {
252 const { policy, schemas, grammar } = input;
253 const pol = (dotted: string): Json => dotted.split(".").reduce((node: Json, part) => node[part], policy);
254 const anchored = (pattern: string): RegExp => {
255 if (!pattern.startsWith("^") || !pattern.endsWith("$")) throw new Error("contract patterns are anchored: " + pattern);
256 return new RegExp(pattern);
257 };
258 const labelRe = anchored(pol("identity.label_regex"));
259 const fqdnRe = anchored(pol("identity.fqdn_regex"));
260 const nonceRe = anchored(pol("identity.nonce_regex"));
261 const kidRe = anchored(pol("identity.kid_regex"));
262 const patterns = new Map<string, RegExp>();
263 const pattern = (text: string): RegExp => {
264 if (!patterns.has(text)) patterns.set(text, anchored(text));
265 return patterns.get(text) as RegExp;
266 };
267
268 const labelOk = (value: Json): boolean => typeof value === "string" && labelRe.test(value);
269 const fqdnOk = (value: Json, zones?: string[]): boolean => {
270 if (typeof value !== "string" || value.length > pol("identity.fqdn_max_length") || !fqdnRe.test(value)) return false;
271 return zones === undefined || zones.includes(value.slice(value.indexOf(".") + 1));
272 };
273 /** The detector a node or a device builds for its own address in a zone: the policy's label pattern, one dot, the zone. */
274 const hostPattern = (zone: string): RegExp => {
275 const label: string = pol("identity.label_regex");
276 return new RegExp("^" + label.slice(1, -1) + "\\." + zone.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "$");
277 };
278
279 // ---- JSON Schema subset (the keywords the schemas use; the same list in the Python and Go validators)
280 const typeOk = (kind: string, value: Json): boolean => {
281 switch (kind) {
282 case "string": return typeof value === "string";
283 case "boolean": return typeof value === "boolean";
284 case "null": return value === null;
285 case "object": return isObject(value);
286 case "array": return Array.isArray(value);
287 case "integer": return typeof value === "number" && Number.isInteger(value);
288 case "number": return typeof value === "number";
289 default: return false;
290 }
291 };
292 const resolve = (doc: string, ref: string): [string, Json] => {
293 const [file, pointer] = ref.split("#");
294 const target = file || doc;
295 let node: Json = schemas[target];
296 if (pointer) for (const part of pointer.replace(/^\//, "").split("/")) node = node[part.replace(/~1/g, "/").replace(/~0/g, "~")];
297 return [target, node];
298 };
299 const check = (doc: string, schema: Dict, value: Json, path: string, errors: string[]): void => {
300 const at = path || "/";
301 if ("$ref" in schema) {
302 const [target, node] = resolve(doc, schema.$ref);
303 check(target, node, value, path, errors);
304 }
305 if ("const" in schema && !equal(value, schema.const)) errors.push(`${at}: const`);
306 if ("enum" in schema && !schema.enum.some((item: Json) => equal(value, item))) errors.push(`${at}: enum`);
307 if ("type" in schema) {
308 const kinds: string[] = Array.isArray(schema.type) ? schema.type : [schema.type];
309 if (!kinds.some((kind) => typeOk(kind, value))) {
310 errors.push(`${at}: type`);
311 return;
312 }
313 }
314 if ("oneOf" in schema) {
315 let hits = 0;
316 for (const option of schema.oneOf) {
317 const trial: string[] = [];
318 check(doc, option, value, path, trial);
319 if (trial.length === 0) hits++;
320 }
321 if (hits !== 1) errors.push(`${at}: oneOf`);
322 }
323 if (typeof value === "string") {
324 const length = [...value].length;
325 if ("minLength" in schema && length < schema.minLength) errors.push(`${at}: minLength`);
326 if ("maxLength" in schema && length > schema.maxLength) errors.push(`${at}: maxLength`);
327 if ("pattern" in schema && !pattern(schema.pattern).test(value)) errors.push(`${at}: pattern`);
328 if (schema.format === "ip" && !validIp(value)) errors.push(`${at}: format`);
329 }
330 if (typeof value === "number") {
331 if ("minimum" in schema && value < schema.minimum) errors.push(`${at}: minimum`);
332 if ("maximum" in schema && value > schema.maximum) errors.push(`${at}: maximum`);
333 }
334 if (isObject(value)) {
335 for (const key of schema.required ?? []) if (!has(value, key)) errors.push(`${path}/${key}: required`);
336 const props: Dict = schema.properties ?? {};
337 for (const key of Object.keys(value).sort()) {
338 if (has(props, key)) check(doc, props[key], value[key], `${path}/${key}`, errors);
339 else if (schema.additionalProperties === false) errors.push(`${path}/${key}: additionalProperties`);
340 }
341 }
342 if (Array.isArray(value)) {
343 if ("minItems" in schema && value.length < schema.minItems) errors.push(`${at}: minItems`);
344 if ("maxItems" in schema && value.length > schema.maxItems) errors.push(`${at}: maxItems`);
345 if (schema.uniqueItems && value.some((a, i) => value.some((b, j) => j > i && equal(a, b)))) errors.push(`${at}: uniqueItems`);
346 if ("items" in schema) value.forEach((item, k) => check(doc, schema.items, item, `${path}/${k}`, errors));
347 }
348 };
349 const validate = (name: string, instance: Json): string[] => {
350 const errors: string[] = [];
351 check(name, schemas[name], instance, "", errors);
352 return errors;
353 };
354
355 // ---- the wire grammar
356 const fieldOk = (kind: string, token: string): boolean => {
357 const spec: Dict = grammar.types[kind];
358 if (spec.max_bytes_from && token.length > pol(spec.max_bytes_from)) return false;
359 if (spec.pattern && !pattern(spec.pattern).test(token)) return false;
360 if (spec.format === "ip" && !validIp(token)) return false;
361 if (spec.enum_from && !pol(spec.enum_from).includes(token)) return false;
362 return true;
363 };
364
365 /** Parse one protocol line (the text before the LF) travelling in `direction` ("d2n" or "n2d"). Throws ProtocolError. */
366 const parseLine = (line: string, direction: string): Parsed => {
367 if (typeof line !== "string" || [...line].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) > 0x7e)) throw new ProtocolError("bad_command");
368 const cut = line.indexOf(" ");
369 const verb = cut < 0 ? line : line.slice(0, cut);
370 const hasSpace = cut >= 0;
371 const rest = hasSpace ? line.slice(cut + 1) : "";
372 const spec: Dict | undefined = Object.prototype.hasOwnProperty.call(grammar.verbs, verb) ? grammar.verbs[verb] : undefined;
373 const limit: number = spec && spec.max ? pol(spec.max) : pol("protocol.line_max_bytes");
374 if (line.length + 1 > limit) throw new ProtocolError("line_too_long");
375 if (!spec || (spec.dir !== direction && spec.dir !== "both") || line === "" || (hasSpace && rest === "")) throw new ProtocolError("bad_command");
376 const out: Parsed = { verb };
377 if (spec.json) {
378 if (!hasSpace) throw new ProtocolError("bad_command");
379 let body: Json;
380 try {
381 body = parseJson(rest);
382 } catch {
383 throw new ProtocolError("bad_command");
384 }
385 if (!noFloats(body) || validate(spec.json, body).length > 0) throw new ProtocolError("bad_command");
386 out.body = body;
387 return out;
388 }
389 const tokens = hasSpace ? rest.split(" ") : [];
390 if (tokens.some((token) => token === "")) throw new ProtocolError("bad_command");
391 if (spec.repeat) {
392 const rule = spec.repeat;
393 if (tokens.length < rule.min || tokens.length > pol(rule.max_from) || !tokens.every((token) => fieldOk(rule.type, token))) throw new ProtocolError("bad_command");
394 out.addresses = tokens;
395 return out;
396 }
397 const fields: Dict[] = spec.fields ?? [];
398 const optional: Dict[] = spec.optional ?? [];
399 if (tokens.length < fields.length || tokens.length > fields.length + optional.length) throw new ProtocolError("bad_command");
400 const rules = [...fields, ...optional];
401 tokens.forEach((token, k) => {
402 const rule = rules[k];
403 if ("equals" in rule && token !== rule.equals) throw new ProtocolError(fieldOk(rule.type, token) ? rule.error_if_number : "bad_command");
404 if (!fieldOk(rule.type, token)) throw new ProtocolError(rule.error ?? "bad_command");
405 out[rule.name] = token;
406 });
407 return out;
408 };
409
410 /** The canonical line of a parsed message (the inverse of parseLine, which it is checked against). */
411 const formatLine = (message: Parsed): string => {
412 const spec: Dict = grammar.verbs[message.verb];
413 let line: string;
414 if (spec.json) line = `${message.verb} ${canonicalJson(message.body)}`;
415 else if (spec.repeat) line = [message.verb, ...message.addresses].join(" ");
416 else {
417 const rules: Dict[] = [...(spec.fields ?? []), ...(spec.optional ?? [])];
418 line = [message.verb, ...rules.filter((rule) => has(message, rule.name)).map((rule) => message[rule.name])].join(" ");
419 }
420 parseLine(line, spec.dir === "both" ? "d2n" : spec.dir);
421 return line;
422 };
423
424 // ---- tokens
425 const fail = (error: string): Verdict => ({ ok: false, error });
426
427 const jwsParts = (token: Json, maxBytes: number) => {
428 try {
429 if (typeof token !== "string" || [...token].some((ch) => ch.charCodeAt(0) > 0x7f) || token.length > maxBytes) throw new Error();
430 const parts = token.split(".");
431 if (parts.length !== 3) throw new Error();
432 // ignoreBOM keeps a leading U+FEFF in the text, so parseJson refuses it (Python and Go refuse it too; the default decoder drops it)
433 const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
434 const header = parseJson(decoder.decode(b64uDecode(parts[0])));
435 const claims = parseJson(decoder.decode(b64uDecode(parts[1])));
436 const signature = parts[2] === "" ? new Uint8Array(0) : b64uDecode(parts[2]);
437 if (!isObject(header) || !isObject(claims)) throw new Error();
438 if (["crit", "b64", "jku", "x5u", "x5c"].some((name) => has(header, name))) throw new Error();
439 if (!noFloats(claims)) throw new Error();
440 return { header, claims, signature, input: `${parts[0]}.${parts[1]}` };
441 } catch {
442 throw new Reject("malformed_token");
443 }
444 };
445
446 const jwkX = (value: Json): string => {
447 if (!isObject(value) || value.kty !== "OKP" || value.crv !== "Ed25519" || has(value, "d") || typeof value.x !== "string" || value.x.length !== 43) {
448 throw new Reject("malformed_token");
449 }
450 try {
451 if (b64uDecode(value.x).length !== 32) throw new Error();
452 } catch {
453 throw new Reject("malformed_token");
454 }
455 return value.x;
456 };
457
458 const claimsOk = (claims: Dict, schema: string, ...ignore: string[]): boolean =>
459 validate(schema, claims).every((error) => ignore.some((name) => error.startsWith(`/${name}:`)));
460
461 const parseJwks = (doc: Json): Map<string, string> => {
462 if (validate("jwks.schema.json", doc).length > 0) throw new Reject("bad_jwks");
463 const keys = new Map<string, string>();
464 for (const key of doc.keys) {
465 if (keys.has(key.kid)) throw new Reject("bad_jwks");
466 keys.set(key.kid, key.x);
467 }
468 return keys;
469 };
470
471 const parseNodeKeys = (doc: Json): Map<string, { node: string; x: string }> => {
472 if (validate("node-keys.schema.json", doc).length > 0) throw new Reject("bad_node_keys");
473 const keys = new Map<string, { node: string; x: string }>();
474 for (const key of doc.keys) {
475 if (keys.has(key.kid)) throw new Reject("bad_node_keys");
476 keys.set(key.kid, { node: key.node, x: key.x });
477 }
478 return keys;
479 };
480
481 const signedByJwks = async (token: Json, keys: Map<string, string>, typ: string) => {
482 const jws = jwsParts(token, pol("protocol.statement_line_max_bytes"));
483 if (jws.header.alg !== "EdDSA") throw new Reject("bad_alg");
484 const kid = jws.header.kid;
485 if (typeof kid !== "string" || !kidRe.test(kid) || !keys.has(kid)) throw new Reject("unknown_kid");
486 if (!(await ed25519Ok(keys.get(kid) as string, jws.signature, jws.input))) throw new Reject("bad_signature");
487 if (jws.header.typ !== typ || jws.claims.typ !== typ) throw new Reject("wrong_type");
488 return jws;
489 };
490
491 const run = async (body: () => Promise<Verdict>): Promise<Verdict> => {
492 try {
493 return await body();
494 } catch (error) {
495 if (error instanceof Reject) return fail(error.code);
496 throw error;
497 }
498 };
499
500 /** A device session as the node accepts it at HELLO and RENEW. Order of failure: malformed_token, bad_alg, unknown_kid, bad_signature, wrong_type,
501 * wrong_issuer, wrong_audience, bad_claims, bad_lifetime, not_yet_valid, expired. */
502 const verifySession = (token: Json, o: { now: number; audience: string; keys: Map<string, string>; skew: number }): Promise<Verdict> => run(async () => {
503 if (typeof token !== "string" || token.length > pol("protocol.jwt_max_bytes")) throw new Reject("malformed_token");
504 const { claims } = await signedByJwks(token, o.keys, "device-session");
505 if (claims.ver !== 2) throw new Reject("wrong_type");
506 if (claims.iss !== pol("identity.issuer")) throw new Reject("wrong_issuer");
507 if (claims.aud !== o.audience) throw new Reject("wrong_audience");
508 if (!claimsOk(claims, "session-claims.schema.json", "aud")) throw new Reject("bad_claims");
509 if (claims.exp - claims.iat !== pol("authorization.session_ttl_s")) throw new Reject("bad_lifetime");
510 if (claims.iat > o.now + o.skew) throw new Reject("not_yet_valid");
511 if (claims.exp <= o.now) throw new Reject("expired");
512 return { ok: true, claims, jti: claims.jti };
513 });
514
515 /** The DPoP proof of PROOF. Order of failure: malformed_token, bad_alg, wrong_type, wrong_key, bad_signature, wrong_subject, wrong_audience,
516 * wrong_htm, wrong_htu, wrong_ath, wrong_nonce, bad_claims, not_yet_valid, stale_proof, replayed_jti. */
517 const verifyProof = (proof: Json, o: { now: number; sessionToken: string; sessionClaims: Dict; nonce: string; htu: string; skew: number; seen: Set<string> }): Promise<Verdict> => run(async () => {
518 if (typeof proof !== "string" || proof.length > pol("protocol.jwt_max_bytes")) throw new Reject("malformed_token");
519 const jws = jwsParts(proof, pol("protocol.jwt_max_bytes"));
520 if (jws.header.alg !== "EdDSA") throw new Reject("bad_alg");
521 if (jws.header.typ !== "dpop+jwt") throw new Reject("wrong_type");
522 const x = jwkX(jws.header.jwk);
523 if (x !== o.sessionClaims.cnf.jwk.x) throw new Reject("wrong_key");
524 if (!(await ed25519Ok(x, jws.signature, jws.input))) throw new Reject("bad_signature");
525 const claims = jws.claims;
526 if (claims.sub !== o.sessionClaims.sub) throw new Reject("wrong_subject");
527 if (claims.aud !== pol("identity.aud_tunnel")) throw new Reject("wrong_audience");
528 if (claims.htm !== pol("protocol.proof_htm")) throw new Reject("wrong_htm");
529 if (claims.htu !== o.htu) throw new Reject("wrong_htu");
530 if (claims.ath !== (await sha256B64(o.sessionToken))) throw new Reject("wrong_ath");
531 if (claims.nonce !== o.nonce) throw new Reject("wrong_nonce");
532 if (!claimsOk(claims, "proof-claims.schema.json", "htu")) throw new Reject("bad_claims");
533 if (claims.iat > o.now + o.skew) throw new Reject("not_yet_valid");
534 if (claims.iat + pol("authorization.proof_ttl_s") <= o.now) throw new Reject("stale_proof");
535 if (o.seen.has(claims.jti)) throw new Reject("replayed_jti");
536 return { ok: true, claims, jti: claims.jti };
537 });
538
539 /** A node's request authentication (header x-all-node-assertion). Order of failure: malformed_token, bad_alg, unknown_kid, bad_signature,
540 * wrong_type, wrong_audience, wrong_subject, wrong_htm, wrong_htu, bad_claims, bad_lifetime, not_yet_valid, expired, wrong_body_hash, replayed_jti. */
541 const verifyNodeAssertion = (token: Json, o: { now: number; keys: Map<string, { node: string; x: string }>; method: string; url: string; body: Uint8Array; skew: number; seen: Set<string> }): Promise<Verdict> => run(async () => {
542 const jws = jwsParts(token, pol("protocol.jwt_max_bytes"));
543 if (jws.header.alg !== "EdDSA") throw new Reject("bad_alg");
544 const kid = jws.header.kid;
545 if (typeof kid !== "string" || !kidRe.test(kid) || !o.keys.has(kid)) throw new Reject("unknown_kid");
546 const key = o.keys.get(kid) as { node: string; x: string };
547 if (!(await ed25519Ok(key.x, jws.signature, jws.input))) throw new Reject("bad_signature");
548 const claims = jws.claims;
549 if (jws.header.typ !== "node-assertion" || claims.typ !== "node-assertion" || claims.ver !== 1) throw new Reject("wrong_type");
550 if (claims.aud !== pol("identity.aud_node_assertion")) throw new Reject("wrong_audience");
551 if (claims.sub !== key.node) throw new Reject("wrong_subject");
552 if (claims.htm !== o.method) throw new Reject("wrong_htm");
553 if (claims.htu !== o.url) throw new Reject("wrong_htu");
554 if (!claimsOk(claims, "node-assertion-claims.schema.json", "htm", "htu", "aud", "sub")) throw new Reject("bad_claims");
555 if (claims.exp - claims.iat !== pol("authorization.node_assertion_ttl_s")) throw new Reject("bad_lifetime");
556 if (claims.iat > o.now + o.skew) throw new Reject("not_yet_valid");
557 if (claims.exp <= o.now) throw new Reject("expired");
558 if (claims.bh !== (await sha256B64(o.body))) throw new Reject("wrong_body_hash");
559 if (o.seen.has(claims.jti)) throw new Reject("replayed_jti");
560 return { ok: true, claims, jti: claims.jti };
561 });
562
563 /** The control plane's signed access statement as the device accepts it. Order of failure: malformed_token, bad_alg, unknown_kid, bad_signature,
564 * wrong_type, wrong_issuer, wrong_audience, wrong_subject, bad_claims, bad_lifetime, not_yet_valid, stale_seq. An expired statement still verifies. */
565 const verifyStatement = (token: Json, o: { now: number; keys: Map<string, string>; deviceId: string; lastSeq: number }): Promise<Verdict> => run(async () => {
566 const { claims } = await signedByJwks(token, o.keys, "device-access");
567 if (claims.ver !== 1) throw new Reject("wrong_type");
568 if (claims.iss !== pol("identity.issuer")) throw new Reject("wrong_issuer");
569 if (claims.aud !== pol("identity.aud_access")) throw new Reject("wrong_audience");
570 if (claims.sub !== o.deviceId) throw new Reject("wrong_subject");
571 if (!claimsOk(claims, "statement-claims.schema.json", "aud", "sub") || has(claims, "paywall") !== (claims.mode === "paywall") ||
572 (has(claims, "paused") && has(claims, "resumed"))) throw new Reject("bad_claims");
573 const ttl = claims.exp - claims.iat;
574 if (!(ttl > 0 && ttl <= pol(claims.mode === "paywall" ? "authorization.access_ttl_paywall_s" : "authorization.access_ttl_paid_s"))) throw new Reject("bad_lifetime");
575 if (claims.iat > o.now + pol("authorization.access_future_slack_s")) throw new Reject("not_yet_valid");
576 if (claims.seq < o.lastSeq) throw new Reject("stale_seq");
577 return { ok: true, claims, jti: claims.jti };
578 });
579
580 /** Schema plus the rules a schema cannot say: events strictly increasing and after `since`, epoch not behind the last event, kill and kill_clear
581 * carry no device, every other event carries both device and label. */
582 const checkFeed = (body: Json, since: number): string[] => {
583 const errors = validate("feed-response.schema.json", body);
584 if (errors.length > 0) return errors;
585 let previous = since;
586 body.events.forEach((event: Dict, k: number) => {
587 if (event.epoch <= previous) errors.push(`/events/${k}: order`);
588 previous = Math.max(previous, event.epoch);
589 const global = event.type === "kill" || event.type === "kill_clear";
590 if (global !== (event.device_id === null && event.label === null)) errors.push(`/events/${k}: scope`);
591 if ((event.device_id === null) !== (event.label === null)) errors.push(`/events/${k}: scope`);
592 });
593 if (body.epoch < previous) errors.push("/epoch: behind events");
594 return errors;
595 };
596
597 // ---- surface identity proof
598 const surfaceKey = (secret: string): Uint8Array => {
599 const raw = b64uDecode(secret);
600 if (raw.length !== 32) throw new Error("secret");
601 return raw;
602 };
603 const surfaceMac = (secret: string, nonce: string): Promise<string> => hmacB64(surfaceKey(secret), "all-surface-challenge-v1\n" + nonce);
604 const surfaceProof = (secret: string, nonce: string, pid: number, port: number): Promise<string> =>
605 hmacB64(surfaceKey(secret), `all-surface-proof-v1\n${nonce}\n${pid}\n${port}`);
606 const verifySurfaceChallenge = async (secret: string, nonce: string, mac: Json): Promise<Verdict> => {
607 try {
608 if (typeof nonce !== "string" || !nonceRe.test(nonce) || typeof mac !== "string") return fail("bad_challenge");
609 return sameText(await surfaceMac(secret, nonce), mac) ? { ok: true } : fail("bad_challenge");
610 } catch {
611 return fail("bad_challenge");
612 }
613 };
614 const verifySurfaceProof = async (secret: string, nonce: string, pid: number, port: number, proof: Json): Promise<Verdict> => {
615 try {
616 if (typeof nonce !== "string" || !nonceRe.test(nonce) || typeof proof !== "string") return fail("bad_proof");
617 return sameText(await surfaceProof(secret, nonce, pid, port), proof) ? { ok: true } : fail("bad_proof");
618 } catch {
619 return fail("bad_proof");
620 }
621 };
622 const identityRequest = (host: string, port: number, nonce: string, mac: string): string =>
623 `GET ${pol("surface.identity_path")} HTTP/1.1\r\nHost: ${host}:${port}\r\n${pol("surface.identity_challenge_header")}: ${nonce}\r\n` +
624 `${pol("surface.identity_mac_header")}: ${mac}\r\nConnection: keep-alive\r\n\r\n`;
625
626 // ---- node behaviour
627 /** Does the recorded behaviour of ONE node obey the contract? Returns "" when it does, otherwise the first violation word. */
628 const checkTranscript = async (doc: Dict): Promise<string> => {
629 const ctx = doc.ctx;
630 const keys = parseJwks(ctx.jwks);
631 const skew: number = ctx.skew;
632 type Conn = { phase: string; session: string; claims: Dict | null; sessionOk: boolean; kind: string; nonce: string; proof: string; authorized: boolean;
633 reply: Dict | null; host: string; status: string; validUntil: number; superseded: boolean; sub: string };
634 const conns = new Map<string, Conn>();
635 const seen = new Set<string>();
636 const streams = new Map<string, { secret: string; t: number; consumed: boolean }>();
637 const secretsUsed = new Set<string>();
638 const hellos = new Map<string, { ip: string; cls: string }[]>(); // per host: the peer_ip and class of each complete ClientHello still waiting for its OPEN
639 let ended = false;
640 let now = 0;
641
642 for (const event of doc.events as Dict[]) {
643 now = event.t;
644 const kind: string = event.ev;
645 let conn: Conn | undefined;
646 if (typeof event.conn === "string" && ["recv", "send", "cp", "cp_reply"].includes(kind)) {
647 if (!conns.has(event.conn)) {
648 conns.set(event.conn, { phase: "new", session: "", claims: null, sessionOk: false, kind: "hello", nonce: "", proof: "none", authorized: false,
649 reply: null, host: "", status: "", validUntil: 0, superseded: false, sub: "" });
650 }
651 conn = conns.get(event.conn);
652 }
653 if (kind === "client_hello") {
654 if (event.complete === true) hellos.set(event.sni, [...(hellos.get(event.sni) ?? []), { ip: event.peer_ip, cls: event.class }]);
655 } else if (kind === "recv" && conn) {
656 let message: Parsed;
657 try {
658 message = parseLine(event.line, "d2n");
659 } catch {
660 continue;
661 }
662 const verb = message.verb;
663 if ((verb === "HELLO" && conn.phase === "new") || (verb === "RENEW" && conn.phase === "ready")) {
664 const result = await verifySession(message.session, { now, audience: pol("identity.aud_tunnel"), keys, skew });
665 conn.sessionOk = result.ok && (verb === "HELLO" || (result.claims as Dict).sub === conn.sub);
666 conn.session = message.session;
667 conn.claims = result.ok ? (result.claims as Dict) : null;
668 conn.kind = verb === "HELLO" ? "hello" : "renew";
669 conn.nonce = "";
670 conn.proof = "none";
671 conn.authorized = false;
672 conn.reply = null;
673 if (verb === "HELLO") conn.phase = "hello";
674 } else if (verb === "PROOF" && conn.sessionOk && conn.nonce !== "") {
675 const result = await verifyProof(message.proof, { now, sessionToken: conn.session, sessionClaims: conn.claims as Dict, nonce: conn.nonce,
676 htu: ctx.node_url, skew, seen });
677 conn.proof = result.ok ? "ok" : result.error === "replayed_jti" ? "replayed" : "invalid";
678 conn.authorized = false;
679 conn.reply = null;
680 if (result.ok && result.jti !== undefined) seen.add(result.jti);
681 }
682 } else if (kind === "cp" && conn) {
683 const body = event.body;
684 if (validate("authorize-request.schema.json", body).length > 0 || conn.claims === null || body.device_id !== conn.claims.sub ||
685 body.session_jti !== conn.claims.jti || body.reason !== conn.kind || body.node !== ctx.node_id) return "bad_authorize_request";
686 if (conn.proof === "ok") conn.authorized = true;
687 } else if (kind === "cp_reply" && conn) {
688 conn.reply = conn.authorized ? event : null;
689 } else if (kind === "send" && conn) {
690 let message: Parsed;
691 try {
692 message = parseLine(event.line, "n2d");
693 } catch {
694 return "bad_line";
695 }
696 if (message.verb === "CHALLENGE") {
697 if (!conn.sessionOk || (conn.phase !== "hello" && conn.phase !== "ready")) return "challenge_for_invalid_session";
698 conn.nonce = message.nonce;
699 } else if (message.verb === "OK") {
700 if (event.line !== "OK " + canonicalJson(message.body)) return "bad_line";
701 if (conn.proof === "none") return "ok_without_proof";
702 if (conn.proof === "replayed") return "replayed_proof_accepted";
703 if (conn.proof !== "ok") return "ok_with_invalid_proof";
704 if (!conn.authorized || conn.reply === null || conn.reply.status !== 200) return conn.kind === "renew" ? "renew_without_authorize" : "ok_without_authorize";
705 const body = message.body;
706 const answer = conn.reply.body;
707 if (body.host !== answer.host) return "host_not_from_control_plane";
708 if (body.status !== answer.status || body.valid_until !== answer.valid_until || !equal(body.policy, answer.policy) || body.node !== ctx.node_id) {
709 return "ok_mismatch_control_plane";
710 }
711 conn.phase = "ready";
712 conn.host = body.host;
713 conn.status = body.status;
714 conn.validUntil = body.valid_until;
715 conn.sub = (conn.claims as Dict).sub;
716 conn.proof = "none";
717 conn.authorized = false;
718 conn.reply = null;
719 for (const other of conns.values()) if (other !== conn && other.phase === "ready" && other.sub === conn.sub) other.superseded = true;
720 } else if (message.verb === "OPEN") {
721 if (conn.phase !== "ready" || conn.status === "") return "open_before_ok";
722 if (conn.status === "paused") return "open_when_paused";
723 if (now >= conn.validUntil) return "open_after_valid_until";
724 const pending = hellos.get(conn.host) ?? [];
725 if (pending.length === 0) return [...hellos.values()].some((list) => list.length > 0) ? "open_wrong_host" : "open_without_client_hello";
726 const waiting = pending.findIndex((hello) => hello.ip === message.peer_ip && hello.cls === message.class);
727 if (waiting < 0) return "open_wrong_client";
728 if (streams.has(message.stream_id) || secretsUsed.has(message.secret)) return "duplicate_stream";
729 pending.splice(waiting, 1);
730 streams.set(message.stream_id, { secret: message.secret, t: now, consumed: false });
731 secretsUsed.add(message.secret);
732 } else if (message.verb === "CLOSE") {
733 conn.phase = "closed";
734 }
735 } else if (kind === "attach") {
736 let message: Parsed;
737 try {
738 message = parseLine(event.line, "d2n");
739 } catch {
740 continue;
741 }
742 const known = streams.get(message.stream_id);
743 const good = known !== undefined && sameText(known.secret, message.secret);
744 const fresh = good && !known.consumed && now - known.t <= pol("protocol.attach_timeout_s");
745 if (event.result === "ok") {
746 if (known === undefined) return "attach_unknown_accepted";
747 if (!good) return "attach_wrong_secret_accepted";
748 if (known.consumed) return "attach_secret_reused";
749 if (!fresh) return "attach_after_timeout";
750 known.consumed = true;
751 } else if (fresh) {
752 return "attach_refused_good_secret";
753 }
754 } else if (kind === "end") {
755 ended = true;
756 for (const other of conns.values()) {
757 if (other.phase === "ready" && other.superseded) return "replaced_not_closed";
758 if (other.phase === "ready" && now > other.validUntil) return "no_close_at_valid_until";
759 }
760 }
761 }
762 return ended ? "" : "transcript_without_end";
763 };
764
765 return {
766 pol, labelOk, fqdnOk, hostPattern, validate, parseLine, formatLine, verifySession, verifyProof, verifyNodeAssertion, verifyStatement, parseJwks,
767 parseNodeKeys, checkFeed, surfaceMac, surfaceProof, verifySurfaceChallenge, verifySurfaceProof, identityRequest, checkTranscript, canonicalJson,
768 labelRegexSource: labelRe.source,
769 };
770}
771
772export type Contracts = ReturnType<typeof createContracts>;
773export { Reject, b64uDecode, b64uEncode };