From c244fbe8aa05d2c54b56a2b3937066c8a28cf2e0 Mon Sep 17 00:00:00 2001 From: Atharva Bedekar Date: Sat, 31 Oct 2026 15:21:54 +0530 Subject: [PATCH] fix(validate-signature): flag weak timestamp imprints and ignore signer entries Treat an MD5 or SHA-1 messageImprint as an insecure digest and show the imprint algorithm in Technical Details. Ignore Reason, Location, ContactInfo and Name on document timestamps, as ISO 32000-2 Table 255 requires for ETSI.RFC3161. The insecure digest status could not be reached because such results are never valid, so these cards read "Invalid Signature". Show it in the invalid branch instead. --- src/js/logic/validate-signature-pdf-page.ts | 9 ++--- src/js/logic/validate-signature-pdf.ts | 20 +++++++--- src/js/types/validate-signature-type.ts | 1 + .../validate-signature-extraction.test.ts | 16 ++++++++ .../validate-signature-timestamp.test.ts | 38 ++++++++++++++----- translations/ui-source.json | 6 +++ 6 files changed, 70 insertions(+), 20 deletions(-) diff --git a/src/js/logic/validate-signature-pdf-page.ts b/src/js/logic/validate-signature-pdf-page.ts index 3f2229a9..bf9c16cf 100644 --- a/src/js/logic/validate-signature-pdf-page.ts +++ b/src/js/logic/validate-signature-pdf-page.ts @@ -392,12 +392,10 @@ function createSignatureCard( statusText = result.cryptoVerified === false ? trText('Invalid: Cryptographic Verification Failed') - : trText('Invalid Signature'); + : result.usesInsecureDigest + ? trText('Insecure Digest (MD5 / SHA-1)') + : trText('Invalid Signature'); } - } else if (result.usesInsecureDigest) { - statusColor = 'text-red-400'; - statusIcon = 'x-circle'; - statusText = trText('Insecure Digest (MD5 / SHA-1)'); } else if (result.isExpired) { statusColor = 'text-yellow-400'; statusIcon = 'alert-triangle'; @@ -519,6 +517,7 @@ function createSignatureCard(

${trHtml('Serial Number:')} ${escapeHtml(result.serialNumber)}

${trHtml('Digest Algorithm:')} ${escapeHtml(result.algorithms.digest)}

${trHtml('Signature Algorithm:')} ${escapeHtml(result.algorithms.signature)}

+ ${result.algorithms.imprint ? `

${trHtml('Timestamp Imprint Algorithm:')} ${escapeHtml(result.algorithms.imprint)}

` : ''} ${result.errorMessage ? `

${trHtml('Error: {{error}}', { error: result.errorMessage })}

` : ''} ${result.unsupportedAlgorithmReason ? `

${escapeHtml(result.unsupportedAlgorithmReason)}

` : ''} diff --git a/src/js/logic/validate-signature-pdf.ts b/src/js/logic/validate-signature-pdf.ts index 3ccb9384..e993947e 100644 --- a/src/js/logic/validate-signature-pdf.ts +++ b/src/js/logic/validate-signature-pdf.ts @@ -103,6 +103,9 @@ function readSignatureDictionary( isTimestamp: boolean ): ExtractedSignatureWithError { const text = (key: string) => decodePdfText(pdfBytes, entries.get(key)); + // A PDF processor should ignore these on ETSI.RFC3161 dictionaries (ISO + // 32000-2, Table 255) + const signerText = (key: string) => (isTimestamp ? undefined : text(key)); const timeMatch = text('M')?.match( /^D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/ ); @@ -110,10 +113,10 @@ function readSignatureDictionary( index, contents: new Uint8Array(0), byteRange: [], - reason: text('Reason'), - location: text('Location'), - contactInfo: text('ContactInfo'), - name: text('Name'), + reason: signerText('Reason'), + location: signerText('Location'), + contactInfo: signerText('ContactInfo'), + name: signerText('Name'), signingTime: timeMatch ? `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}` : undefined, @@ -442,11 +445,17 @@ export async function validateSignature( (digestOid && getDigestAlgorithmName(digestOid)) || getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''), signature: getSignatureAlgorithmName(signerCert.signatureOid || ''), + imprint: timestamp + ? getDigestAlgorithmName(timestamp.imprintOid) + : undefined, }; if (digestOid && INSECURE_DIGEST_OIDS.has(digestOid)) { result.usesInsecureDigest = true; } + if (timestamp && INSECURE_DIGEST_OIDS.has(timestamp.imprintOid)) { + result.usesInsecureDigest = true; + } // Parse signing time if available in signature if (timestamp?.genTime) { @@ -666,8 +675,7 @@ interface TimestampToken { /** * Reads the TSTInfo out of an RFC 3161 timestamp token and relabels its * encapsulated content as id-data, because node-forge only parses SignedData - * that wraps plain data. Only the label changes: the signature covers the - * signed attributes, which keep the real content type. + * that wraps plain data. */ function unwrapTimestampToken( contentInfo: forge.asn1.Asn1 diff --git a/src/js/types/validate-signature-type.ts b/src/js/types/validate-signature-type.ts index 4b2088d8..fc6de2ac 100644 --- a/src/js/types/validate-signature-type.ts +++ b/src/js/types/validate-signature-type.ts @@ -17,6 +17,7 @@ export interface SignatureValidationResult { algorithms: { digest: string; signature: string; + imprint?: string; }; serialNumber: string; reason?: string; diff --git a/src/tests/validate-signature-extraction.test.ts b/src/tests/validate-signature-extraction.test.ts index 04037947..ba15b632 100644 --- a/src/tests/validate-signature-extraction.test.ts +++ b/src/tests/validate-signature-extraction.test.ts @@ -188,6 +188,22 @@ describe('validatePdfSignatures extraction and coverage', () => { expect(signatures.every((sig) => sig.isTimestamp)).toBe(true); }); + it('ignores Reason, Location, ContactInfo and Name on document timestamps', () => { + const pdfBytes = new TextEncoder().encode( + '%PDF-1.7\n' + + '1 0 obj\n<< /Type /DocTimeStamp /SubFilter /ETSI.RFC3161 ' + + '/Reason (r) /Location (l) /ContactInfo (c) /Name (n) ' + + '/ByteRange [0 10 20 10] /Contents <00> >>\nendobj\n%%EOF\n' + ); + + const [signature] = extractSignatures(pdfBytes); + expect(signature.isTimestamp).toBe(true); + expect(signature.reason).toBeUndefined(); + expect(signature.location).toBeUndefined(); + expect(signature.contactInfo).toBeUndefined(); + expect(signature.name).toBeUndefined(); + }); + it('reports an intact signature as partial when an incremental update follows', async () => { const signed = await signPdf(samplePdf, certificateData, { signatureInfo: { reason: 'Original' }, diff --git a/src/tests/validate-signature-timestamp.test.ts b/src/tests/validate-signature-timestamp.test.ts index 15860b11..5a2e4fad 100644 --- a/src/tests/validate-signature-timestamp.test.ts +++ b/src/tests/validate-signature-timestamp.test.ts @@ -26,17 +26,14 @@ const node = (type: number, value: string | forge.asn1.Asn1[]) => asn1.create(asn1.Class.UNIVERSAL, type, Array.isArray(value), value); const oid = (id: string) => node(asn1.Type.OID, asn1.oidToDer(id).getBytes()); -function tstInfoDer(imprint: string): string { +function tstInfoDer(imprint: string, imprintOid: string): string { return asn1 .toDer( node(asn1.Type.SEQUENCE, [ node(asn1.Type.INTEGER, asn1.integerToDer(1).getBytes()), oid('1.2.3.4.1'), node(asn1.Type.SEQUENCE, [ - node(asn1.Type.SEQUENCE, [ - oid(forge.pki.oids.sha256), - node(asn1.Type.NULL, ''), - ]), + node(asn1.Type.SEQUENCE, [oid(imprintOid), node(asn1.Type.NULL, '')]), node(asn1.Type.OCTETSTRING, imprint), ]), node(asn1.Type.INTEGER, asn1.integerToDer(42).getBytes()), @@ -47,9 +44,9 @@ function tstInfoDer(imprint: string): string { } /** An RFC 3161 token whose TSTInfo carries the given document hash. */ -function timestampTokenHex(imprint: string): string { +function timestampTokenHex(imprint: string, imprintOid: string): string { const p7 = forge.pkcs7.createSignedData(); - p7.content = forge.util.createBuffer(tstInfoDer(imprint)); + p7.content = forge.util.createBuffer(tstInfoDer(imprint, imprintOid)); p7.addCertificate(tsaCert); p7.addSigner({ key: keys.privateKey, @@ -78,7 +75,8 @@ function sha256(content: string): string { } function buildTimestampedPdf( - imprintFor: (signedContent: string) => string = sha256 + imprintFor: (signedContent: string) => string = sha256, + imprintOid: string = forge.pki.oids.sha256 ): Uint8Array { const headLength = HEAD.length - RANGE_PLACEHOLDER.length + RANGE_WIDTH; const gapLength = HEX_LENGTH + 2; @@ -87,7 +85,7 @@ function buildTimestampedPdf( RANGE_PLACEHOLDER, `/ByteRange [${byteRange.join(' ')}]`.padEnd(RANGE_WIDTH) ); - const hex = timestampTokenHex(imprintFor(head + TAIL)).padEnd( + const hex = timestampTokenHex(imprintFor(head + TAIL), imprintOid).padEnd( HEX_LENGTH, '0' ); @@ -102,12 +100,34 @@ describe('document timestamps (RFC 3161)', () => { expect(result.cryptoVerificationStatus).toBe('verified'); expect(result.coverageStatus).toBe('full'); expect(result.isValid).toBe(true); + expect(result.usesInsecureDigest).toBeFalsy(); + expect(result.algorithms.imprint).toBe('SHA-256'); expect(result.signerName).toBe('Test TSA'); expect(result.signatureDate?.toISOString()).toBe( '2026-01-02T03:04:05.000Z' ); }); + it.each([ + ['SHA-1', forge.md.sha1, forge.pki.oids.sha1], + ['MD5', forge.md.md5, forge.pki.oids.md5], + ])('treats a %s messageImprint as insecure', async (name, algorithm, id) => { + const imprintFor = (content: string) => { + const md = algorithm.create(); + md.update(content); + return md.digest().bytes(); + }; + + const [result] = await validatePdfSignatures( + buildTimestampedPdf(imprintFor, id) + ); + + expect(result.cryptoVerificationStatus).toBe('verified'); + expect(result.usesInsecureDigest).toBe(true); + expect(result.isValid).toBe(false); + expect(result.algorithms.imprint).toBe(name); + }); + it('fails when the document no longer matches the timestamped hash', async () => { const pdfBytes = buildTimestampedPdf(); // Rename /Adobe.PPKLite to /Adobe.QPKLite: still a valid PDF, new bytes diff --git a/translations/ui-source.json b/translations/ui-source.json index 8d6f5b77..541d4d60 100644 --- a/translations/ui-source.json +++ b/translations/ui-source.json @@ -9001,6 +9001,12 @@ ], "fileCount": 1 }, + "Timestamp Imprint Algorithm:": { + "files": [ + "logic/validate-signature-pdf-page.ts" + ], + "fileCount": 1 + }, "Timestamp Intact: Timestamp Authority Not Verified": { "files": [ "logic/validate-signature-pdf-page.ts"