mcpb/test/sign.e2e.test.ts
Bryan Thompson bb676eb17d
fix: update ZIP EOCD comment_length when signing MCPB files (#194)
The signMcpbFile function appended a signature block to the ZIP file but
did not update the EOCD comment_length field. Strict ZIP parsers (like
Claude Desktop's) rejected these files because the declared comment
length didn't account for the appended signature data.

Now scans backwards for the EOCD magic bytes, reads the current
comment_length, and adds the signature block length before concatenating.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-12 08:59:13 -05:00

517 lines
12 KiB
TypeScript
Executable file

/**
* End-to-end test for MCPB signing and verification
*
* This test:
* 1. Creates a test MCPB file
* 2. Generates a self-signed certificate for testing
* 3. Signs the MCPB file
* 4. Verifies the signature
* 5. Tests various failure scenarios
*/
import * as fs from "fs";
import forge from "node-forge";
import * as path from "path";
import {
signMcpbFile,
unsignMcpbFile,
verifyMcpbFile,
} from "../src/node/sign.js";
// Test directory
const TEST_DIR = path.join(__dirname, "test-output");
// Ensure test directory exists
if (!fs.existsSync(TEST_DIR)) {
fs.mkdirSync(TEST_DIR, { recursive: true });
}
// Test file paths
const TEST_MCPB = path.join(TEST_DIR, "test.mcpb");
const SELF_SIGNED_CERT = path.join(TEST_DIR, "self-signed.crt");
const SELF_SIGNED_KEY = path.join(TEST_DIR, "self-signed.key");
const CA_CERT = path.join(TEST_DIR, "ca.crt");
const CA_KEY = path.join(TEST_DIR, "ca.key");
const SIGNED_CERT = path.join(TEST_DIR, "signed.crt");
const SIGNED_KEY = path.join(TEST_DIR, "signed.key");
/**
* Generate a self-signed certificate for testing
*/
function generateSelfSignedCert() {
const keys = forge.pki.rsa.generateKeyPair(2048);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = "01";
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
const attrs = [
{ name: "commonName", value: "Test MCPB Publisher" },
{ name: "countryName", value: "US" },
{ name: "organizationName", value: "Test Org" },
];
cert.setSubject(attrs);
cert.setIssuer(attrs);
// Add extensions
cert.setExtensions([
{
name: "basicConstraints",
cA: false,
},
{
name: "keyUsage",
keyCertSign: false,
digitalSignature: true,
nonRepudiation: true,
keyEncipherment: false,
dataEncipherment: false,
},
{
name: "extKeyUsage",
codeSigning: true,
},
]);
// Self-sign
cert.sign(keys.privateKey, forge.md.sha256.create());
// Write files
fs.writeFileSync(SELF_SIGNED_CERT, forge.pki.certificateToPem(cert));
fs.writeFileSync(SELF_SIGNED_KEY, forge.pki.privateKeyToPem(keys.privateKey));
}
/**
* Generate a CA and signed certificate for testing
*/
function generateCASignedCert() {
// Generate CA
const caKeys = forge.pki.rsa.generateKeyPair(2048);
const caCert = forge.pki.createCertificate();
caCert.publicKey = caKeys.publicKey;
caCert.serialNumber = "01";
caCert.validity.notBefore = new Date();
caCert.validity.notAfter = new Date();
caCert.validity.notAfter.setFullYear(
caCert.validity.notBefore.getFullYear() + 10,
);
const caAttrs = [
{ name: "commonName", value: "Test CA" },
{ name: "countryName", value: "US" },
{ name: "organizationName", value: "Test CA Org" },
];
caCert.setSubject(caAttrs);
caCert.setIssuer(caAttrs);
caCert.setExtensions([
{
name: "basicConstraints",
cA: true,
},
{
name: "keyUsage",
keyCertSign: true,
digitalSignature: true,
cRLSign: true,
},
]);
caCert.sign(caKeys.privateKey, forge.md.sha256.create());
// Generate signed certificate
const keys = forge.pki.rsa.generateKeyPair(2048);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = "02";
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
const attrs = [
{ name: "commonName", value: "Test Publisher (CA Signed)" },
{ name: "countryName", value: "US" },
{ name: "organizationName", value: "Test Publisher Org" },
];
cert.setSubject(attrs);
cert.setIssuer(caCert.subject.attributes);
cert.setExtensions([
{
name: "basicConstraints",
cA: false,
},
{
name: "keyUsage",
digitalSignature: true,
nonRepudiation: true,
},
{
name: "extKeyUsage",
codeSigning: true,
},
]);
// Sign with CA
cert.sign(caKeys.privateKey, forge.md.sha256.create());
// Write files
fs.writeFileSync(CA_CERT, forge.pki.certificateToPem(caCert));
fs.writeFileSync(CA_KEY, forge.pki.privateKeyToPem(caKeys.privateKey));
fs.writeFileSync(SIGNED_CERT, forge.pki.certificateToPem(cert));
fs.writeFileSync(SIGNED_KEY, forge.pki.privateKeyToPem(keys.privateKey));
}
/**
* Create a test MCPB file (just a simple ZIP)
*/
function createTestDxt() {
// Create a simple ZIP file content
// This is a minimal valid ZIP with one file
const zipContent = Buffer.from([
0x50,
0x4b,
0x03,
0x04, // Local file header signature
0x14,
0x00, // Version needed
0x00,
0x00, // Flags
0x00,
0x00, // Compression method (stored)
0x00,
0x00, // Last mod time
0x00,
0x00, // Last mod date
0x00,
0x00,
0x00,
0x00, // CRC-32
0x0d,
0x00,
0x00,
0x00, // Compressed size
0x0d,
0x00,
0x00,
0x00, // Uncompressed size
0x0c,
0x00, // Filename length
0x00,
0x00, // Extra field length
// Filename: "manifest.json"
0x6d,
0x61,
0x6e,
0x69,
0x66,
0x65,
0x73,
0x74,
0x2e,
0x6a,
0x73,
0x6f,
0x6e,
// File content: '{"test":true}'
0x7b,
0x22,
0x74,
0x65,
0x73,
0x74,
0x22,
0x3a,
0x74,
0x72,
0x75,
0x65,
0x7d,
// Central directory
0x50,
0x4b,
0x01,
0x02, // Central directory signature
0x14,
0x00, // Version made by
0x14,
0x00, // Version needed
0x00,
0x00, // Flags
0x00,
0x00, // Compression method
0x00,
0x00, // Last mod time
0x00,
0x00, // Last mod date
0x00,
0x00,
0x00,
0x00, // CRC-32
0x0d,
0x00,
0x00,
0x00, // Compressed size
0x0d,
0x00,
0x00,
0x00, // Uncompressed size
0x0c,
0x00, // Filename length
0x00,
0x00, // Extra field length
0x00,
0x00, // File comment length
0x00,
0x00, // Disk number
0x00,
0x00, // Internal attributes
0x00,
0x00,
0x00,
0x00, // External attributes
0x00,
0x00,
0x00,
0x00, // Offset of local header
// Filename: "manifest.json"
0x6d,
0x61,
0x6e,
0x69,
0x66,
0x65,
0x73,
0x74,
0x2e,
0x6a,
0x73,
0x6f,
0x6e,
// End of central directory
0x50,
0x4b,
0x05,
0x06, // End of central directory signature
0x00,
0x00, // Disk number
0x00,
0x00, // Start disk
0x01,
0x00, // Number of central directory records on this disk
0x01,
0x00, // Total number of central directory records
0x3a,
0x00,
0x00,
0x00, // Size of central directory
0x34,
0x00,
0x00,
0x00, // Offset of central directory
0x00,
0x00, // Comment length
]);
fs.writeFileSync(TEST_MCPB, zipContent);
}
/**
* Test signing with self-signed certificate
*/
async function testSelfSignedSigning() {
// Create a copy for this test
const testFile = path.join(TEST_DIR, "test-self-signed.dxt");
fs.copyFileSync(TEST_MCPB, testFile);
// Sign the file
expect(() =>
signMcpbFile(testFile, SELF_SIGNED_CERT, SELF_SIGNED_KEY),
).not.toThrow();
// Verify the signature
const result = await verifyMcpbFile(testFile);
// Self-signed certs may not be trusted by OS, so we accept either status
expect(["self-signed", "unsigned"]).toContain(result.status);
// Clean up
fs.unlinkSync(testFile);
}
/**
* Test signing with CA-signed certificate
*/
async function testCASignedSigning() {
// Create a copy for this test
const testFile = path.join(TEST_DIR, "test-ca-signed.dxt");
fs.copyFileSync(TEST_MCPB, testFile);
// Sign the file with intermediate
expect(() =>
signMcpbFile(testFile, SIGNED_CERT, SIGNED_KEY, [CA_CERT]),
).not.toThrow();
// Verify the signature
const result = await verifyMcpbFile(testFile);
// CA-signed status depends on whether test CA is trusted by OS
expect(["signed", "unsigned"]).toContain(result.status);
// Clean up
fs.unlinkSync(testFile);
}
/**
* Test tampering detection
*/
async function testTamperingDetection() {
// Create a copy and sign it
const testFile = path.join(TEST_DIR, "test-tampered.dxt");
fs.copyFileSync(TEST_MCPB, testFile);
signMcpbFile(testFile, SELF_SIGNED_CERT, SELF_SIGNED_KEY);
// Read the signed file
const signedContent = fs.readFileSync(testFile);
// Tamper with the content (change a byte in the ZIP portion)
const tamperedContent = Buffer.from(signedContent);
tamperedContent[10] = (tamperedContent[10] + 1) % 256;
fs.writeFileSync(testFile, tamperedContent);
// Try to verify - should fail
const result = await verifyMcpbFile(testFile);
expect(result.status).toBe("unsigned");
// Clean up
fs.unlinkSync(testFile);
}
/**
* Test unsigned file verification
*/
async function testUnsignedFile() {
const result = await verifyMcpbFile(TEST_MCPB);
expect(result.status).toBe("unsigned");
}
/**
* Test signature removal
*/
async function testSignatureRemoval() {
// Create a copy and sign it
const testFile = path.join(TEST_DIR, "test-remove-sig.dxt");
fs.copyFileSync(TEST_MCPB, testFile);
signMcpbFile(testFile, SELF_SIGNED_CERT, SELF_SIGNED_KEY);
// Verify it's signed (or at least has signature data)
await verifyMcpbFile(testFile);
// Remove signature
expect(() => unsignMcpbFile(testFile)).not.toThrow();
// Verify it's unsigned
const afterResult = await verifyMcpbFile(testFile);
expect(afterResult.status).toBe("unsigned");
// Clean up
fs.unlinkSync(testFile);
}
describe("MCPB Signing E2E Tests", () => {
beforeAll(() => {
// Ensure test directory exists
if (!fs.existsSync(TEST_DIR)) {
fs.mkdirSync(TEST_DIR, { recursive: true });
}
// Setup
generateSelfSignedCert();
generateCASignedCert();
createTestDxt();
});
afterAll(() => {
// Cleanup
try {
fs.rmSync(TEST_DIR, { recursive: true, force: true });
} catch (e) {
// Ignore cleanup errors
}
});
it("should sign and verify with self-signed certificate", async () => {
await testSelfSignedSigning();
});
it("should sign and verify with CA-signed certificate", async () => {
await testCASignedSigning();
});
it("should detect tampering", async () => {
await testTamperingDetection();
});
it("should identify unsigned files", async () => {
await testUnsignedFile();
});
it("should remove signatures", async () => {
await testSignatureRemoval();
});
it("should update EOCD comment_length after signing", async () => {
const testFile = path.join(TEST_DIR, "test-eocd.mcpb");
fs.copyFileSync(TEST_MCPB, testFile);
// Read original EOCD comment_length
const originalContent = fs.readFileSync(testFile);
let eocdOffset = -1;
for (let i = originalContent.length - 22; i >= 0; i--) {
if (originalContent.readUInt32LE(i) === 0x06054b50) {
eocdOffset = i;
break;
}
}
expect(eocdOffset).toBeGreaterThanOrEqual(0);
const originalCommentLength = originalContent.readUInt16LE(eocdOffset + 20);
expect(originalCommentLength).toBe(0); // Fresh ZIP has no comment
// Sign the file
signMcpbFile(testFile, SELF_SIGNED_CERT, SELF_SIGNED_KEY);
// Read signed file and verify EOCD comment_length was updated
const signedContent = fs.readFileSync(testFile);
let signedEocdOffset = -1;
for (let i = signedContent.length - 22; i >= 0; i--) {
if (signedContent.readUInt32LE(i) === 0x06054b50) {
signedEocdOffset = i;
break;
}
}
expect(signedEocdOffset).toBeGreaterThanOrEqual(0);
const signedCommentLength = signedContent.readUInt16LE(
signedEocdOffset + 20,
);
// Comment length should equal everything after the EOCD record's original end
const eocdMinSize = 22; // minimum EOCD size (no comment)
const dataAfterEocd =
signedContent.length -
(signedEocdOffset + eocdMinSize + originalCommentLength);
expect(signedCommentLength).toBe(dataAfterEocd);
expect(signedCommentLength).toBeGreaterThan(0);
// Clean up
fs.unlinkSync(testFile);
});
});