Spaces:
Running
Running
| /** | |
| * lcert-verify-web — browser-side re-derivation of LCERT-1 certificate verdicts. | |
| * | |
| * Why the port is faithful: | |
| * | |
| * - The interval arithmetic uses only IEEE-754 double +, -, *, comparison, | |
| * Math.max and nextafter. These are correctly rounded by the standard, and | |
| * JavaScript numbers *are* IEEE doubles — the same doubles CPython uses — so | |
| * the per-locus classification is BIT-IDENTICAL to the Python verifier. | |
| * | |
| * - erfc is the one exception. There is no erfc in the JS standard library, so | |
| * this file carries its own implementation, accurate to roughly 1e-15 | |
| * relative. It is NOT bit-identical to a platform libm. That is sound here | |
| * because erfc is used only for the kappa round-trip, whose acceptance | |
| * tolerance the format fixes at 1e-12 absolute — two orders of magnitude | |
| * above the approximation error. The conformance suite checks this against | |
| * the Python implementation rather than assuming it. | |
| * | |
| * No dependencies. No network. Nothing leaves the page. | |
| */ | |
| const FORMAT = "litholab-cert-bundle/1"; | |
| /* ---------- IEEE-754 nextafter, used for outward rounding ---------- */ | |
| const _buf = new ArrayBuffer(8); | |
| const _f64 = new Float64Array(_buf); | |
| const _u64 = new BigUint64Array(_buf); | |
| export function nextafter(x, y) { | |
| if (Number.isNaN(x) || Number.isNaN(y)) return NaN; | |
| if (x === y) return y; | |
| if (x === 0) return y > 0 ? Number.MIN_VALUE : -Number.MIN_VALUE; | |
| _f64[0] = x; | |
| let bits = _u64[0]; | |
| // Move one ULP toward y. | |
| if ((x < y) === (x > 0)) bits += 1n; | |
| else bits -= 1n; | |
| _u64[0] = bits; | |
| return _f64[0]; | |
| } | |
| export const down = (x) => nextafter(x, -Infinity); | |
| export const up = (x) => nextafter(x, Infinity); | |
| /* ---------- erfc, matching the C library to double precision ---------- */ | |
| /** | |
| * Complementary error function. | |
| * | |
| * Uses the standard Numerical Recipes incomplete-gamma continued fraction / | |
| * series split, which is accurate to ~1e-15 relative — sufficient for the | |
| * round-trip tolerance the format specifies (1e-12 absolute). | |
| */ | |
| export function erfc(x) { | |
| if (x < 0) return 2 - erfc(-x); | |
| if (x === 0) return 1; | |
| // Continued fraction (Lentz) for the upper incomplete gamma Q(1/2, x^2). | |
| const a = 0.5, xx = x * x; | |
| if (xx < a + 1) { | |
| // Series expansion for P(a, xx); erfc = 1 - P | |
| let ap = a, sum = 1 / a, del = sum; | |
| for (let n = 0; n < 500; n++) { | |
| ap += 1; | |
| del *= xx / ap; | |
| sum += del; | |
| if (Math.abs(del) < Math.abs(sum) * 1e-17) break; | |
| } | |
| const P = sum * Math.exp(-xx + a * Math.log(xx) - lgamma(a)); | |
| return 1 - P; | |
| } | |
| const FPMIN = 1e-300; | |
| let b = xx + 1 - a, c = 1 / FPMIN, d = 1 / b, h = d; | |
| for (let i = 1; i < 500; i++) { | |
| const an = -i * (i - a); | |
| b += 2; | |
| d = an * d + b; if (Math.abs(d) < FPMIN) d = FPMIN; | |
| c = b + an / c; if (Math.abs(c) < FPMIN) c = FPMIN; | |
| d = 1 / d; | |
| const del = d * c; | |
| h *= del; | |
| if (Math.abs(del - 1) < 1e-17) break; | |
| } | |
| return h * Math.exp(-xx + a * Math.log(xx) - lgamma(a)); | |
| } | |
| const _LG = [76.18009172947146, -86.50532032941677, 24.01409824083091, | |
| -1.231739572450155, 0.1208650973866179e-2, -0.5395239384953e-5]; | |
| function lgamma(z) { | |
| let x = z, y = z, tmp = x + 5.5; | |
| tmp -= (x + 0.5) * Math.log(tmp); | |
| let ser = 1.000000000190015; | |
| for (let j = 0; j < 6; j++) ser += _LG[j] / ++y; | |
| return -tmp + Math.log(2.5066282746310005 * ser / x); | |
| } | |
| /* ---------- canonical JSON, matching Python's separators=(',',':') ---------- */ | |
| /** | |
| * Python-compatible float formatting. | |
| * | |
| * JSON canonicalization only works across languages if the number formatting | |
| * agrees. It does not by default: Python renders an integral float as `100.0` | |
| * where JS renders `100`, and Python switches to exponent notation below 1e-4 | |
| * where JS waits until 1e-6. Both use shortest-round-trip digits, so once those | |
| * two rules are applied the outputs coincide. | |
| */ | |
| export function pyFloatRepr(x) { | |
| if (!Number.isFinite(x)) throw new Error("non-finite numbers have no canonical form"); | |
| if (Number.isInteger(x) && Math.abs(x) < 1e16) { | |
| return (Object.is(x, -0) ? "-0" : String(x)) + ".0"; | |
| } | |
| const exp = Math.floor(Math.log10(Math.abs(x))); | |
| let s; | |
| if (exp < -4 || exp >= 16) { | |
| s = x.toExponential(); // shortest round-trip mantissa | |
| // Python pads the exponent to at least two digits: 1e-5 -> 1e-05 | |
| s = s.replace(/e([+-])(\d)$/, "e$10$2"); | |
| } else { | |
| s = String(x); | |
| if (s.includes("e")) { // JS chose exponent, Python would not | |
| s = x.toFixed(20).replace(/0+$/, "").replace(/\.$/, ".0"); | |
| } | |
| } | |
| return s; | |
| } | |
| // JSON does not distinguish an integer from an integral float, so a canonical | |
| // re-serialization in JS cannot always reproduce the producer's bytes. Keys that | |
| // the format defines as integers are listed here; everything else numeric is | |
| // rendered as a float. See the note on `verifyBundle` for why this does not | |
| // weaken verification. | |
| const _INT_KEYS = new Set(["seed", "n_loci", "n_certainly_safe", | |
| "n_certainly_unsafe", "n_straddle"]); | |
| /** | |
| * Canonical JSON, matching Python's `separators=(",",":")` with sorted keys. | |
| * | |
| * CONTRACT: exact for objects whose numeric leaves are floats, plus integers at | |
| * the keys the format defines as integral (`_INT_KEYS`). A bare JSON integer at | |
| * any other key cannot be distinguished from an integral float in JavaScript and | |
| * will be rendered as a float. Certificate payloads satisfy this contract by | |
| * construction; do not use `canon` as a general-purpose Python-JSON bridge. | |
| */ | |
| export function canon(obj, key) { | |
| if (obj === null) return "null"; | |
| if (typeof obj === "boolean") return obj ? "true" : "false"; | |
| if (typeof obj === "number") { | |
| return (Number.isInteger(obj) && _INT_KEYS.has(key)) ? String(obj) : pyFloatRepr(obj); | |
| } | |
| if (typeof obj === "string") return JSON.stringify(obj); | |
| if (Array.isArray(obj)) return "[" + obj.map((v) => canon(v, key)).join(",") + "]"; | |
| const keys = Object.keys(obj).sort(); | |
| return "{" + keys.map((k) => JSON.stringify(k) + ":" + canon(obj[k], k)).join(",") + "}"; | |
| } | |
| /* ---------- SHA-256 / HMAC via WebCrypto ---------- */ | |
| const enc = new TextEncoder(); | |
| export async function sha256(bytes) { | |
| const d = await crypto.subtle.digest("SHA-256", bytes); | |
| return new Uint8Array(d); | |
| } | |
| export function hex(bytes) { | |
| return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| } | |
| export function concat(...arrs) { | |
| const n = arrs.reduce((s, a) => s + a.length, 0); | |
| const out = new Uint8Array(n); | |
| let o = 0; | |
| for (const a of arrs) { out.set(a, o); o += a.length; } | |
| return out; | |
| } | |
| /* ---------- the verdict re-derivation (the part that matters) ---------- */ | |
| /** | |
| * Re-derive the per-locus interval-gate classification and the verdict. | |
| * Mirrors the Python `rederive_gate_verdict` operation for operation. | |
| */ | |
| export function rederiveGateVerdict(cert) { | |
| const thr = +cert.thr, dd = +cert.delta_dose, K = +cert.K; | |
| const K_lo = down(down(K)), K_hi = up(up(K)); | |
| const L = cert.loci; | |
| const n = L.ae0.length; | |
| let nSafe = 0, nUnsafe = 0, nStraddle = 0; | |
| for (let j = 0; j < n; j++) { | |
| const I_lo = +L.I_lo[j], I_hi = +L.I_hi[j], ae0 = +L.ae0[j]; | |
| const sub = ae0 < thr; | |
| const he = (1.0 + dd) * I_hi; | |
| const le = (1.0 - dd) * I_lo; | |
| const he_lo = down(he), he_hi = up(he); | |
| const le_lo = down(le), le_hi = up(le); | |
| let m_lo, m_hi, in_lo, in_hi; | |
| if (sub) { | |
| m_lo = down(thr - he_hi); m_hi = up(thr - he_lo); | |
| in_lo = he_lo; in_hi = he_hi; | |
| } else { | |
| m_lo = down(le_lo - thr); m_hi = up(le_hi - thr); | |
| in_lo = le_lo; in_hi = le_hi; | |
| } | |
| const Kin_lo = down(K_lo * Math.max(in_lo, 0.0)); | |
| const Kin_hi = up(K_hi * Math.max(in_hi, 0.0)); | |
| const safe = (m_lo > 0.0) && (down(m_lo * m_lo) >= Kin_hi); | |
| const unsafe = (m_hi <= 0.0) || (up(m_hi * m_hi) < Kin_lo); | |
| if (safe) nSafe++; | |
| else if (unsafe) nUnsafe++; | |
| else nStraddle++; | |
| } | |
| let interval_admit, stable; | |
| if (n === 0) { interval_admit = true; stable = true; } | |
| else if (nUnsafe > 0) { interval_admit = false; stable = nStraddle === 0; } | |
| else if (nStraddle === 0) { interval_admit = true; stable = true; } | |
| else { interval_admit = false; stable = false; } | |
| return { interval_admit, stable, n_loci: n, n_certainly_safe: nSafe, | |
| n_certainly_unsafe: nUnsafe, n_straddle: nStraddle }; | |
| } | |
| export function checkKappaK(budget, safety, nPhotons, kappa, K) { | |
| const errs = []; | |
| if (!(Math.abs(0.5 * erfc(kappa) - budget) < 1e-12)) | |
| errs.push("kappa fails the erfc round-trip against budget"); | |
| const Kre = 2.0 * kappa * kappa * safety * safety / nPhotons; | |
| if (Kre !== K) | |
| errs.push("K does not recompute bit-identically from (kappa, safety, n_photons)"); | |
| return errs; | |
| } | |
| /* ---------- top-level bundle verification ---------- */ | |
| /** | |
| * Verify a bundle. | |
| * | |
| * `requireCerts` (default true) refuses a bundle carrying no certificates at all. | |
| * Such a bundle is trivially consistent, so a bare format check reports success on | |
| * it — which a reader would mistake for "something was certified", and which an | |
| * attacker can produce by simply deleting the certificates. Pass `false` only if | |
| * an empty bundle is genuinely expected. | |
| */ | |
| export const VERDICT = { | |
| VERIFIED: "VERIFIED", | |
| VERIFIED_VACUOUS: "VERIFIED-VACUOUS", | |
| INTERNALLY_CONSISTENT: "INTERNALLY-CONSISTENT", | |
| UNVERIFIED: "UNVERIFIED", | |
| VACUOUS: "VACUOUS", | |
| REFUTED: "REFUTED", | |
| }; | |
| const NO_ANCHOR = | |
| "no trust anchor supplied — the bundle is internally consistent, but internal " + | |
| "consistency cannot distinguish a genuine certificate from a self-consistent forgery " + | |
| "(one where the physics inputs AND the recorded verdict were edited together). Supply " + | |
| "the expected bundle fingerprint, obtained out of band. To accept the weaker " + | |
| "internal-consistency check on purpose, pass { requireAnchor: false }."; | |
| export async function verifyBundle(bundleText, files = {}, expectedSha = "", | |
| { requireCerts = true, requireAnchor = true } = {}) { | |
| const errors = []; | |
| const raw = enc.encode(bundleText); | |
| if (expectedSha) { | |
| const got = hex(await sha256(raw)); | |
| if (got !== expectedSha.toLowerCase()) | |
| errors.push(`bundle fingerprint ${got} does not match the expected value`); | |
| } | |
| let bundle; | |
| try { bundle = JSON.parse(bundleText); } | |
| catch (e) { return { ok: false, errors: [`bundle.json is not valid JSON: ${e.message}`] }; } | |
| if (bundle.format !== FORMAT) | |
| errors.push(`unknown bundle format ${JSON.stringify(bundle.format)}`); | |
| // NOTE ON CANONICAL JSON. The Python reference verifier additionally checks | |
| // that bundle.json round-trips through its canonical serializer. That check is | |
| // deliberately NOT reproduced here: JSON cannot distinguish an integer from an | |
| // integral float, so a JS re-serialization can differ from the producer's bytes | |
| // for reasons that are not tampering. Byte-level integrity is instead | |
| // established by the fingerprint above, which is exact and language-neutral — | |
| // supply `expectedSha` (obtained out of band) to get it. Everything the | |
| // canonical check would catch, the fingerprint catches strictly better. | |
| for (const [rel, want] of Object.entries(bundle.manifest || {})) { | |
| if (!(rel in files)) { errors.push(`manifest lists ${rel}, which was not supplied`); continue; } | |
| const got = hex(await sha256(files[rel])); | |
| if (got !== want) errors.push(`payload ${rel} does not match its manifest sha256`); | |
| } | |
| for (const cert of bundle.gate_certs || []) { | |
| const name = cert.name ?? "?"; | |
| for (const e of checkKappaK(+cert.budget, +cert.safety, +cert.n_photons, | |
| +cert.kappa, +cert.K)) | |
| errors.push(`[${name}] ${e}`); | |
| const red = rederiveGateVerdict(cert); | |
| const rec = cert.recorded || {}; | |
| for (const [k, v] of Object.entries(red)) | |
| if (rec[k] !== v) | |
| errors.push(`[${name}] recorded ${k}=${JSON.stringify(rec[k])} but re-derived ${JSON.stringify(v)}`); | |
| } | |
| const nCerts = (bundle.gate_certs || []).length + | |
| (bundle.image_bound_certs || []).length + | |
| (bundle.resource_floor_certs || []).length; | |
| const nLoci = (bundle.gate_certs || []) | |
| .reduce((s, c) => s + ((c.loci && c.loci.ae0) ? c.loci.ae0.length : 0), 0); | |
| const internallyConsistent = errors.length === 0; | |
| const fingerprint = hex(await sha256(raw)); | |
| let verdict, ok; | |
| if (errors.length) { | |
| verdict = VERDICT.REFUTED; ok = false; | |
| } else if (requireCerts && nCerts === 0) { | |
| verdict = VERDICT.VACUOUS; ok = false; | |
| errors.push("bundle carries no certificates — nothing was verified. This is a " + | |
| "vacuous bundle; pass { requireCerts: false } if that is intended."); | |
| } else if (!expectedSha) { | |
| if (requireAnchor) { verdict = VERDICT.UNVERIFIED; ok = false; errors.push(NO_ANCHOR); } | |
| else { verdict = VERDICT.INTERNALLY_CONSISTENT; ok = true; } | |
| } else if (nLoci === 0) { | |
| // Consistent, but no locus carried a proof obligation. Calling that VERIFIED | |
| // would sell a guarantee nothing had to earn. | |
| verdict = VERDICT.VERIFIED_VACUOUS; ok = true; | |
| } else { | |
| verdict = VERDICT.VERIFIED; ok = true; | |
| } | |
| return { ok, verdict, errors, bundle, nCertificates: nCerts, nGatedLoci: nLoci, | |
| trustAnchor: expectedSha ? "fingerprint" : "NONE", | |
| internallyConsistent, fingerprint }; | |
| } | |
| export { FORMAT }; | |