This commit is contained in:
parent
8e71537d57
commit
fcc18ddcc8
96 changed files with 8074 additions and 214 deletions
380
tests/unit/verifier.test.ts
Normal file
380
tests/unit/verifier.test.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
/* 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue