diff --git a/src/js/logic/validate-signature-pdf.ts b/src/js/logic/validate-signature-pdf.ts index f6192132..cb77ca51 100644 --- a/src/js/logic/validate-signature-pdf.ts +++ b/src/js/logic/validate-signature-pdf.ts @@ -26,7 +26,7 @@ export function extractSignatures( const signatures: ExtractedSignatureWithError[] = []; const pdfString = new TextDecoder('latin1').decode(pdfBytes); - // Find all signature objects for /Type /Sig + // Find all signature dictionaries (see isSignatureDictionary) const objectHeader = /\d+\s+\d+\s+obj\b/g; let endobj = -1; @@ -48,8 +48,7 @@ export function extractSignatures( if (end < 0) continue; for (const entries of dictionaries) { - const type = entries.get('Type'); - if (type && pdfString.slice(type.start, type.end) === '/Sig') { + if (isSignatureDictionary(pdfString, entries)) { signatures.push( readSignatureDictionary( pdfString, @@ -65,6 +64,30 @@ export function extractSignatures( return signatures; } +/** + * /Type is optional in a signature dictionary (PDF 32000-1, 12.8.1, Table + * 252), and some signers leave it out, so a dictionary without /Type that + * carries both /ByteRange and /Contents is a signature too. Document + * timestamps (/Type /DocTimeStamp, /SubFilter /ETSI.RFC3161) hold an RFC 3161 + * token rather than a CMS signature over the document and stay out. + */ +function isSignatureDictionary( + pdfString: string, + entries: Map +): boolean { + const name = (key: string) => { + const span = entries.get(key); + return span ? pdfString.slice(span.start, span.end) : undefined; + }; + const type = name('Type'); + if (type !== undefined) return type === '/Sig'; + return ( + entries.has('ByteRange') && + entries.has('Contents') && + name('SubFilter') !== '/ETSI.RFC3161' + ); +} + function readSignatureDictionary( pdfString: string, pdfBytes: Uint8Array, diff --git a/src/tests/validate-signature-extraction.test.ts b/src/tests/validate-signature-extraction.test.ts index d3f34dcb..fac50880 100644 --- a/src/tests/validate-signature-extraction.test.ts +++ b/src/tests/validate-signature-extraction.test.ts @@ -59,11 +59,13 @@ function buildSignedPdf(layout: { head: string; gap: (hex: string) => string; tail: string; + hexLength?: number; }): Uint8Array { const prefix = layout.prefix ?? ''; + const hexLength = layout.hexLength ?? HEX_LENGTH; const headLength = layout.head.length - RANGE_PLACEHOLDER.length + RANGE_WIDTH; - const gapLength = layout.gap('0'.repeat(HEX_LENGTH)).length; + const gapLength = layout.gap('0'.repeat(hexLength)).length; const byteRange = [ prefix.length, headLength, @@ -74,7 +76,7 @@ function buildSignedPdf(layout: { RANGE_PLACEHOLDER, `/ByteRange [${byteRange.join(' ')}]`.padEnd(RANGE_WIDTH) ); - const hex = signedCmsHex(head + layout.tail).padEnd(HEX_LENGTH, '0'); + const hex = signedCmsHex(head + layout.tail).padEnd(hexLength, '0'); return new TextEncoder().encode( prefix + head + layout.gap(hex) + layout.tail ); @@ -130,6 +132,50 @@ describe('validatePdfSignatures extraction and coverage', () => { expect(extractSignatures(pdfBytes)[0].name).toBe('Real Signer'); }); + it('validates a signature whose /Contents placeholder is tens of kilobytes', async () => { + // Signers routinely reserve 4-40 KB for the CMS blob (issue #831). + const pdfBytes = buildSignedPdf({ + head: `${SIG_HEAD}${RANGE_PLACEHOLDER} /Contents `, + gap: (hex) => `<${hex}>`, + tail: SIG_TAIL, + hexLength: 74506, + }); + + const results = await validatePdfSignatures(pdfBytes); + + expect(results).toHaveLength(1); + expect(results[0].isValid).toBe(true); + expect(results[0].coverageStatus).toBe('full'); + }); + + it('validates a signature dictionary without the optional /Type key', async () => { + const pdfBytes = buildSignedPdf({ + head: `${SIG_HEAD.replace('/Type /Sig ', '')}${RANGE_PLACEHOLDER} /Contents `, + gap: (hex) => `<${hex}>`, + tail: SIG_TAIL, + }); + + const results = await validatePdfSignatures(pdfBytes); + + expect(countSignatures(pdfBytes)).toBe(1); + expect(results).toHaveLength(1); + expect(results[0].isValid).toBe(true); + expect(results[0].coverageStatus).toBe('full'); + }); + + it('does not take other untyped dictionaries for signatures', () => { + const pdfBytes = new TextEncoder().encode( + '%PDF-1.7\n' + + '1 0 obj\n<< /Subtype /Text /Contents (A note) >>\nendobj\n' + + '2 0 obj\n<< /Filter /Adobe.PPKLite /SubFilter /ETSI.RFC3161 ' + + '/ByteRange [0 10 20 10] /Contents <00> >>\nendobj\n' + + '3 0 obj\n<< /Type /DocTimeStamp /ByteRange [0 10 20 10] ' + + '/Contents <00> >>\nendobj\n%%EOF\n' + ); + + expect(countSignatures(pdfBytes)).toBe(0); + }); + it('reports an intact signature as partial when an incremental update follows', async () => { const signed = await signPdf(samplePdf, certificateData, { signatureInfo: { reason: 'Original' },