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>
517 lines
12 KiB
TypeScript
Executable file
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);
|
|
});
|
|
});
|