Skip to content
Open
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
35 changes: 34 additions & 1 deletion src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { existsSync, readFileSync, statSync } from "fs";
import { basename, dirname, join, resolve } from "path";
import { fileURLToPath } from "url";

import { signMcpbFile, unsignMcpbFile, verifyMcpbFile } from "../node/sign.js";
import {
fixSignatureMcpbFile,
signMcpbFile,
unsignMcpbFile,
verifyMcpbFile,
} from "../node/sign.js";
import { cleanMcpb, validateManifest } from "../node/validate.js";
import { initExtension } from "./init.js";
import { packExtension } from "./pack.js";
Expand Down Expand Up @@ -355,5 +360,33 @@ program
}
});

// Fix-signature command (for externally signed bundles)
program
.command("fix-signature <mcpb-file>")
.description(
"Fix ZIP EOCD comment_length for externally signed MCPB bundles (e.g. GaraSign, SignServer)",
)
.action((mcpbFile: string) => {
try {
const mcpbPath = resolve(mcpbFile);

if (!existsSync(mcpbPath)) {
console.error(`ERROR: MCPB file not found: ${mcpbFile}`);
process.exit(1);
}

console.log(`Fixing signature for ${basename(mcpbPath)}...`);
fixSignatureMcpbFile(mcpbPath);
console.log(
`Signature fixed — bundle is now installable in Claude Desktop`,
);
} catch (error) {
console.error(
`ERROR: ${error instanceof Error ? error.message : "Unknown error"}`,
);
process.exit(1);
}
});

// Parse command line arguments
program.parse();
46 changes: 46 additions & 0 deletions src/node/sign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,52 @@ export async function verifyCertificateChain(
}
}

/**
* Fixes the ZIP EOCD comment_length for an externally signed MCPB file.
*
* External signing tools (GaraSign, SignServer, Venafi, etc.) append a
* PKCS#7 signature block after the ZIP EOCD without updating comment_length.
* Strict ZIP parsers like adm-zip (used by Claude Desktop) reject these files.
* This function sets comment_length to encompass the trailing signature bytes,
* making the file a spec-valid ZIP while preserving the signature intact.
*
* @param mcpbPath Path to the signed MCPB file to fix
*/
export function fixSignatureMcpbFile(mcpbPath: string): void {
const fileContent = readFileSync(mcpbPath);

// Verify this file has a signature block
const { pkcs7Signature } = extractSignatureBlock(fileContent);
if (!pkcs7Signature) {
throw new Error("File is not signed — nothing to fix");
}

// Find the EOCD record
const eocdOffset = findEocdOffset(fileContent);
if (eocdOffset === -1) {
throw new Error("No ZIP end-of-central-directory record found");
}

const currentCommentLength = fileContent.readUInt16LE(eocdOffset + 20);
const actualTrailing = fileContent.length - (eocdOffset + 22);

if (currentCommentLength === actualTrailing) {
console.log("EOCD comment_length already correct — no fix needed");
return;
}

if (actualTrailing > 65535) {
throw new Error(
`Signature block too large for ZIP comment field (${actualTrailing} > 65535)`,
);
}

// Apply the fix — same approach as signMcpbFile() from PR #204
const updatedContent = Buffer.from(fileContent);
updatedContent.writeUInt16LE(actualTrailing, eocdOffset + 20);
writeFileSync(mcpbPath, updatedContent);
}

/**
* Removes signature from a MCPB file
*/
Expand Down
81 changes: 81 additions & 0 deletions test/sign.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import * as path from "path";

import {
extractSignatureBlock,
fixSignatureMcpbFile,
signMcpbFile,
unsignMcpbFile,
verifyMcpbFile,
Expand Down Expand Up @@ -514,4 +516,83 @@
// Clean up
fs.unlinkSync(testFile);
});

