Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263/** * A bundle is the tests a job runs: scripts and their lock files, as text. It is identified by a hash of its content and signed, with an expiry, by the release key whose public half is * pinned in the extension build. A job is data, never code (Manifest V3 forbids remote code), and the extension refuses a bundle whose hash or signature is wrong * before it parses a line of it. Uses WebCrypto, so it runs in the extension and in Node. */import { verifySignature } from "./signature";
export interface Bundle { version: 1; files: Record<string, string>;}
export const MAX_BUNDLE_CHARS = 2_000_000;const FILE = /^scripts\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.(?:aite|lock\.json)$/;/** The signed text is labelled so a signature made for some other purpose over the same hex is not a bundle signature. */const SIGNED_PREFIX = "aite-bundle-v2:";
export function parseBundle(raw: unknown): { ok: true; bundle: Bundle } | { ok: false; message: string } { const no = (message: string) => ({ ok: false as const, message }); if (typeof raw !== "object" || raw === null) return no("the bundle is not an object"); const b = raw as { version?: unknown; files?: unknown }; if (b.version !== 1) return no("the bundle is not version 1"); if (typeof b.files !== "object" || b.files === null || Array.isArray(b.files)) return no("the bundle has no files"); const entries = Object.entries(b.files as Record<string, unknown>); if (entries.length === 0) return no("the bundle has no files"); let size = 0; for (const [path, text] of entries) { if (!FILE.test(path) || path.split("/").some((p) => p === "." || p === "..")) return no(`${JSON.stringify(path).slice(0, 80)} is not a script or lock file under scripts/`); if (typeof text !== "string") return no(`${path} is not text`); size += text.length + path.length; } if (size > MAX_BUNDLE_CHARS) return no("the bundle is too large"); return { ok: true, bundle: { version: 1, files: Object.fromEntries(entries) as Record<string, string> } };}
const hex = (bytes: ArrayBuffer): string => [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
/** SHA-256 over the files sorted by path, as JSON, so the same files always give the same hash and one file's end cannot be mistaken for the next one's start. */export async function bundleHash(bundle: Bundle): Promise<string> { const entries = Object.entries(bundle.files).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); return hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(entries))));}
/** What the release key signs: the hash and the moment the bundle stops being good (milliseconds since 1970), so neither can be changed by whoever serves it. */export const signedText = (hash: string, notAfter: number): Uint8Array => new TextEncoder().encode(`${SIGNED_PREFIX}${hash}:${notAfter}`);
export type VerifyResult = { ok: true } | { ok: false; reason: "not-requested" | "hash" | "key" | "signature" | "expired"; message: string };
/** * `publicKeyB64` is the raw 32 byte Ed25519 public key of the release key, base64. `wantedHash` is the hash the job named. `now` is this device's clock in * milliseconds: a bundle past its signed `notAfter` is refused. */export async function verifyBundle(received: { hash: string; signature: string; notAfter: number; bundle: Bundle }, publicKeyB64: string, wantedHash: string, now: number): Promise<VerifyResult> { const no = (reason: Exclude<VerifyResult, { ok: true }>["reason"], message: string): VerifyResult => ({ ok: false, reason, message }); if (received.hash !== wantedHash) return no("not-requested", "the bundle sent is not the one the job names"); if ((await bundleHash(received.bundle)) !== wantedHash) return no("hash", "the bundle's content does not match its hash: it was changed"); if (typeof received.notAfter !== "number" || !Number.isInteger(received.notAfter) || received.notAfter < 0) return no("expired", "the bundle has no valid expiry"); const checked = await verifySignature(publicKeyB64, received.signature, signedText(wantedHash, received.notAfter), "bundle"); if (!checked.ok) return no(checked.reason, checked.message); if (now > received.notAfter) return no("expired", "the bundle has expired"); return { ok: true };}