diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ce1d6e9..0bf39ee 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -10,6 +10,7 @@ export enum VerifyErrorCode { KEY_FETCH_FAILED = "KEY_FETCH_FAILED", MISSING_MANIFEST = "MISSING_MANIFEST", MISSING_FILE = "MISSING_FILE", + UNEXPECTED_FILE = "UNEXPECTED_FILE", HASH_MISMATCH = "HASH_MISMATCH", INVALID_FRONT_MATTER = "INVALID_FRONT_MATTER", NETWORK_ERROR = "NETWORK_ERROR", diff --git a/packages/core/src/verify.ts b/packages/core/src/verify.ts index a49f0e3..2bb90bb 100644 --- a/packages/core/src/verify.ts +++ b/packages/core/src/verify.ts @@ -129,6 +129,21 @@ async function sha256Hex(data: Uint8Array): Promise { .join(""); } +function findUnexpectedFiles( + manifest: PackageManifest, + files: Map, +): FileIntegrityResult[] { + const allowed = new Set(Object.keys(manifest.files)); + const extras: FileIntegrityResult[] = []; + for (const path of files.keys()) { + if (path === "MANIFEST.json") continue; + if (!allowed.has(path)) { + extras.push({ path, valid: false, code: VerifyErrorCode.UNEXPECTED_FILE }); + } + } + return extras; +} + async function verifyManifestHashes( manifest: PackageManifest, files: Map, @@ -235,16 +250,15 @@ export async function verifyPackage( }; } - const fileResults = await verifyManifestHashes(manifest, files); + const extras = findUnexpectedFiles(manifest, files); + const fileResults = [...(await verifyManifestHashes(manifest, files)), ...extras]; const hasFailedFile = fileResults.some((f) => !f.valid); if (hasFailedFile) { const firstFailed = fileResults.find((f) => !f.valid)!; return { valid: false, code: firstFailed.code, - reason: firstFailed.code === VerifyErrorCode.MISSING_FILE - ? `Missing file: ${firstFailed.path}` - : `Hash mismatch for file: ${firstFailed.path}`, + reason: failedFileReason(firstFailed), details: { ...docMeta(manifest), signers, files: fileResults }, }; } @@ -255,6 +269,14 @@ export async function verifyPackage( }; } +function failedFileReason(f: FileIntegrityResult): string { + switch (f.code) { + case VerifyErrorCode.MISSING_FILE: return `Missing file: ${f.path}`; + case VerifyErrorCode.UNEXPECTED_FILE: return `Unexpected file not in manifest: ${f.path}`; + default: return `Hash mismatch for file: ${f.path}`; + } +} + // -- DNS TXT ------------------------------------------------------------------ export function parseDnsTxtRecord(txt: string): DnsTxtKeyRecord | null { @@ -709,16 +731,15 @@ async function verifyZipFromAuthor( files.set(path, data); } - const fileResults = await verifyManifestHashes(manifest, files); + const extras = findUnexpectedFiles(manifest, files); + const fileResults = [...(await verifyManifestHashes(manifest, files)), ...extras]; const hasFailedFile = fileResults.some((f) => !f.valid); if (hasFailedFile) { const firstFailed = fileResults.find((f) => !f.valid)!; return { valid: false, code: firstFailed.code, - reason: firstFailed.code === VerifyErrorCode.MISSING_FILE - ? `Missing file: ${firstFailed.path}` - : `Hash mismatch for file: ${firstFailed.path}`, + reason: failedFileReason(firstFailed), details: { ...docMeta(manifest), signers, files: fileResults }, }; } diff --git a/packages/core/test/verify.spec.ts b/packages/core/test/verify.spec.ts index 956e620..ff45ff6 100644 --- a/packages/core/test/verify.spec.ts +++ b/packages/core/test/verify.spec.ts @@ -202,6 +202,21 @@ describe("verifyPackage", () => { expect(result.details?.signers![0].code).toBe(VerifyErrorCode.MALFORMED_SIGNATURE); }); + it("rejects ZIP with extra unsigned file not in manifest", async () => { + const { publicKey, privateKey } = await generateKeyPair(); + const zip = zipSync({ "safe.txt": enc("safe content") }); + const signed = await signPackage(zip, PKG_META, privateKey); + const entries = unzipSync(signed); + entries["evil.sh"] = enc("#!/bin/bash\nmalicious"); + const tampered = zipSync(entries); + const result = await verifyPackage(tampered, publicKey); + expect(result.valid).toBe(false); + expect(result.code).toBe(VerifyErrorCode.UNEXPECTED_FILE); + const extra = result.details?.files!.find((f) => f.path === "evil.sh"); + expect(extra?.valid).toBe(false); + expect(extra?.code).toBe(VerifyErrorCode.UNEXPECTED_FILE); + }); + it("verifies package with multiple files", async () => { const { publicKey, privateKey } = await generateKeyPair(); const zip = zipSync({ @@ -455,6 +470,29 @@ signatures: expect(result.code).toBe(VerifyErrorCode.NO_SIGNATURES); }); + it("rejects ZIP with extra unsigned file via verifyFromAuthor (UNEXPECTED_FILE)", async () => { + const { privateKey, publicKey } = await generateKeyPair(); + const zip = zipSync({ "safe.txt": enc("safe content") }); + const signed = await signPackage(zip, PKG_META, privateKey); + const entries = unzipSync(signed); + entries["evil.sh"] = enc("#!/bin/bash\nmalicious"); + const tampered = zipSync(entries); + + const pubKeyB64 = uint8ToBase64(publicKey); + const futureDate = new Date("2030-01-01T00:00:00Z").toISOString(); + const result = await verifyFromAuthor(tampered, { + fetch: mockFetchWithKeys([ + { keyId: "key_test", algorithm: "ed25519", publicKey: pubKeyB64, expires: futureDate }, + ]) as typeof globalThis.fetch, + resolveTxt: false, + }); + expect(result.valid).toBe(false); + expect(result.code).toBe(VerifyErrorCode.UNEXPECTED_FILE); + const extra = result.details?.files!.find((f) => f.path === "evil.sh"); + expect(extra?.valid).toBe(false); + expect(extra?.code).toBe(VerifyErrorCode.UNEXPECTED_FILE); + }); + it("rejects revoked HTTPS key even when DNS reports it valid (no race bypass)", async () => { const { privateKey, publicKey } = await generateKeyPair(); const signed = await signFile(SAMPLE_MD, privateKey, { keyId: "key_test", publisher: "example.com" });