380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/array-type, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
|
|
/** Stage 7 — pure package validation, ordering, replay protection, and quarantine. */
|
|
import { describe, expect, it } from "vitest";
|
|
import { canonicalJson } from "../../pipeline/canonical-json.js";
|
|
import { buildPackage } from "../../pipeline/package.js";
|
|
import { generateTestKeyPair, signManifest } from "../../pipeline/sign.js";
|
|
import { sha256Hex } from "../../pipeline/hash.js";
|
|
import { makeValidInput } from "../../pipeline/fixtures.js";
|
|
import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
|
|
import { verifyPackage } from "../../src/sync/verifier/package.js";
|
|
import type {
|
|
PackageFileProvider,
|
|
QuarantineRecord,
|
|
VerifyDependencies,
|
|
} from "../../src/sync/verifier/types.js";
|
|
import type { PackageSignature } from "../../src/data/festival-package/types.js";
|
|
|
|
const enc = (value: string) => new TextEncoder().encode(value);
|
|
|
|
function makeFixture() {
|
|
const keyPair = generateTestKeyPair();
|
|
const built = buildPackage(makeValidInput(), { signWith: keyPair });
|
|
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
|
|
const manifestBytes = enc(canonicalJson(built.pkg.manifest));
|
|
const files = new Map<string, Uint8Array>();
|
|
for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes);
|
|
for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent);
|
|
const records: QuarantineRecord[] = [];
|
|
const provider: PackageFileProvider = {
|
|
listFiles: () => [...files.keys()],
|
|
getFile: async (name) => files.get(name),
|
|
};
|
|
const deps: VerifyDependencies = {
|
|
trustedKeys: new Map([
|
|
[keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)],
|
|
]),
|
|
appVersion: "1.0.0",
|
|
supportedSchemaRange: [1],
|
|
quarantine: {
|
|
add: (record) => {
|
|
records.push(record);
|
|
},
|
|
},
|
|
};
|
|
return {
|
|
keyPair,
|
|
built: built.pkg,
|
|
signature: built.pkg.signature as PackageSignature,
|
|
manifestBytes,
|
|
files,
|
|
provider,
|
|
deps,
|
|
records,
|
|
};
|
|
}
|
|
|
|
function resign(manifest: unknown, keyPair: ReturnType<typeof generateTestKeyPair>) {
|
|
const bytes = enc(canonicalJson(manifest));
|
|
return { bytes, signature: signManifest(bytes, keyPair.privateKeyPem, keyPair.fingerprint) };
|
|
}
|
|
|
|
function withManifest(fixture: ReturnType<typeof makeFixture>, manifest: unknown) {
|
|
const signed = resign(manifest, fixture.keyPair);
|
|
return { ...fixture, manifestBytes: signed.bytes, signature: signed.signature };
|
|
}
|
|
|
|
function replaceSection(fixture: ReturnType<typeof makeFixture>, name: string, value: unknown) {
|
|
const bytes = enc(canonicalJson(value));
|
|
const sectionId = (
|
|
Object.keys(fixture.built.manifest.sections) as Array<
|
|
keyof typeof fixture.built.manifest.sections
|
|
>
|
|
).find((id) => fixture.built.manifest.sections[id].file === name);
|
|
if (!sectionId) throw new Error(`unknown section file ${name}`);
|
|
const entry = fixture.built.manifest.sections[sectionId];
|
|
const manifest = {
|
|
...fixture.built.manifest,
|
|
sections: {
|
|
...fixture.built.manifest.sections,
|
|
[sectionId]: { ...entry, bytes: bytes.length, sha256: sha256Hex(bytes) },
|
|
},
|
|
limits: {
|
|
totalBytes:
|
|
fixture.built.manifest.limits.totalBytes + bytes.length - fixture.files.get(name)!.length,
|
|
},
|
|
};
|
|
const changed = withManifest(fixture, manifest);
|
|
const files = new Map(fixture.files);
|
|
files.set(name, bytes);
|
|
return {
|
|
...changed,
|
|
files,
|
|
provider: {
|
|
listFiles: () => [...files.keys()],
|
|
getFile: async (file: string) => files.get(file),
|
|
},
|
|
};
|
|
}
|
|
|
|
function depsWithEvents(fixture: ReturnType<typeof makeFixture>, events: string[]) {
|
|
return {
|
|
...fixture.deps,
|
|
onGate: (gate: "signature" | "compatibility" | "files" | "schema") => events.push(gate),
|
|
};
|
|
}
|
|
|
|
async function verify(
|
|
fixture: ReturnType<typeof makeFixture>,
|
|
deps = fixture.deps,
|
|
signature: PackageSignature = fixture.signature as PackageSignature,
|
|
extras: {
|
|
readonly active?: { edition: string; packageVersion: number } | null;
|
|
readonly emergencyFloor?: unknown;
|
|
} = {},
|
|
) {
|
|
const input = {
|
|
manifestBytes: fixture.manifestBytes,
|
|
signature,
|
|
files: fixture.provider,
|
|
emergencyFloor: extras.emergencyFloor ?? fixture.built.emergencyFloor,
|
|
...(extras.active === undefined ? {} : { active: extras.active }),
|
|
};
|
|
return verifyPackage(input, deps);
|
|
}
|
|
|
|
describe("Stage 7 package verifier", () => {
|
|
it("accepts a valid signed package and does not quarantine it", async () => {
|
|
const fixture = makeFixture();
|
|
const result = await verify(fixture);
|
|
expect(result.ok).toBe(true);
|
|
expect(fixture.records).toHaveLength(0);
|
|
});
|
|
|
|
it("checks signature, compatibility, files, then schema in order", async () => {
|
|
const fixture = makeFixture();
|
|
const gates: string[] = [];
|
|
const result = await verify(fixture, depsWithEvents(fixture, gates));
|
|
expect(result.ok).toBe(true);
|
|
expect(gates).toEqual(["signature", "compatibility", "files", "schema"]);
|
|
});
|
|
|
|
it("rejects bad signatures without retrieving any package file", async () => {
|
|
const fixture = makeFixture();
|
|
let gets = 0;
|
|
const result = await verify(
|
|
{
|
|
...fixture,
|
|
provider: {
|
|
getFile: async () => {
|
|
gets++;
|
|
return undefined;
|
|
},
|
|
},
|
|
},
|
|
fixture.deps,
|
|
{ ...fixture.signature, signature: "invalid" } as PackageSignature,
|
|
);
|
|
expect(result).toMatchObject({ ok: false, code: "signature_mismatch" });
|
|
expect(gets).toBe(0);
|
|
expect(fixture.records).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects malformed signatures and unknown keys before file access", async () => {
|
|
const fixture = makeFixture();
|
|
let gets = 0;
|
|
const input = {
|
|
...fixture,
|
|
provider: {
|
|
getFile: async () => {
|
|
gets++;
|
|
return undefined;
|
|
},
|
|
},
|
|
};
|
|
const malformed = await verify(input, fixture.deps, {
|
|
...fixture.signature,
|
|
signature: "",
|
|
} as PackageSignature);
|
|
expect(malformed.ok).toBe(false);
|
|
const unknown = await verify(input, fixture.deps, {
|
|
...fixture.signature,
|
|
publicKeyFingerprint: "sha256:unknown",
|
|
} as PackageSignature);
|
|
expect(unknown).toMatchObject({ ok: false, code: "unknown_fingerprint" });
|
|
expect(gets).toBe(0);
|
|
});
|
|
|
|
it("rejects a manifest hash mismatch and wrong manifest bytes", async () => {
|
|
const fixture = makeFixture();
|
|
const badHash = await verify(fixture, fixture.deps, {
|
|
...fixture.signature,
|
|
manifestSha256: "0".repeat(64),
|
|
} as PackageSignature);
|
|
expect(badHash).toMatchObject({ ok: false, code: "manifest_sha_mismatch" });
|
|
const wrongBytes = {
|
|
...fixture,
|
|
manifestBytes: enc(canonicalJson({ ...fixture.built.manifest, packageVersion: 99 })),
|
|
};
|
|
const wrong = await verify(wrongBytes);
|
|
expect(wrong).toMatchObject({ ok: false, code: "manifest_sha_mismatch" });
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"app",
|
|
{ appCompatibility: { minAppVersion: "9.0.0", maxAppVersion: null } },
|
|
"incompatible_app",
|
|
],
|
|
["schema", { schemaVersion: 99 }, "incompatible_app"],
|
|
["version", { packageVersion: 0 }, "malformed_manifest"],
|
|
["budget", { limits: { totalBytes: 41 * 1024 * 1024 } }, "budget_exceeded"],
|
|
] as const)("rejects %s before file retrieval", async (_name, change, code) => {
|
|
const fixture = makeFixture();
|
|
let gets = 0;
|
|
const changed = withManifest(fixture, { ...fixture.built.manifest, ...change });
|
|
const result = await verify(
|
|
{
|
|
...changed,
|
|
provider: {
|
|
getFile: async () => {
|
|
gets++;
|
|
return undefined;
|
|
},
|
|
},
|
|
},
|
|
changed.deps,
|
|
changed.signature,
|
|
);
|
|
expect(result).toMatchObject({ ok: false, code });
|
|
expect(gets).toBe(0);
|
|
});
|
|
|
|
it("rejects replayed and cross-edition packages before files", async () => {
|
|
const fixture = makeFixture();
|
|
const replay = await verify(fixture, fixture.deps, fixture.signature, {
|
|
active: {
|
|
edition: fixture.built.manifest.edition,
|
|
packageVersion: fixture.built.manifest.packageVersion,
|
|
},
|
|
});
|
|
expect(replay).toMatchObject({ ok: false });
|
|
const newer = withManifest(fixture, { ...fixture.built.manifest, packageVersion: 8 });
|
|
expect(
|
|
await verify({ ...newer, provider: fixture.provider }, { ...newer.deps }, newer.signature, {
|
|
active: {
|
|
edition: fixture.built.manifest.edition,
|
|
packageVersion: fixture.built.manifest.packageVersion,
|
|
},
|
|
}),
|
|
).toMatchObject({ ok: true });
|
|
const other = withManifest(fixture, { ...fixture.built.manifest, edition: "other-2026" });
|
|
expect(
|
|
await verify({ ...other, provider: fixture.provider }, { ...other.deps }, other.signature, {
|
|
active: {
|
|
edition: fixture.built.manifest.edition,
|
|
packageVersion: fixture.built.manifest.packageVersion,
|
|
},
|
|
}),
|
|
).toMatchObject({ ok: false, code: "incompatible_edition" });
|
|
});
|
|
|
|
it("rejects missing, size-mismatched, hash-mismatched, and unexpected files", async () => {
|
|
const fixture = makeFixture();
|
|
const missing = new Map(fixture.files);
|
|
missing.delete("schedule.json");
|
|
expect(
|
|
await verify({ ...fixture, provider: { getFile: async (name) => missing.get(name) } }),
|
|
).toMatchObject({ ok: false, code: "missing_file" });
|
|
const wrongSize = new Map(fixture.files);
|
|
wrongSize.set("schedule.json", enc("wrong"));
|
|
expect(
|
|
await verify({ ...fixture, provider: { getFile: async (name) => wrongSize.get(name) } }),
|
|
).toMatchObject({ ok: false, code: "size_mismatch" });
|
|
const wrongHash = new Map(fixture.files);
|
|
wrongHash.set("schedule.json", new Uint8Array(fixture.files.get("schedule.json")!));
|
|
const wrongSchedule = wrongHash.get("schedule.json");
|
|
if (wrongSchedule) wrongSchedule[0] = (wrongSchedule[0] ?? 0) ^ 1;
|
|
expect(
|
|
await verify({ ...fixture, provider: { getFile: async (name) => wrongHash.get(name) } }),
|
|
).toMatchObject({ ok: false, code: "hash_mismatch" });
|
|
const extra = new Map(fixture.files);
|
|
extra.set("unexpected.bin", enc("x"));
|
|
expect(
|
|
await verify({
|
|
...fixture,
|
|
provider: { listFiles: () => [...extra.keys()], getFile: async (name) => extra.get(name) },
|
|
}),
|
|
).toMatchObject({ ok: false, code: "unexpected_file" });
|
|
});
|
|
|
|
it("rejects malformed sections and invalid emergency floor after file integrity", async () => {
|
|
const fixture = makeFixture();
|
|
const schedule = JSON.parse(new TextDecoder().decode(fixture.files.get("schedule.json")));
|
|
schedule.events[0].dayKey = "1900-01-01";
|
|
const scheduleWithBadDay = replaceSection(fixture, "schedule.json", schedule);
|
|
const result = await verify(scheduleWithBadDay);
|
|
expect(result).toMatchObject({ ok: false, code: "malformed_manifest" });
|
|
const floor = await verify(fixture, fixture.deps, fixture.signature, {
|
|
emergencyFloor: { floor: true },
|
|
});
|
|
expect(floor).toMatchObject({ ok: false, code: "malformed_manifest" });
|
|
});
|
|
|
|
it("validates stable IDs, map POIs, and emergency section schema after integrity", async () => {
|
|
const fixture = makeFixture();
|
|
const schedule = JSON.parse(new TextDecoder().decode(fixture.files.get("schedule.json")));
|
|
schedule.events[0].id = "bad id";
|
|
expect(await verify(replaceSection(fixture, "schedule.json", schedule))).toMatchObject({
|
|
ok: false,
|
|
code: "malformed_manifest",
|
|
});
|
|
const map = JSON.parse(new TextDecoder().decode(fixture.files.get("map.json")));
|
|
map.pois[0].x = 2;
|
|
expect(await verify(replaceSection(fixture, "map.json", map))).toMatchObject({
|
|
ok: false,
|
|
code: "malformed_manifest",
|
|
});
|
|
const emergency = JSON.parse(new TextDecoder().decode(fixture.files.get("emergency.json")));
|
|
delete emergency.procedures;
|
|
expect(await verify(replaceSection(fixture, "emergency.json", emergency))).toMatchObject({
|
|
ok: false,
|
|
code: "malformed_manifest",
|
|
});
|
|
});
|
|
|
|
it("enforces downloaded map dimensions and the per-file ceiling", async () => {
|
|
const fixture = makeFixture();
|
|
const map = JSON.parse(new TextDecoder().decode(fixture.files.get("map.json")));
|
|
map.base.levels[0].width = 1601;
|
|
expect(await verify(replaceSection(fixture, "map.json", map))).toMatchObject({
|
|
ok: false,
|
|
code: "budget_exceeded",
|
|
});
|
|
const oversized = withManifest(fixture, {
|
|
...fixture.built.manifest,
|
|
sections: {
|
|
...fixture.built.manifest.sections,
|
|
emergency: {
|
|
...fixture.built.manifest.sections.emergency,
|
|
bytes: 7 * 1024 * 1024,
|
|
},
|
|
},
|
|
});
|
|
expect(await verify(oversized, oversized.deps, oversized.signature)).toMatchObject({
|
|
ok: false,
|
|
code: "malformed_manifest",
|
|
});
|
|
});
|
|
|
|
it("accepts unknown forward-compatible section fields after integrity", async () => {
|
|
const fixture = makeFixture();
|
|
const schedule = JSON.parse(new TextDecoder().decode(fixture.files.get("schedule.json")));
|
|
schedule.futureField = { version: 2, optional: true };
|
|
expect(await verify(replaceSection(fixture, "schedule.json", schedule))).toMatchObject({
|
|
ok: true,
|
|
});
|
|
});
|
|
|
|
it("quarantines failures without invoking activation or changing active state", async () => {
|
|
const fixture = makeFixture();
|
|
const active = {
|
|
edition: fixture.built.manifest.edition,
|
|
packageVersion: fixture.built.manifest.packageVersion,
|
|
};
|
|
const before = { ...active };
|
|
const result = await verify(
|
|
fixture,
|
|
fixture.deps,
|
|
{ ...fixture.signature, signature: "bad" } as PackageSignature,
|
|
{ active },
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
expect(active).toEqual(before);
|
|
expect(fixture.records[0]).toMatchObject({
|
|
code: "signature_mismatch",
|
|
edition: null,
|
|
packageVersion: null,
|
|
});
|
|
});
|
|
});
|