mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-09 17:14:16 +01:00
fix(validate-signature): detect signature dictionaries without /Type
/Type is optional in a signature dictionary (PDF 32000-1, 12.8.1) and some signers leave it out, so those documents still reported "No Signatures Found". A dictionary without /Type that carries both /ByteRange and /Contents is now read as a signature; document timestamps (/Type /DocTimeStamp, /SubFilter /ETSI.RFC3161) stay out because they hold an RFC 3161 token, not a CMS signature over the document. Also covers a /Contents placeholder of 74,506 hex characters, the largest seen in real signed documents, which the former fixed scan window dropped. Fixes #831 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9aae81e73c
commit
d86de593ca
2 files changed
+74
-5
No files matched your search
@@ -26,7 +26,7 @@ export function extractSignatures(
|
|||||||
const signatures: ExtractedSignatureWithError[] = [];
|
const signatures: ExtractedSignatureWithError[] = [];
|
||||||
const pdfString = new TextDecoder('latin1').decode(pdfBytes);
|
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;
|
const objectHeader = /\d+\s+\d+\s+obj\b/g;
|
||||||
let endobj = -1;
|
let endobj = -1;
|
||||||
|
|
||||||
@@ -48,8 +48,7 @@ export function extractSignatures(
|
|||||||
if (end < 0) continue;
|
if (end < 0) continue;
|
||||||
|
|
||||||
for (const entries of dictionaries) {
|
for (const entries of dictionaries) {
|
||||||
const type = entries.get('Type');
|
if (isSignatureDictionary(pdfString, entries)) {
|
||||||
if (type && pdfString.slice(type.start, type.end) === '/Sig') {
|
|
||||||
signatures.push(
|
signatures.push(
|
||||||
readSignatureDictionary(
|
readSignatureDictionary(
|
||||||
pdfString,
|
pdfString,
|
||||||
@@ -65,6 +64,30 @@ export function extractSignatures(
|
|||||||
return signatures;
|
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<string, PdfValueSpan>
|
||||||
|
): 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(
|
function readSignatureDictionary(
|
||||||
pdfString: string,
|
pdfString: string,
|
||||||
pdfBytes: Uint8Array,
|
pdfBytes: Uint8Array,
|
||||||
|
|||||||
@@ -59,11 +59,13 @@ function buildSignedPdf(layout: {
|
|||||||
head: string;
|
head: string;
|
||||||
gap: (hex: string) => string;
|
gap: (hex: string) => string;
|
||||||
tail: string;
|
tail: string;
|
||||||
|
hexLength?: number;
|
||||||
}): Uint8Array {
|
}): Uint8Array {
|
||||||
const prefix = layout.prefix ?? '';
|
const prefix = layout.prefix ?? '';
|
||||||
|
const hexLength = layout.hexLength ?? HEX_LENGTH;
|
||||||
const headLength =
|
const headLength =
|
||||||
layout.head.length - RANGE_PLACEHOLDER.length + RANGE_WIDTH;
|
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 = [
|
const byteRange = [
|
||||||
prefix.length,
|
prefix.length,
|
||||||
headLength,
|
headLength,
|
||||||
@@ -74,7 +76,7 @@ function buildSignedPdf(layout: {
|
|||||||
RANGE_PLACEHOLDER,
|
RANGE_PLACEHOLDER,
|
||||||
`/ByteRange [${byteRange.join(' ')}]`.padEnd(RANGE_WIDTH)
|
`/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(
|
return new TextEncoder().encode(
|
||||||
prefix + head + layout.gap(hex) + layout.tail
|
prefix + head + layout.gap(hex) + layout.tail
|
||||||
);
|
);
|
||||||
@@ -130,6 +132,50 @@ describe('validatePdfSignatures extraction and coverage', () => {
|
|||||||
expect(extractSignatures(pdfBytes)[0].name).toBe('Real Signer');
|
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 () => {
|
it('reports an intact signature as partial when an incremental update follows', async () => {
|
||||||
const signed = await signPdf(samplePdf, certificateData, {
|
const signed = await signPdf(samplePdf, certificateData, {
|
||||||
signatureInfo: { reason: 'Original' },
|
signatureInfo: { reason: 'Original' },
|
||||||
|
|||||||
Reference in new issue
Block a user