import { verifySignature } from "./signature"; /** * What the extension checks before it parses a job or trusts a list of revoked bundles. Three separate signed things, each with its own label so a signature made for * one is never good for another: * - a job, signed by the dispatch key (it holds the device, the attempt and every limit the job carries, and an expiry); * - a list of revoked bundle hashes, signed by the release key (the offline one), with a number that only goes up so an older list cannot be served again. * The bundle's own signature is in bundle.ts. */ const JOB_PREFIX = "aite-job-v1:"; const DENYLIST_PREFIX = "aite-denylist-v1:"; /** JSON with every object's keys sorted, so the same data always gives the same text whichever order a server wrote the fields in. */ export function canonicalJson(v: unknown): string { if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`; if (typeof v === "object" && v !== null) { const o = v as Record; return `{${Object.keys(o).sort().filter((k) => o[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`; } return JSON.stringify(v) ?? "null"; } /** The signed text of a job: everything but the signature itself and `state` (which is the one field that changes as the job moves). */ export function jobSignedText(job: Record): Uint8Array { const { signature: _s, state: _t, ...rest } = job; return new TextEncoder().encode(JOB_PREFIX + canonicalJson(rest)); } export type JobCheck = { ok: true } | { ok: false; reason: "unsigned" | "key" | "signature" | "expired"; message: string }; const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); /** `now` is this device's clock in milliseconds; `notAfter` is inside the signature, so the server cannot stretch it. */ export async function verifyJob(raw: unknown, dispatchKeyB64: string, now: number): Promise { if (!isRecord(raw)) return { ok: false, reason: "unsigned", message: "the job is not an object" }; if (typeof raw.signature !== "string" || raw.signature === "") return { ok: false, reason: "unsigned", message: "the job is not signed" }; const checked = await verifySignature(dispatchKeyB64, raw.signature, jobSignedText(raw), "job"); if (!checked.ok) return checked; if (typeof raw.notAfter !== "number" || !Number.isInteger(raw.notAfter) || now > raw.notAfter) return { ok: false, reason: "expired", message: "the job has expired" }; return { ok: true }; } export interface Denylist { seq: number; hashes: string[]; } export const denylistSignedText = (list: Denylist): Uint8Array => new TextEncoder().encode(DENYLIST_PREFIX + canonicalJson({ seq: list.seq, hashes: list.hashes })); export type DenylistCheck = { ok: true; seq: number; hashes: string[] } | { ok: false; message: string }; export async function verifyDenylist(raw: unknown, releaseKeyB64: string): Promise { if (!isRecord(raw)) return { ok: false, message: "the revocation list is not an object" }; const { seq, hashes, signature } = raw; if (typeof signature !== "string" || signature === "") return { ok: false, message: "the revocation list is not signed" }; if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 0) return { ok: false, message: "the revocation list has no valid number" }; if (!Array.isArray(hashes) || !hashes.every((h) => typeof h === "string" && /^[0-9a-f]{64}$/.test(h))) return { ok: false, message: "the revocation list holds something that is not a bundle hash" }; const checked = await verifySignature(releaseKeyB64, signature, denylistSignedText({ seq, hashes: hashes as string[] }), "revocation list"); if (!checked.ok) return { ok: false, message: checked.message }; return { ok: true, seq, hashes: hashes as string[] }; }