cert-verifier / src /lcert.js
nickh007's picture
Abstain without a trust anchor; amber UNVERIFIED state
a5055ef verified
Raw
History Blame Contribute Delete
13.5 kB
/**
* 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 };