diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 817b659..d42cd8e 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -6,7 +6,14 @@ 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 { + applyExternalSignature, + MAX_SIG_BLOCK_SIZE, + prepareForExternalSigning, + signMcpbFile, + unsignMcpbFile, + verifyMcpbFile, +} from "../node/sign.js"; import { cleanMcpb, validateManifest } from "../node/validate.js"; import { initExtension } from "./init.js"; import { packExtension } from "./pack.js"; @@ -355,5 +362,91 @@ program } }); +// Prepare-for-signing command (external/enterprise signing workflow) +program + .command("prepare-for-signing ") + .description( + "Prepare an MCPB file for external signing (GaraSign, ESRP, SignServer, etc.)", + ) + .option("-o, --output ", "Output path (default: overwrite input)") + .action( + (mcpbFile: string, options: { output?: string }) => { + try { + const mcpbPath = resolve(mcpbFile); + + if (!existsSync(mcpbPath)) { + console.error(`ERROR: MCPB file not found: ${mcpbFile}`); + process.exit(1); + } + + prepareForExternalSigning(mcpbPath, options.output); + + const target = options.output + ? basename(options.output) + : basename(mcpbPath); + console.log( + `Prepared ${target} for external signing (EOCD comment_length set to ${MAX_SIG_BLOCK_SIZE})`, + ); + console.log( + `\nNext steps:\n` + + ` 1. Sign the prepared file with your HSM/signing tool (detached PKCS#7, DER format)\n` + + ` 2. Run: mcpb apply-signature ${mcpbFile} --signature `, + ); + } catch (error) { + console.error( + `ERROR: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + process.exit(1); + } + }, + ); + +// Apply-signature command (external/enterprise signing workflow) +program + .command("apply-signature ") + .description( + "Apply a detached PKCS#7 signature to a prepared MCPB file", + ) + .requiredOption( + "-s, --signature ", + "Path to detached PKCS#7 signature file (.p7s, DER format)", + ) + .option("-o, --output ", "Output path (default: overwrite input)") + .action( + (mcpbFile: string, options: { signature: string; output?: string }) => { + try { + const mcpbPath = resolve(mcpbFile); + const sigPath = resolve(options.signature); + + if (!existsSync(mcpbPath)) { + console.error(`ERROR: MCPB file not found: ${mcpbFile}`); + process.exit(1); + } + + if (!existsSync(sigPath)) { + console.error( + `ERROR: Signature file not found: ${options.signature}`, + ); + process.exit(1); + } + + const sigSize = statSync(sigPath).size; + applyExternalSignature(mcpbPath, sigPath, options.output); + + const target = options.output + ? basename(options.output) + : basename(mcpbPath); + console.log( + `Applied signature to ${target} (${sigSize} byte PKCS#7, padded to ${MAX_SIG_BLOCK_SIZE})`, + ); + } catch (error) { + console.error( + `ERROR: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + process.exit(1); + } + }, + ); + // Parse command line arguments program.parse(); diff --git a/src/node/sign.ts b/src/node/sign.ts index bd4c8c4..c603ba2 100644 --- a/src/node/sign.ts +++ b/src/node/sign.ts @@ -13,6 +13,21 @@ import type { McpbSignatureInfoSchema } from "../shared/common.js"; const SIGNATURE_HEADER = "MCPB_SIG_V1"; const SIGNATURE_FOOTER = "MCPB_SIG_END"; +/** + * Maximum signature block size in bytes. The ZIP EOCD comment length is set to + * this value during prepare-for-signing so that the final signed file remains a + * valid ZIP archive. The signature block (MCPB_SIG_V1 + length + signature + + * padding + MCPB_SIG_END) is padded with zeros to exactly this size. + * + * Matches Microsoft's Azure MCP Server pipeline (Stage-McpbForSigning.ps1). + * Current enterprise signatures are ~4KB; 16384 provides ample headroom. + */ +export const MAX_SIG_BLOCK_SIZE = 16384; + +/** Overhead bytes in signature block: MCPB_SIG_V1 (11) + length (4) + MCPB_SIG_END (12) */ +const SIG_BLOCK_OVERHEAD = + Buffer.byteLength(SIGNATURE_HEADER) + 4 + Buffer.byteLength(SIGNATURE_FOOTER); + const execFileAsync = promisify(execFile); /** @@ -426,6 +441,130 @@ export async function verifyCertificateChain( } } +/** + * Prepares an unsigned MCPB file for external/enterprise signing. + * + * Sets the ZIP EOCD comment_length to MAX_SIG_BLOCK_SIZE so that after the + * signature block is appended (and padded to this exact size), the file remains + * a valid ZIP. The external signer (GaraSign, ESRP, SignServer, etc.) signs + * this prepared content, so `mcpb verify` works because the "original content" + * extracted during verification matches what was signed. + * + * Based on Microsoft's Azure MCP Server pipeline (Stage-McpbForSigning.ps1). + * + * @param mcpbPath Path to the unsigned MCPB file + * @param outputPath Optional output path (defaults to overwriting input) + */ +export function prepareForExternalSigning( + mcpbPath: string, + outputPath?: string, +): void { + const content = Buffer.from(readFileSync(mcpbPath)); + + // Reject if already signed + const footerBytes = Buffer.from(SIGNATURE_FOOTER, "utf-8"); + if (content.length >= footerBytes.length) { + const tail = content.slice(content.length - footerBytes.length); + if (tail.equals(footerBytes)) { + throw new Error("MCPB file is already signed. Use 'mcpb unsign' first."); + } + } + + // Find EOCD + const eocdOffset = findEocdOffset(content); + if (eocdOffset === -1) { + throw new Error("ZIP End of Central Directory not found — not a valid MCPB file."); + } + + // Reject if already prepared + const currentCommentLength = content.readUInt16LE(eocdOffset + 20); + if (currentCommentLength === MAX_SIG_BLOCK_SIZE) { + throw new Error("MCPB file is already prepared for external signing."); + } + + // Set EOCD comment_length to MAX_SIG_BLOCK_SIZE + content.writeUInt16LE(MAX_SIG_BLOCK_SIZE, eocdOffset + 20); + + writeFileSync(outputPath || mcpbPath, content); +} + +/** + * Applies a detached PKCS#7 signature to a prepared MCPB file. + * + * The MCPB must have been prepared with `prepareForExternalSigning()` first, + * which sets the EOCD comment_length to MAX_SIG_BLOCK_SIZE. The signature + * block is padded with zeros to exactly MAX_SIG_BLOCK_SIZE so the resulting + * file is a valid ZIP with comment_length matching the actual trailing data. + * + * Based on Microsoft's Azure MCP Server pipeline (Apply-McpbSignatures.ps1). + * + * @param mcpbPath Path to the prepared MCPB file + * @param signaturePath Path to the detached PKCS#7 signature file (.p7s, DER format) + * @param outputPath Optional output path (defaults to overwriting input) + */ +export function applyExternalSignature( + mcpbPath: string, + signaturePath: string, + outputPath?: string, +): void { + const mcpbContent = readFileSync(mcpbPath); + const signatureBytes = readFileSync(signaturePath); + + // Reject if already signed + const footerBytes = Buffer.from(SIGNATURE_FOOTER, "utf-8"); + if (mcpbContent.length >= footerBytes.length) { + const tail = mcpbContent.slice(mcpbContent.length - footerBytes.length); + if (tail.equals(footerBytes)) { + throw new Error( + "MCPB file is already signed. Use 'mcpb unsign' to remove the existing signature first.", + ); + } + } + + // Verify the bundle was prepared (EOCD comment_length == MAX_SIG_BLOCK_SIZE) + const eocdOffset = findEocdOffset(mcpbContent); + if (eocdOffset === -1) { + throw new Error("ZIP End of Central Directory not found — not a valid MCPB file."); + } + + const commentLength = mcpbContent.readUInt16LE(eocdOffset + 20); + if (commentLength !== MAX_SIG_BLOCK_SIZE) { + throw new Error( + `MCPB file was not prepared for external signing (comment_length is ${commentLength}, expected ${MAX_SIG_BLOCK_SIZE}). ` + + "Run 'mcpb prepare-for-signing' first.", + ); + } + + // Reject oversized signatures + if (signatureBytes.length + SIG_BLOCK_OVERHEAD > MAX_SIG_BLOCK_SIZE) { + throw new Error( + `Signature is too large (${signatureBytes.length} bytes). ` + + `Maximum signature size is ${MAX_SIG_BLOCK_SIZE - SIG_BLOCK_OVERHEAD} bytes.`, + ); + } + + // Build padded signature block + const headerBuf = Buffer.from(SIGNATURE_HEADER, "utf-8"); + const footerBuf = Buffer.from(SIGNATURE_FOOTER, "utf-8"); + const lengthBuf = Buffer.alloc(4); + lengthBuf.writeUInt32LE(signatureBytes.length, 0); + + const paddingSize = + MAX_SIG_BLOCK_SIZE - (headerBuf.length + 4 + signatureBytes.length + footerBuf.length); + const paddingBuf = Buffer.alloc(paddingSize, 0); + + const signedContent = Buffer.concat([ + mcpbContent, + headerBuf, + lengthBuf, + signatureBytes, + paddingBuf, + footerBuf, + ]); + + writeFileSync(outputPath || mcpbPath, signedContent); +} + /** * Removes signature from a MCPB file */ diff --git a/test/sign.e2e.test.ts b/test/sign.e2e.test.ts index 9af52a0..025a9ce 100755 --- a/test/sign.e2e.test.ts +++ b/test/sign.e2e.test.ts @@ -14,6 +14,9 @@ import forge from "node-forge"; import * as path from "path"; import { + applyExternalSignature, + MAX_SIG_BLOCK_SIZE, + prepareForExternalSigning, signMcpbFile, unsignMcpbFile, verifyMcpbFile, @@ -515,3 +518,286 @@ describe("MCPB Signing E2E Tests", () => { fs.unlinkSync(testFile); }); }); + +/** + * Helper: create a detached PKCS#7 signature using node-forge. + * Simulates what an enterprise HSM (GaraSign, ESRP, etc.) produces. + */ +function createDetachedSignature( + content: Buffer, + certPath: string, + keyPath: string, +): Buffer { + const certPem = fs.readFileSync(certPath, "utf-8"); + const keyPem = fs.readFileSync(keyPath, "utf-8"); + + const cert = forge.pki.certificateFromPem(certPem); + const key = forge.pki.privateKeyFromPem(keyPem); + + const p7 = forge.pkcs7.createSignedData(); + p7.content = forge.util.createBuffer(content); + p7.addCertificate(cert); + p7.addSigner({ + key, + certificate: cert, + digestAlgorithm: forge.pki.oids.sha256, + authenticatedAttributes: [ + { type: forge.pki.oids.contentType, value: forge.pki.oids.data }, + { type: forge.pki.oids.messageDigest }, + { type: forge.pki.oids.signingTime }, + ], + }); + p7.sign({ detached: true }); + + const asn1 = forge.asn1.toDer(p7.toAsn1()); + return Buffer.from(asn1.getBytes(), "binary"); +} + +describe("External Signing E2E Tests", () => { + const EXT_TEST_DIR = path.join(__dirname, "test-output-external"); + + beforeAll(() => { + if (!fs.existsSync(EXT_TEST_DIR)) { + fs.mkdirSync(EXT_TEST_DIR, { recursive: true }); + } + // Ensure the shared test-output dir exists for certs and test MCPB + if (!fs.existsSync(TEST_DIR)) { + fs.mkdirSync(TEST_DIR, { recursive: true }); + } + // Generate certs and test MCPB if not already present + if (!fs.existsSync(SELF_SIGNED_CERT)) { + generateSelfSignedCert(); + } + if (!fs.existsSync(TEST_MCPB)) { + createTestDxt(); + } + }); + + afterAll(() => { + try { + fs.rmSync(EXT_TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it("should complete prepare → sign → apply roundtrip", () => { + const mcpbFile = path.join(EXT_TEST_DIR, "roundtrip.mcpb"); + const sigFile = path.join(EXT_TEST_DIR, "roundtrip.p7s"); + fs.copyFileSync(TEST_MCPB, mcpbFile); + + // Step 1: Prepare + prepareForExternalSigning(mcpbFile); + + // Verify EOCD comment_length was set + const prepared = fs.readFileSync(mcpbFile); + let eocdOffset = -1; + for (let i = prepared.length - 22; i >= 0; i--) { + if (prepared.readUInt32LE(i) === 0x06054b50) { + eocdOffset = i; + break; + } + } + expect(eocdOffset).toBeGreaterThanOrEqual(0); + expect(prepared.readUInt16LE(eocdOffset + 20)).toBe(MAX_SIG_BLOCK_SIZE); + + // Step 2: Create detached signature (simulates enterprise HSM) + const sigBytes = createDetachedSignature( + prepared, + SELF_SIGNED_CERT, + SELF_SIGNED_KEY, + ); + fs.writeFileSync(sigFile, sigBytes); + + // Step 3: Apply signature + applyExternalSignature(mcpbFile, sigFile); + + // Verify result + const signed = fs.readFileSync(mcpbFile); + + // File should end with MCPB_SIG_END + const footer = signed + .slice(signed.length - Buffer.byteLength("MCPB_SIG_END")) + .toString("utf-8"); + expect(footer).toBe("MCPB_SIG_END"); + + // Total appended block should be exactly MAX_SIG_BLOCK_SIZE + const appendedSize = signed.length - prepared.length; + expect(appendedSize).toBe(MAX_SIG_BLOCK_SIZE); + + // EOCD comment_length should still be MAX_SIG_BLOCK_SIZE + let signedEocdOffset = -1; + for (let i = prepared.length - 22; i >= 0; i--) { + if (signed.readUInt32LE(i) === 0x06054b50) { + signedEocdOffset = i; + break; + } + } + expect(signedEocdOffset).toBeGreaterThanOrEqual(0); + expect(signed.readUInt16LE(signedEocdOffset + 20)).toBe(MAX_SIG_BLOCK_SIZE); + + // adm-zip validation: file_size == eocd_offset + 22 + comment_length + expect(signed.length).toBe(signedEocdOffset + 22 + MAX_SIG_BLOCK_SIZE); + }); + + it("should reject already-signed bundles in prepare", () => { + const mcpbFile = path.join(EXT_TEST_DIR, "already-signed.mcpb"); + fs.copyFileSync(TEST_MCPB, mcpbFile); + + // Sign it normally + signMcpbFile(mcpbFile, SELF_SIGNED_CERT, SELF_SIGNED_KEY); + + // Prepare should reject + expect(() => prepareForExternalSigning(mcpbFile)).toThrow( + "already signed", + ); + }); + + it("should reject already-prepared bundles in prepare", () => { + const mcpbFile = path.join(EXT_TEST_DIR, "already-prepared.mcpb"); + fs.copyFileSync(TEST_MCPB, mcpbFile); + + // Prepare once + prepareForExternalSigning(mcpbFile); + + // Prepare again should reject + expect(() => prepareForExternalSigning(mcpbFile)).toThrow( + "already prepared", + ); + }); + + it("should reject unprepared bundles in apply-signature", () => { + const mcpbFile = path.join(EXT_TEST_DIR, "unprepared.mcpb"); + const sigFile = path.join(EXT_TEST_DIR, "dummy.p7s"); + fs.copyFileSync(TEST_MCPB, mcpbFile); + fs.writeFileSync(sigFile, Buffer.alloc(100)); // dummy signature + + expect(() => applyExternalSignature(mcpbFile, sigFile)).toThrow( + "not prepared for external signing", + ); + }); + + it("should reject oversized signatures", () => { + const mcpbFile = path.join(EXT_TEST_DIR, "oversized.mcpb"); + const sigFile = path.join(EXT_TEST_DIR, "oversized.p7s"); + fs.copyFileSync(TEST_MCPB, mcpbFile); + + // Prepare + prepareForExternalSigning(mcpbFile); + + // Create a signature larger than MAX_SIG_BLOCK_SIZE + fs.writeFileSync(sigFile, Buffer.alloc(MAX_SIG_BLOCK_SIZE)); + + expect(() => applyExternalSignature(mcpbFile, sigFile)).toThrow( + "too large", + ); + }); + + it("should produce correct padding", () => { + const mcpbFile = path.join(EXT_TEST_DIR, "padding.mcpb"); + const sigFile = path.join(EXT_TEST_DIR, "padding.p7s"); + fs.copyFileSync(TEST_MCPB, mcpbFile); + + prepareForExternalSigning(mcpbFile); + const preparedSize = fs.statSync(mcpbFile).size; + + // Create detached signature + const prepared = fs.readFileSync(mcpbFile); + const sigBytes = createDetachedSignature( + prepared, + SELF_SIGNED_CERT, + SELF_SIGNED_KEY, + ); + fs.writeFileSync(sigFile, sigBytes); + + applyExternalSignature(mcpbFile, sigFile); + + const signed = fs.readFileSync(mcpbFile); + const sigBlock = signed.slice(preparedSize); + + // Total block size must be exactly MAX_SIG_BLOCK_SIZE + expect(sigBlock.length).toBe(MAX_SIG_BLOCK_SIZE); + + // Parse the block: header (11) + length (4) + sig (N) + padding + footer (12) + const header = sigBlock.slice(0, 11).toString("utf-8"); + expect(header).toBe("MCPB_SIG_V1"); + + const sigLen = sigBlock.readUInt32LE(11); + expect(sigLen).toBe(sigBytes.length); + + const extractedSig = sigBlock.slice(15, 15 + sigLen); + expect(extractedSig.equals(sigBytes)).toBe(true); + + // Padding should be all zeros + const paddingStart = 15 + sigLen; + const paddingEnd = sigBlock.length - 12; // before MCPB_SIG_END + const padding = sigBlock.slice(paddingStart, paddingEnd); + expect(padding.every((b) => b === 0)).toBe(true); + + const endMarker = sigBlock.slice(sigBlock.length - 12).toString("utf-8"); + expect(endMarker).toBe("MCPB_SIG_END"); + }); + + it("should satisfy adm-zip strict validation", () => { + const mcpbFile = path.join(EXT_TEST_DIR, "admzip.mcpb"); + const sigFile = path.join(EXT_TEST_DIR, "admzip.p7s"); + fs.copyFileSync(TEST_MCPB, mcpbFile); + + prepareForExternalSigning(mcpbFile); + const prepared = fs.readFileSync(mcpbFile); + const sigBytes = createDetachedSignature( + prepared, + SELF_SIGNED_CERT, + SELF_SIGNED_KEY, + ); + fs.writeFileSync(sigFile, sigBytes); + applyExternalSignature(mcpbFile, sigFile); + + const signed = fs.readFileSync(mcpbFile); + + // Find EOCD in signed file + let eocdOffset = -1; + for (let i = signed.length - 22; i >= 0; i--) { + if (signed.readUInt32LE(i) === 0x06054b50) { + eocdOffset = i; + break; + } + } + expect(eocdOffset).toBeGreaterThanOrEqual(0); + + const commentLength = signed.readUInt16LE(eocdOffset + 20); + + // This is the exact check adm-zip performs: + // data.length - Constants.ENDHDR !== commentLength + // where ENDHDR starts at eocdOffset, and is 22 bytes long + expect(signed.length).toBe(eocdOffset + 22 + commentLength); + }); + + it("should write to output path when specified", () => { + const inputFile = path.join(EXT_TEST_DIR, "output-input.mcpb"); + const outputFile = path.join(EXT_TEST_DIR, "output-prepared.mcpb"); + fs.copyFileSync(TEST_MCPB, inputFile); + + // Prepare with output path — input should not be modified + const originalContent = fs.readFileSync(inputFile); + prepareForExternalSigning(inputFile, outputFile); + + // Input unchanged + const afterContent = fs.readFileSync(inputFile); + expect(afterContent.equals(originalContent)).toBe(true); + + // Output was created and has updated EOCD + expect(fs.existsSync(outputFile)).toBe(true); + const preparedContent = fs.readFileSync(outputFile); + let eocdOffset = -1; + for (let i = preparedContent.length - 22; i >= 0; i--) { + if (preparedContent.readUInt32LE(i) === 0x06054b50) { + eocdOffset = i; + break; + } + } + expect(preparedContent.readUInt16LE(eocdOffset + 20)).toBe( + MAX_SIG_BLOCK_SIZE, + ); + }); +});