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
| 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 | |
| 11 | type Json = any; |
| 12 | type Dict = Record<string, Json>; |
| 13 | |
| 14 | export type Verdict = { ok: boolean; error?: string; claims?: Dict; jti?: string }; |
| 15 | export type Parsed = Dict & { verb: string }; |
| 16 | export type ContractInput = { policy: Dict; schemas: Record<string, Json>; grammar: Dict }; |
| 17 | |
| 18 | export class ProtocolError extends Error { |
| 19 | code: string; |
| 20 | constructor(code: string) { |
| 21 | super(code); |
| 22 | this.code = code; |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | class Reject extends Error { |
| 27 | code: string; |
| 28 | constructor(code: string) { |
| 29 | super(code); |
| 30 | this.code = code; |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | const 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. */ |
| 37 | const 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). */ |
| 42 | export 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 | |
| 132 | function 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 | |
| 139 | function 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. */ |
| 144 | export 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 | |
| 169 | function 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 | |
| 182 | function 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 | |
| 190 | function 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 | |
| 196 | async 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 | |
| 200 | async 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 | |
| 205 | function 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 | |
| 212 | async 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 | |
| 224 | const 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). */ |
| 227 | export 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 | |
| 251 | export 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 | |
| 772 | export type Contracts = ReturnType<typeof createContracts>; |
| 773 | export { Reject, b64uDecode, b64uEncode }; |