Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
emretinaztepe marked this conversation as resolved.
INVALID_FRONT_MATTER = "INVALID_FRONT_MATTER",
NETWORK_ERROR = "NETWORK_ERROR",
Expand Down
37 changes: 29 additions & 8 deletions packages/core/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,21 @@ async function sha256Hex(data: Uint8Array): Promise<string> {
.join("");
}

function findUnexpectedFiles(
manifest: PackageManifest,
files: Map<string, Uint8Array>,
): 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<string, Uint8Array>,
Expand Down Expand Up @@ -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 },
};
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Comment on lines 732 to 736

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verifyZipFromAuthor() now rejects ZIP entries not present in manifest.files, but there’s no regression test exercising this code path (only verifyPackage() is covered). Add a test that calls verifyFromAuthor(tamperedZip, { fetch: mockFetchWithKeys([...]), resolveTxt: false }) and asserts it returns valid: false with VerifyErrorCode.UNEXPECTED_FILE and includes the unexpected entry in details.files.

This ensures the security fix can’t regress in the author-verification entry point.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — added in 1ea22de. New test rejects ZIP with extra unsigned file via verifyFromAuthor (UNEXPECTED_FILE) exercises verifyFromAuthor with the same tampered-ZIP scenario and asserts code: UNEXPECTED_FILE and that evil.sh shows up in details.files as a failed entry.

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 },
};
}
Expand Down
38 changes: 38 additions & 0 deletions packages/core/test/verify.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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" });
Expand Down
Loading