Skip to content

Commit 77fc483

Browse files
andy-linerclaude
andcommitted
fix: make mcpb verify/info actually verify PKCS#7 signatures
`verifyMcpbFile()` called node-forge's `PkcsSignedData.verify()`, which is not implemented and always throws "PKCS#7 signature verification not yet implemented". The throw was caught and mapped to `status: "unsigned"`, so `mcpb verify` and `mcpb info` reported *every* signed bundle as unsigned, regardless of how it was signed. The `"self-signed"` status was also unreachable: the OS trust-store check returned "unsigned" before it. This implements the detached PKCS#7 verification manually: - the signed `messageDigest` attribute must equal SHA-256 of the content, and - the signer signature must validate over the DER-encoded authenticated attributes (re-tagged as a SET OF). Content matching also accounts for the EOCD `comment_length` bump that `signMcpbFile()` applies *after* signing (added in modelcontextprotocol#204): the stored content can differ from the signed content by those two bytes. Verification accepts a digest match against either the stored content or the comment_length-reversed content, so it works for bundles from both current and older signers (and never underflows the reversal when the comment_length was not bumped). Trust levels: OS-trusted chain -> "signed"; self-signed (issuer CN == subject CN) -> "self-signed"; valid signature but untrusted, non-self-signed chain -> "unsigned". The self-signed e2e test accepted both "self-signed" and "unsigned", so it never caught this; it now requires "self-signed". Adds a regression test for a signed bundle whose EOCD comment_length was not bumped. Related: modelcontextprotocol#195 (championed verify fix, native crypto; lacks the EOCD handling needed on current main), modelcontextprotocol#205, modelcontextprotocol#212. Fixes modelcontextprotocol#21. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 70fe3b3 commit 77fc483

2 files changed

Lines changed: 141 additions & 44 deletions

File tree

src/node/sign.ts

Lines changed: 97 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -137,16 +137,13 @@ export async function verifyMcpbFile(
137137
return { status: "unsigned" };
138138
}
139139

140-
// Now we know it's PkcsSignedData. The types are incorrect, so we'll
141-
// fix them there
140+
// node-forge's TS types omit `rawCapture`, which holds the parsed signer
141+
// fields we need for manual signature verification (see below).
142142
const p7 = p7Message as unknown as forge.pkcs7.PkcsSignedData & {
143-
signerInfos: Array<{
144-
authenticatedAttributes: Array<{
145-
type: string;
146-
value: unknown;
147-
}>;
148-
}>;
149-
verify: (options?: { authenticatedAttributes?: boolean }) => boolean;
143+
rawCapture: {
144+
authenticatedAttributes?: forge.asn1.Asn1[];
145+
signature?: string;
146+
};
150147
};
151148

