From ed81227557992a3745294ad8f7b5504ffb6a87a5 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Thu, 23 Jul 2026 12:39:43 +0200 Subject: [PATCH] Improve the detection of the changes made after a document has been signed --- src/core/document.js | 104 ++++++++++++++------ src/core/xref.js | 24 +++++ test/pdfs/.gitignore | 1 + test/pdfs/signed_verified.pdf | 87 ++++++++++++++++ test/unit/api_spec.js | 77 +++++++++++++++ test/unit/document_spec.js | 86 +++++++++++++++- web/digital_signature_properties_manager.js | 2 + web/firefoxcom.js | 5 + web/genericcom.js | 2 + 9 files changed, 356 insertions(+), 32 deletions(-) create mode 100644 test/pdfs/signed_verified.pdf diff --git a/src/core/document.js b/src/core/document.js index 685c3e7eb..a12de7a99 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -81,6 +81,7 @@ import { XFAFactory } from "./xfa/factory.js"; import { XRef } from "./xref.js"; const LETTER_SIZE_MEDIABOX = [0, 0, 612, 792]; +const SIGNATURE_TAIL_CHUNK_SIZE = 65536; class Page { #resourcesPromise = null; @@ -1004,11 +1005,10 @@ function find(stream, signature, limit = 1024, backwards = false) { class PDFDocument { #pagePromises = new Map(); - // Map — populated by the + // Map — populated by the // `signatures` getter, consumed by `getSignatureData`. We deliberately - // keep the byte payload out of the metadata array so it doesn't ride - // the worker→main `postMessage` boundary unless the viewer actually - // asks to verify (one shot per signature). + // keep the signed byte spans out of the metadata array and only slice + // them out of the stream when the viewer actually asks to verify. #signatureData = null; #version = null; @@ -2026,12 +2026,47 @@ class PDFDocument { } } - // The /ByteRange of a signature that covers the whole document usually - // ends a few bytes before the file's actual end — the trailer, - // `startxref` offset and `%%EOF` marker are conventionally left outside - // the signed range. 100 bytes covers the worst case (large - // `startxref` offsets, optional whitespace) without false positives. - static #WHOLE_DOCUMENT_TAIL_FUZZ = 100; + async #getByteRange(begin, end) { + try { + return this.stream.getByteRange(begin, end); + } catch (ex) { + if (!(ex instanceof MissingDataException)) { + throw ex; + } + await this.pdfManager.requestRange(begin, end); + return this.#getByteRange(begin, end); + } + } + + async #coversWholeDocument(signedEnd, modificationsAfterSignature) { + if (modificationsAfterSignature > 0) { + return false; + } + + const fileLength = this.stream.end; + for ( + let begin = signedEnd; + begin < fileLength; + begin += SIGNATURE_TAIL_CHUNK_SIZE + ) { + const end = Math.min(begin + SIGNATURE_TAIL_CHUNK_SIZE, fileLength); + const tail = await this.#getByteRange(begin, end); + + for (const byte of tail) { + if ( + byte !== 0x00 && // null + byte !== 0x09 && // horizontal tab + byte !== 0x0a && // line feed + byte !== 0x0c && // form feed + byte !== 0x0d && // carriage return + byte !== 0x20 // space + ) { + return false; + } + } + } + return true; + } #parseSignatureDict(field, sigDict, fieldRef) { const byteRange = sigDict.get("ByteRange"); @@ -2075,15 +2110,12 @@ class PDFDocument { if ( a !== 0 || b <= 0 || - d < 0 || a + b > c || c + d > fileLength || fileLength === 0 ) { return null; } - const data = [stream.getByteRange(a, a + b), stream.getByteRange(c, c + d)]; - const pkcs7 = stringToBytes(contents); const t = field.get("T"); @@ -2097,13 +2129,6 @@ class PDFDocument { const refKey = fieldRef instanceof Ref ? fieldRef.toString() : "inline"; const id = `${refKey}:${a}-${b}-${c}-${d}`; - // A signature "covers the whole document" iff the gap between the - // last signed byte and EOF is no more than the conventional - // trailer-slack window. Compare on the gap (not on the absolute - // lastSignedByte) so a tiny file with a tiny ByteRange isn't - // mis-flagged as full-coverage. - const tailGap = fileLength - (c + d); - return { id, fieldName, @@ -2119,11 +2144,8 @@ class PDFDocument { signatureType, byteRange, pkcs7, - data, revisionIndex: 0, parentId: null, - coversWholeDocument: - tailGap >= 0 && tailGap <= PDFDocument.#WHOLE_DOCUMENT_TAIL_FUZZ, }; } @@ -2143,6 +2165,18 @@ class PDFDocument { const collected = []; this.#collectSignatureFields(fields, collected, new RefSet()); + await Promise.all( + collected.map(async signature => { + const signedEnd = signature.byteRange[2] + signature.byteRange[3]; + signature.modificationsAfterSignature = + this.xref.countUpdatesAfter?.(signedEnd) ?? null; + signature.coversWholeDocument = await this.#coversWholeDocument( + signedEnd, + signature.modificationsAfterSignature + ); + }) + ); + // Group sub-signatures by ByteRange containment: outer revision is // the largest covering signature (largest c + d). Sort descending, // then point each later signature at the smallest enclosing parent @@ -2165,14 +2199,14 @@ class PDFDocument { } } } - // Split bytes (`data`, `pkcs7`) out of the metadata so the array - // we ship to the main thread stays small. The viewer fetches the - // bytes on demand via `getSignatureData(id)`, one signature at a - // time, only when verification is about to run. + // Keep the PKCS#7 blob and byte-range information worker-side so the + // metadata array stays small. The viewer fetches the signed bytes on + // demand via `getSignatureData(id)`, one signature at a time, only + // when verification is about to run. const signatureData = new Map(); const metadata = collected.map(sig => { - const { data, pkcs7, ...rest } = sig; - signatureData.set(sig.id, { data, pkcs7 }); + const { pkcs7, ...rest } = sig; + signatureData.set(sig.id, { byteRange: sig.byteRange, pkcs7 }); return rest; }); this.#signatureData = signatureData; @@ -2185,7 +2219,17 @@ class PDFDocument { async getSignatureData(id) { // Ensure parsing is finished and `#signatureData` is populated. await this.signatures; - return this.#signatureData?.get(id) ?? null; + const signature = this.#signatureData?.get(id); + if (!signature) { + return null; + } + const { byteRange, pkcs7 } = signature; + const [a, b, c, d] = byteRange; + const data = await Promise.all([ + this.#getByteRange(a, a + b), + this.#getByteRange(c, c + d), + ]); + return { data, pkcs7 }; } get hasJSActions() { diff --git a/src/core/xref.js b/src/core/xref.js index e2b69a48d..f68a12148 100644 --- a/src/core/xref.js +++ b/src/core/xref.js @@ -38,6 +38,9 @@ class XRef { this.pdfManager = pdfManager; this.entries = []; this._xrefStms = new Set(); + this._xrefSectionOffsets = new Set(); + this._xrefSectionsComplete = true; + this._parsedWithRecovery = false; this._cacheMap = new Map(); // Prepare the XRef cache. this._pendingRefs = new RefSet(); this._newPersistentRefNum = null; @@ -97,6 +100,7 @@ class XRef { } parse(recoveryMode = false) { + this._parsedWithRecovery = recoveryMode; let trailerDict; if (!recoveryMode) { trailerDict = this.readXRef(); @@ -769,6 +773,8 @@ class XRef { throw new FormatError("Invalid XRef stream header"); } + this._xrefSectionOffsets.add(startXRef); + // Recursively get previous dictionary, if any obj = dict.get("Prev"); if (Number.isInteger(obj)) { @@ -782,6 +788,7 @@ class XRef { if (e instanceof MissingDataException) { throw e; } + this._xrefSectionsComplete = false; info("(while reading XRef): " + e); } this.startXRefQueue.shift(); @@ -796,6 +803,23 @@ class XRef { throw new XRefParseException(); } + countUpdatesAfter(offset) { + if (this._parsedWithRecovery || !this._xrefSectionsComplete) { + return null; + } + const relativeOffset = offset - this.stream.start; + let count = 0; + for (const sectionOffset of this._xrefSectionOffsets) { + if ( + sectionOffset >= relativeOffset && + !this._xrefStms.has(sectionOffset) + ) { + count++; + } + } + return count; + } + getEntry(i) { const xrefEntry = this.entries[i]; if (xrefEntry && !xrefEntry.free && xrefEntry.offset) { diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 803f4e599..11aa0c6bf 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -948,3 +948,4 @@ !saslprep-r6.pdf !jpx_smaskindata.pdf !nonisolated_blend_smask.pdf +!signed_verified.pdf diff --git a/test/pdfs/signed_verified.pdf b/test/pdfs/signed_verified.pdf new file mode 100644 index 000000000..e4d30f947 --- /dev/null +++ b/test/pdfs/signed_verified.pdf @@ -0,0 +1,87 @@ +%PDF-1.7 +%¥±ë +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /SigFlags 3 >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 7 0 R /Resources << /Font << /F1 8 0 R >> >> >> +endobj +4 0 obj +<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /V 5 0 R /Rect [0 0 0 0] /F 4 /P 3 0 R >> +endobj +5 0 obj +<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /M (D:20260509000000Z) /Reason (Test signature for pdf.js Digital signature properties UI) /ByteRange [0000000000 0000000644 0000008836 0000001416] /Contents <308204A006092A864886F70D010702A08204913082048D020101310B300906052B0E03021A0500300B06092A864886F70D010701A08202C4308202C0308201A8A003020102021409930917D7E2AF0DF12B06C2D2395AA512B3256F300D06092A864886F70D01010B050030163114301206035504030C0B7064662D7369676E2D63613022180F32303234313132373030303030305A180F32303237303230353030303030305A301A3118301606035504030C0F746573742D7064662D7369676E657230820122300D06092A864886F70D01010105000382010F003082010A0282010100BA8851A8448E16D641FD6EB6880636103D3C13D9EAE4354AB4ECF568576C247BC1C725A8E0D81FBDB19C069B6E1A86F26BE2AF5A756B6A6471087AA55AA74587F71CD5249C027ECD43FC1E69D038202993AB20C349E4DBB94CC26B6C0EED15820FF17EAD691AB1D3023A8B2A41EEA770E00F0D8DFD660B2BB02492A47DB988617990B157903DD23BC5E0B8481FA837D38843EF2716D855B7665AAA7E02902F3A7B10800624CC1C6C97AD96615BB7E29612C07531A30C91DDB4CAF7FCAD1D25D309EFB9170EA768E1B37B2F226F69E3B48A95611DEE26D6259DAB91084E36CB1C24042CBF168B2FE5F18F991731B8B3FE4923FA7251C431D503ACDA180A35ED8D0203010001300D06092A864886F70D01010B0500038201010016B451C70248CBA8D58612CF129803E1451B3A2F8EC5CF1F777660D3035CCEFE383E4535F9C5856F9809FF9B6E9BFBE890AC705FD1EBC754199195FCF77C723876A0F07F89AB25D15479E602C011AC8428458C68F3E8BADA403B80A6DF0528F0904F11C44192BD9218AA188DDE30D80B0A01041728FDB7C09BC1D3F8C704BFD7E4E15B09144DA44078F431016CBDE784B34DEC351C70B71C8D3B0893CD84C5F5A0833CC825C0EBEACD46234EFF8AD91A372658D1012B9BE9095BBB9E51E3D5C2EBCBE2116757493D685264BDC5B3DFC5C8B9F21DB4F2EDC33EBE94B7BBF8919111ECEC5FA696ADE4C13A223DE552EB3260AA6FE33CAEFA7F6E06BF3AC3CFD555318201A4308201A0020101302E30163114301206035504030C0B7064662D7369676E2D6361021409930917D7E2AF0DF12B06C2D2395AA512B3256F300D06096086480165030402010500A04B301806092A864886F70D010903310B06092A864886F70D010701302F06092A864886F70D01090431220420EB906505A2BEB9AB79F6C51EE232303A5F04E63414F85EB50B8135A341A70752300B06092A864886F70D0101010482010090A021F29CD443D5E66F13C47E73E58ED1F9F4E2DC1D859AE2DE0AE17688BB6968C9804C7365F4312FFBFDF6D003784BFD7BE375ED61E8C0CD43FA9F07BC11479A6DDAB3860FA9EDB48BA358619DCCB52F1F9E1AE0EEDD1D8704E0F95D7C614934F9A54E04D14ED68AA19344E0672FDF44420433D9251F34AB13059E22CA047488983347618FB02EF1B2B51A0EA0256A37F7B2EE5BCE7FE2E02733D37051285C362B1621F60631B4ADECFB324690E3485D02746793D1171498916C97E828DFCF6A76353D6842EB80D504F82E5326FEC2E03AEF2A9AD40D30BB373F308245EE2FCD077641E10180DBB985E0B2CD24A24DBC43F118FFDB82E888C307054823E5E200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000> >> +endobj +7 0 obj +<< /Length 1037 >> +stream +BT +/F1 11 Tf +12 TL +50 780 Td +(Digital signature properties -- pdf.js test corpus) Tj +T* +(=========================================) Tj +T* +() Tj +T* +(This PDF is part of the manual-test corpus for the Signature) Tj +T* +(Properties UI. The text below describes what the toolbar button) Tj +T* +(and the doorhanger should look like when this file is opened in) Tj +T* +(a Firefox build with) Tj +T* +(security.pdf_signature_verification.enable_test_trust_anchors) Tj +T* +(set to true.) Tj +T* +() Tj +T* +(Expected verification state: VERIFIED) Tj +T* +() Tj +T* +(Toolbar icon: GREEN circle with white check.) Tj +T* +(Banner: GREEN. "Document signed and verified".) Tj +T* +(Status row: GREEN check, "Status: Signature verified".) Tj +T* +(Certificate row: GREEN check, "Certificate: Trusted \(pdf-sign-ca\)".) Tj +T* +(\(Green only because this is the top-level card AND) Tj +T* +(every signature in the document is verified.\)) Tj +T* +("View certificate" link below the timestamp opens about:certificate.) Tj +T* +(No sub-signatures.) Tj +T* +() Tj +ET +endstream +endobj +8 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 9 +0000000000 65535 f +0000000017 00000 n +0000000110 00000 n +0000000167 00000 n +0000000293 00000 n +0000000408 00000 n +0000000000 65535 f +0000008848 00000 n +0000009937 00000 n +trailer +<< /Size 9 /Root 1 0 R >> +startxref +10007 +%%EOF diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 123beddfb..b30a844f1 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -2147,6 +2147,7 @@ describe("api", function () { revisionIndex: 0, parentId: null, coversWholeDocument: true, + modificationsAfterSignature: 0, }, { id: "49R:0-60285-74083-357914", @@ -2163,6 +2164,7 @@ describe("api", function () { revisionIndex: 1, parentId: "70R:0-435961-437515-34098", coversWholeDocument: false, + modificationsAfterSignature: 1, }, ]); @@ -2202,6 +2204,81 @@ describe("api", function () { await loadingTask.destroy(); }); + it("gets signature metadata without fetching later revisions", async function () { + const baseData = await DefaultFileReaderFactory.fetch({ + path: TEST_PDFS_PATH + "signed_verified.pdf", + }); + const payload = new Uint8Array(2 * 65536).fill(0x41); + const objectHeader = stringToBytes( + `\n9 0 obj\n<< /Length ${payload.length} >>\nstream\n` + ); + const objectFooter = stringToBytes("\nendstream\nendobj\n"); + const objectOffset = baseData.length + 1; + const xrefOffset = + baseData.length + + objectHeader.length + + payload.length + + objectFooter.length; + const xref = stringToBytes( + "xref\n" + + "9 1\n" + + `${objectOffset.toString().padStart(10, "0")} 00000 n \n` + + "trailer\n" + + "<< /Size 10 /Root 1 0 R /Prev 10007 >>\n" + + "startxref\n" + + `${xrefOffset}\n` + + "%%EOF\n" + ); + const data = new Uint8Array( + baseData.length + + objectHeader.length + + payload.length + + objectFooter.length + + xref.length + ); + let position = 0; + for (const part of [ + baseData, + objectHeader, + payload, + objectFooter, + xref, + ]) { + data.set(part, position); + position += part.length; + } + + const initialData = new Uint8Array(data.subarray(0, 65536)); + const transport = new PDFDataRangeTransport(data.length, initialData); + let fetches = 0; + transport.requestDataRange = (begin, end) => { + fetches++; + waitSome(() => { + transport.onDataRange( + begin, + new Uint8Array(data.subarray(begin, end)) + ); + }); + }; + + const loadingTask = getDocument({ + range: transport, + rangeChunkSize: 65536, + disableAutoFetch: true, + disableStream: true, + }); + const pdfDoc = await loadingTask.promise; + const fetchesAfterLoading = fetches; + const signatures = await pdfDoc.getSignatures(); + + expect(signatures.length).toEqual(1); + expect(signatures[0].coversWholeDocument).toEqual(false); + expect(signatures[0].modificationsAfterSignature).toEqual(1); + expect(fetches).toEqual(fetchesAfterLoading); + + await loadingTask.destroy(); + }); + it("gets non-existent calculationOrder", async function () { const calculationOrder = await pdfDocument.getCalculationOrderIds(); expect(calculationOrder).toEqual(null); diff --git a/test/unit/document_spec.js b/test/unit/document_spec.js index 53e599657..de0de169d 100644 --- a/test/unit/document_spec.js +++ b/test/unit/document_spec.js @@ -17,6 +17,7 @@ import { createIdFactory, XRefMock } from "./test_utils.js"; import { Dict, Name, Ref } from "../../src/core/primitives.js"; import { PDFDocument } from "../../src/core/document.js"; import { StringStream } from "../../src/core/stream.js"; +import { XRef } from "../../src/core/xref.js"; describe("document", function () { describe("Page", function () { @@ -44,6 +45,35 @@ describe("document", function () { }); }); + describe("XRef", function () { + it("compares xref sections with absolute file offsets", function () { + const stream = new StringStream(" ".repeat(200)); + stream.pos = 10; + stream.moveStart(); + + const xref = new XRef(stream, {}); + xref._xrefSectionOffsets.add(100); + + expect(xref.countUpdatesAfter(105)).toEqual(1); + expect(xref.countUpdatesAfter(111)).toEqual(0); + }); + + it("returns an unknown update count for an incomplete xref chain", function () { + const stream = new StringStream( + "xref\n" + + "0 1\n" + + "0000000000 65535 f \n" + + "trailer\n" + + "<< /Size 1 /Prev 999 >>\n" + ); + const xref = new XRef(stream, {}); + xref.setStartXRef(0); + + expect(xref.readXRef()).toBeInstanceOf(Dict); + expect(xref.countUpdatesAfter(0)).toBeNull(); + }); + }); + describe("PDFDocument", function () { // Padded to 1024 bytes so signature ByteRange tests using offsets // like `[0, 100, 200, 800]` stay within `stream.end` (the new @@ -51,7 +81,11 @@ describe("document", function () { // the file length). const stream = new StringStream("Dummy_PDF_data".padEnd(1024, " ")); - function getDocument(acroForm, xref = new XRefMock()) { + function getDocument( + acroForm, + xref = new XRefMock(), + documentStream = stream + ) { const catalog = { acroForm }; const pdfManager = { get docId() { @@ -74,7 +108,7 @@ describe("document", function () { return { isOffscreenCanvasSupported: false }; }, }; - const pdfDocument = new PDFDocument(pdfManager, stream); + const pdfDocument = new PDFDocument(pdfManager, documentStream); pdfDocument.xref = xref; pdfDocument.catalog = catalog; @@ -283,6 +317,54 @@ describe("document", function () { expect(bytes.data.length).toEqual(2); }); + it("reports whole-document coverage when only whitespace trails the signed range", async function () { + const acroForm = new Dict(); + acroForm.set("SigFlags", 3); + + const sigRef = Ref.get(40, 0); + const fieldRef = Ref.get(41, 0); + const sigDict = makeSigDict({ byteRange: [0, 20, 30, 20] }); + const fieldDict = makeSigField({ T: "sig_full", sigRef }); + + const xref = new XRefMock([ + { ref: sigRef, data: sigDict }, + { ref: fieldRef, data: fieldDict }, + ]); + acroForm.set("Fields", [fieldRef]); + + const documentStream = new StringStream( + "A".repeat(50) + " ".repeat(150) + ); + const pdfDocument = getDocument(acroForm, xref, documentStream); + const signatures = await pdfDocument.signatures; + expect(signatures.length).toEqual(1); + expect(signatures[0].coversWholeDocument).toBeTrue(); + }); + + it("clears whole-document coverage when non-whitespace trails the signed range", async function () { + const acroForm = new Dict(); + acroForm.set("SigFlags", 3); + + const sigRef = Ref.get(42, 0); + const fieldRef = Ref.get(43, 0); + const sigDict = makeSigDict({ byteRange: [0, 20, 30, 20] }); + const fieldDict = makeSigField({ T: "sig_partial", sigRef }); + + const xref = new XRefMock([ + { ref: sigRef, data: sigDict }, + { ref: fieldRef, data: fieldDict }, + ]); + acroForm.set("Fields", [fieldRef]); + + const documentStream = new StringStream( + "A".repeat(50) + "MODIFIED" + " ".repeat(142) + ); + const pdfDocument = getDocument(acroForm, xref, documentStream); + const signatures = await pdfDocument.signatures; + expect(signatures.length).toEqual(1); + expect(signatures[0].coversWholeDocument).toBeFalse(); + }); + it("walks Kids recursively to find nested signature fields", async function () { const acroForm = new Dict(); acroForm.set("SigFlags", 3); diff --git a/web/digital_signature_properties_manager.js b/web/digital_signature_properties_manager.js index 1f956c732..95c0f363f 100644 --- a/web/digital_signature_properties_manager.js +++ b/web/digital_signature_properties_manager.js @@ -270,6 +270,7 @@ class SignaturePropertiesManager { message: null, certificate: null, documentModifiedAfterSigning: !sig.coversWholeDocument, + modificationsAfterSignature: sig.modificationsAfterSignature, }); } this.#render(); @@ -647,6 +648,7 @@ class SignaturePropertiesManager { message: ex?.message ?? null, certificate: null, documentModifiedAfterSigning: !signature.coversWholeDocument, + modificationsAfterSignature: signature.modificationsAfterSignature, }; } this.#pendingVerify.delete(signature.id); diff --git a/web/firefoxcom.js b/web/firefoxcom.js index 65f9aff46..7da64a4ef 100644 --- a/web/firefoxcom.js +++ b/web/firefoxcom.js @@ -593,6 +593,7 @@ class SignatureVerifier { message: signature.subFilter, certificate: null, documentModifiedAfterSigning: !signature.coversWholeDocument, + modificationsAfterSignature: signature.modificationsAfterSignature, }; } @@ -610,6 +611,7 @@ class SignatureVerifier { message: ex?.message ?? null, certificate: null, documentModifiedAfterSigning: !signature.coversWholeDocument, + modificationsAfterSignature: signature.modificationsAfterSignature, }; } if (!response || response.error) { @@ -619,6 +621,7 @@ class SignatureVerifier { message: null, certificate: null, documentModifiedAfterSigning: !signature.coversWholeDocument, + modificationsAfterSignature: signature.modificationsAfterSignature, }; } @@ -632,6 +635,7 @@ class SignatureVerifier { message: null, certificate: null, documentModifiedAfterSigning: !signature.coversWholeDocument, + modificationsAfterSignature: signature.modificationsAfterSignature, }; } const { status, errorCode } = mapVerificationStatus( @@ -644,6 +648,7 @@ class SignatureVerifier { message: entry.message ?? null, certificate: entry.certificate ?? null, documentModifiedAfterSigning: !signature.coversWholeDocument, + modificationsAfterSignature: signature.modificationsAfterSignature, }; } diff --git a/web/genericcom.js b/web/genericcom.js index 33e573080..6209cf228 100644 --- a/web/genericcom.js +++ b/web/genericcom.js @@ -184,6 +184,7 @@ if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { message: signature.subFilter, certificate: null, documentModifiedAfterSigning: !signature.coversWholeDocument, + modificationsAfterSignature: signature.modificationsAfterSignature, }; } @@ -193,6 +194,7 @@ if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { message: null, certificate: null, documentModifiedAfterSigning: !signature.coversWholeDocument, + modificationsAfterSignature: signature.modificationsAfterSignature, }; }