it("should fix externally-signed bundle with incorrect EOCD comment_length", () => {
// Simulate an external signer (e.g. GaraSign): sign normally, then
// reset comment_length to 0 as if the signer didn't update the EOCD
const testFile = path.join(TEST_DIR, "test-fix-external.mcpb");
fs.copyFileSync(TEST_MCPB, testFile);
signMcpbFile(testFile, SELF_SIGNED_CERT, SELF_SIGNED_KEY);

// Read signed file and zero out comment_length to simulate external signer
const signedContent = fs.readFileSync(testFile);
let eocdOffset = -1;
for (let i = signedContent.length - 22; i >= 0; i--) {
if (signedContent.readUInt32LE(i) === 0x06054b50) {
eocdOffset = i;
break;
}
}
expect(eocdOffset).toBeGreaterThanOrEqual(0);
const brokenContent = Buffer.from(signedContent);
brokenContent.writeUInt16LE(0, eocdOffset + 20); // Zero out comment_length
fs.writeFileSync(testFile, brokenContent);

// Verify it's broken (comment_length doesn't match trailing bytes)
const beforeFix = fs.readFileSync(testFile);
expect(beforeFix.readUInt16LE(eocdOffset + 20)).toBe(0);

// Fix the signature
fixSignatureMcpbFile(testFile);

// Verify comment_length is now correct
const afterFix = fs.readFileSync(testFile);
const actualTrailing = afterFix.length - (eocdOffset + 22);
expect(afterFix.readUInt16LE(eocdOffset + 20)).toBe(actualTrailing);
expect(actualTrailing).toBeGreaterThan(0);

// Verify signature block is still intact
const { pkcs7Signature } = extractSignatureBlock(afterFix);
expect(pkcs7Signature).toBeDefined();

// Clean up
fs.unlinkSync(testFile);
});

it("should no-op on bundle already fixed by mcpb sign", () => {
// Sign normally (mcpb sign already fixes EOCD)
const testFile = path.join(TEST_DIR, "test-fix-noop.mcpb");
fs.copyFileSync(TEST_MCPB, testFile);
signMcpbFile(testFile, SELF_SIGNED_CERT, SELF_SIGNED_KEY);

// Record file content before fix attempt
const beforeContent = fs.readFileSync(testFile);

// fix-signature should be a no-op (logs "already correct")
const consoleSpy = jest.spyOn(console, "log").mockImplementation();
fixSignatureMcpbFile(testFile);
expect(consoleSpy).toHaveBeenCalledWith(
"EOCD comment_length already correct — no fix needed",
);
consoleSpy.mockRestore();

// File should be unchanged
const afterContent = fs.readFileSync(testFile);
expect(afterContent.equals(beforeContent)).toBe(true);

// Clean up
fs.unlinkSync(testFile);
});

it("should throw on unsigned file", () => {
const testFile = path.join(TEST_DIR, "test-fix-unsigned.mcpb");
fs.copyFileSync(TEST_MCPB, testFile);

expect(() => fixSignatureMcpbFile(testFile)).toThrow(

Check warning on line 591 in test/sign.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (20.19.x, ubuntu-latest)

Replace `⏎······"File·is·not·signed",⏎····` with `"File·is·not·signed"`

Check warning on line 591 in test/sign.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (22.17.x, windows-latest)

Replace `⏎······"File·is·not·signed",⏎····` with `"File·is·not·signed"`

Check warning on line 591 in test/sign.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (20.19.x, macos-latest)

Replace `⏎······"File·is·not·signed",⏎····` with `"File·is·not·signed"`

Check warning on line 591 in test/sign.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (22.17.x, ubuntu-latest)

Replace `⏎······"File·is·not·signed",⏎····` with `"File·is·not·signed"`

Check warning on line 591 in test/sign.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (20.19.x, windows-latest)

Replace `⏎······"File·is·not·signed",⏎····` with `"File·is·not·signed"`

Check warning on line 591 in test/sign.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Test (22.17.x, macos-latest)

Replace `⏎······"File·is·not·signed",⏎····` with `"File·is·not·signed"`
"File is not signed",
);

// Clean up
fs.unlinkSync(testFile);
});
});
Loading