152149
// Extract certificates from PKCS#7
@@ -158,37 +155,99 @@ export async function verifyMcpbFile(
158155
// Get the signing certificate (first one)
159156
const signingCert = certificates[0];
160157

161-
// Verify PKCS#7 signature
162-
const contentBuf = forge.util.createBuffer(originalContent);
158+
// Manually verify the PKCS#7 detached signature.
159+
//
160+
// node-forge does not implement `PkcsSignedData.verify()` — it throws
161+
// "PKCS#7 signature verification not yet implemented" — so calling it would
162+
// make every signed file report as unsigned. Instead we verify the signer
163+
// ourselves: (1) the signed `messageDigest` attribute must equal SHA-256 of
164+
// the content, and (2) the signature must validate over the DER-encoded
165+
// authenticated attributes (re-tagged as a SET OF, as PKCS#7 requires).
166+
const { authenticatedAttributes, signature: signerSignature } =
167+
p7.rawCapture;
168+
if (!authenticatedAttributes || !signerSignature) {
169+
return { status: "unsigned" };
170+
}
163171

164-
try {
165-
p7.verify({ authenticatedAttributes: true });
166-
167-
// Also verify the content matches
168-
const signerInfos = p7.signerInfos;
169-
const signerInfo = signerInfos?.[0];
170-
if (signerInfo) {
171-
const md = forge.md.sha256.create();
172-
md.update(contentBuf.getBytes());
173-
const digest = md.digest().getBytes();
174-
175-
// Find the message digest attribute
176-
let messageDigest = null;
177-
for (const attr of signerInfo.authenticatedAttributes) {
178-
if (attr.type === forge.pki.oids.messageDigest) {
179-
messageDigest = attr.value;
180-
break;
181-
}
182-
}
172+
// (1) content digest must match the signed messageDigest attribute.
173+
let signedMessageDigest: string | null = null;
174+
for (const attr of authenticatedAttributes) {
175+
const attrSeq = attr.value as forge.asn1.Asn1[];
176+
const attrOid = forge.asn1.derToOid(attrSeq[0].value as string);
177+
if (attrOid === forge.pki.oids.messageDigest) {
178+
signedMessageDigest = (attrSeq[1].value as forge.asn1.Asn1[])[0]
179+
.value as string;
180+
break;
181+
}
182+
}
183+
if (signedMessageDigest === null) {
184+
return { status: "unsigned" };
185+
}
183186

184-
if (!messageDigest || messageDigest !== digest) {
185-
return { status: "unsigned" };
186-
}
187+
// The signature covers the bytes before the signature block. Depending on
188+
// the version that produced the file, `signMcpbFile()` may have bumped the
189+
// ZIP EOCD comment_length by the signature-block length *after* signing
190+
// (added in #204) — so the stored bytes can differ from the signed bytes by
191+
// those two bytes. Accept a digest match against either the stored content
192+
// or the comment_length-reversed content.
193+
const sha256 = (buf: Buffer): string =>
194+
forge.md.sha256
195+
.create()
196+
.update(buf.toString("binary"))
197+
.digest()
198+
.getBytes();
199+
200+
const candidates: Buffer[] = [originalContent];
201+
const eocdOffset = findEocdOffset(originalContent);
202+
if (eocdOffset !== -1) {
203+
const sigBlockLength = fileContent.length - originalContent.length;
204+
const patchedCommentLength = originalContent.readUInt16LE(
205+
eocdOffset + 20,
206+
);
207+
if (patchedCommentLength >= sigBlockLength) {
208+
const reversed = Buffer.from(originalContent);
209+
reversed.writeUInt16LE(
210+
patchedCommentLength - sigBlockLength,
211+
eocdOffset + 20,
212+
);
213+
candidates.push(reversed);
187214
}
188-
} catch (error) {
215+
}
216+
217+
const contentMatches = candidates.some(
218+
(buf) => sha256(buf) === signedMessageDigest,
219+
);
220+
if (!contentMatches) {
189221
return { status: "unsigned" };
190222
}
191223

224+
// (2) signature must validate over the authenticated attributes
225+
const attrSet = forge.asn1.create(
226+
forge.asn1.Class.UNIVERSAL,
227+
forge.asn1.Type.SET,
228+
true,
229+
authenticatedAttributes,
230+
);
231+
const attrMd = forge.md.sha256.create();
232+
attrMd.update(forge.asn1.toDer(attrSet).getBytes());
233+
234+
let signatureValid = false;
235+
try {
236+
signatureValid = (
237+
signingCert.publicKey as forge.pki.rsa.PublicKey
238+
).verify(attrMd.digest().getBytes(), signerSignature);
239+
} catch {
240+
signatureValid = false;
241+
}
242+
if (!signatureValid) {
243+
return { status: "unsigned" };
244+
}
245+
246+
// The signature is cryptographically valid. Determine the trust level.
247+
const isSelfSigned =
248+
signingCert.issuer.getField("CN")?.value ===
249+
signingCert.subject.getField("CN")?.value;
250+
192251
// Convert forge certificate to PEM for OS verification
193252
const certPem = forge.pki.certificateToPem(signingCert);
194253
const intermediatePems = certificates
@@ -201,18 +260,14 @@ export async function verifyMcpbFile(
201260
intermediatePems,
202261
);
203262

204-
if (!chainValid) {
205-
// Signature is valid but certificate is not trusted
263+
// A valid signature whose chain is neither OS-trusted nor self-signed is
264+
// reported as unsigned, since we cannot attest to the publisher identity.
265+
if (!chainValid && !isSelfSigned) {
206266
return { status: "unsigned" };
207267
}
208268

209-
// Extract certificate info
210-
const isSelfSigned =
211-
signingCert.issuer.getField("CN")?.value ===
212-
signingCert.subject.getField("CN")?.value;
213-
214269
return {
215-
status: isSelfSigned ? "self-signed" : "signed",
270+
status: chainValid ? "signed" : "self-signed",
216271
publisher: signingCert.subject.getField("CN")?.value || "Unknown",
217272
issuer: signingCert.issuer.getField("CN")?.value || "Unknown",
218273
valid_from: signingCert.validity.notBefore.toISOString(),

test/sign.e2e.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -341,8 +341,13 @@ async function testSelfSignedSigning() {
341341
// Verify the signature
342342
const result = await verifyMcpbFile(testFile);
343343

344-
// Self-signed certs may not be trusted by OS, so we accept either status
345-
expect(["self-signed", "unsigned"]).toContain(result.status);
344+
// A validly self-signed file must be detected as "self-signed" (not
345+
// "unsigned"). This guards against the regression where verification relied
346+
// on node-forge's pkcs7.verify() — which is unimplemented and throws — making
347+
// every signed file appear unsigned, and left the "self-signed" status
348+
// unreachable behind the OS trust-store check.
349+
expect(result.status).toBe("self-signed");
350+
expect(result.publisher).toBe("Test MCPB Publisher");
346351

347352
// Clean up
348353
fs.unlinkSync(testFile);
@@ -427,6 +432,39 @@ async function testSignatureRemoval() {
427432
fs.unlinkSync(testFile);
428433
}
429434

435+
/**
436+
* Test a signed file whose EOCD comment_length was NOT bumped after signing
437+
* (as produced by signers predating the comment_length bump). The stored
438+
* content equals the signed content, so verification must match it directly —
439+
* and must not underflow the comment_length reversal.
440+
*/
441+
async function testSignedFileWithUnbumpedComment() {
442+
const testFile = path.join(TEST_DIR, "test-unbumped.dxt");
443+
fs.copyFileSync(TEST_MCPB, testFile);
444+
signMcpbFile(testFile, SELF_SIGNED_CERT, SELF_SIGNED_KEY);
445+
446+
// Zero the EOCD comment_length in the stored (pre-signature) content to mimic
447+
// a signer that did not bump it. The signature was computed over the original
448+
// content (comment_length 0), so this is the form such a signer would store.
449+
const buf = fs.readFileSync(testFile);
450+
const headerIndex = buf.indexOf(Buffer.from("MCPB_SIG_V1", "utf-8"));
451+
let eocd = -1;
452+
for (let i = headerIndex - 22; i >= 0; i--) {
453+
if (buf.readUInt32LE(i) === 0x06054b50) {
454+
eocd = i;
455+
break;
456+
}
457+
}
458+
expect(eocd).toBeGreaterThanOrEqual(0);
459+
buf.writeUInt16LE(0, eocd + 20);
460+
fs.writeFileSync(testFile, buf);
461+
462+
const result = await verifyMcpbFile(testFile);
463+
expect(result.status).toBe("self-signed");
464+
465+
fs.unlinkSync(testFile);
466+
}
467+
430468
describe("MCPB Signing E2E Tests", () => {
431469
beforeAll(() => {
432470
// Ensure test directory exists
@@ -469,6 +507,10 @@ describe("MCPB Signing E2E Tests", () => {
469507
await testSignatureRemoval();
470508
});
471509

510+
it("should verify a signed file whose EOCD comment_length was not bumped", async () => {
511+
await testSignedFileWithUnbumpedComment();
512+
});
513+
472514
it("should update EOCD comment_length after signing", async () => {
473515
const testFile = path.join(TEST_DIR, "test-eocd.mcpb");
474516
fs.copyFileSync(TEST_MCPB, testFile);

0 commit comments

Comments
 (0)