Skip to content

Commit 622ff31

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). It also reverses the EOCD `comment_length` patch that `signMcpbFile()` applies *after* signing (added in #204): the stored content differs from the signed content by those two bytes, so verification must restore them before hashing or the digest never matches for bundles produced by `mcpb sign`. 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". Related: #195 (championed verify fix, native crypto; lacks the EOCD reversal needed on current main), #205, #212. Fixes #21. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 70fe3b3 commit 622ff31

2 files changed

Lines changed: 89 additions & 44 deletions

File tree

src/node/sign.ts

Lines changed: 82 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,84 @@ 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+
//
174+
// signMcpbFile() increases the ZIP EOCD comment_length by the signature
175+
// block length *after* computing the signature, so the stored content
176+
// differs from what was signed by those two bytes. Reverse that patch to
177+
// recover the exact bytes the signature covers before hashing.
178+
const signedContentBytes = Buffer.from(originalContent);
179+
const eocdOffset = findEocdOffset(signedContentBytes);
180+
if (eocdOffset !== -1) {
181+
const sigBlockLength = fileContent.length - originalContent.length;
182+
const patchedCommentLength = signedContentBytes.readUInt16LE(
183+
eocdOffset + 20,
184+
);
185+
signedContentBytes.writeUInt16LE(
186+
patchedCommentLength - sigBlockLength,
187+
eocdOffset + 20,
188+
);
189+
}
183190

184-
if (!messageDigest || messageDigest !== digest) {
185-
return { status: "unsigned" };
186-
}
191+
const contentMd = forge.md.sha256.create();
192+
contentMd.update(signedContentBytes.toString("binary"));
193+
const contentDigest = contentMd.digest().getBytes();
194+
195+
let signedMessageDigest: string | null = null;
196+
for (const attr of authenticatedAttributes) {
197+
const attrSeq = attr.value as forge.asn1.Asn1[];
198+
const attrOid = forge.asn1.derToOid(attrSeq[0].value as string);
199+
if (attrOid === forge.pki.oids.messageDigest) {
200+
signedMessageDigest = (attrSeq[1].value as forge.asn1.Asn1[])[0]
201+
.value as string;
202+
break;
187203
}
188-
} catch (error) {
204+
}
205+
if (signedMessageDigest === null || signedMessageDigest !== contentDigest) {
189206
return { status: "unsigned" };
190207
}
191208

209+
// (2) signature must validate over the authenticated attributes
210+
const attrSet = forge.asn1.create(
211+
forge.asn1.Class.UNIVERSAL,
212+
forge.asn1.Type.SET,
213+
true,
214+
authenticatedAttributes,
215+
);
216+
const attrMd = forge.md.sha256.create();
217+
attrMd.update(forge.asn1.toDer(attrSet).getBytes());
218+
219+
let signatureValid = false;
220+
try {
221+
signatureValid = (
222+
signingCert.publicKey as forge.pki.rsa.PublicKey
223+
).verify(attrMd.digest().getBytes(), signerSignature);
224+
} catch {
225+
signatureValid = false;
226+
}
227+
if (!signatureValid) {
228+
return { status: "unsigned" };
229+
}
230+
231+
// The signature is cryptographically valid. Determine the trust level.
232+
const isSelfSigned =
233+
signingCert.issuer.getField("CN")?.value ===
234+
signingCert.subject.getField("CN")?.value;
235+
192236
// Convert forge certificate to PEM for OS verification
193237
const certPem = forge.pki.certificateToPem(signingCert);
194238
const intermediatePems = certificates
@@ -201,18 +245,14 @@ export async function verifyMcpbFile(
201245
intermediatePems,
202246
);
203247

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

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

test/sign.e2e.test.ts

Lines changed: 7 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);

0 commit comments

Comments
 (0)