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"