Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 74 additions & 30 deletions src/core/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1004,11 +1005,10 @@ function find(stream, signature, limit = 1024, backwards = false) {
class PDFDocument {
#pagePromises = new Map();

// Map<id, {data: Uint8Array[2], pkcs7: Uint8Array}> — populated by the
// Map<id, {byteRange: number[4], pkcs7: Uint8Array}> — 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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand All @@ -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,
Expand All @@ -2119,11 +2144,8 @@ class PDFDocument {
signatureType,
byteRange,
pkcs7,
data,
revisionIndex: 0,
parentId: null,
coversWholeDocument:
tailGap >= 0 && tailGap <= PDFDocument.#WHOLE_DOCUMENT_TAIL_FUZZ,
};
}

Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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() {
Expand Down
24 changes: 24 additions & 0 deletions src/core/xref.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -97,6 +100,7 @@ class XRef {
}

parse(recoveryMode = false) {
this._parsedWithRecovery = recoveryMode;
let trailerDict;
if (!recoveryMode) {
trailerDict = this.readXRef();
Expand Down Expand Up @@ -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)) {
Expand All @@ -782,6 +788,7 @@ class XRef {
if (e instanceof MissingDataException) {
throw e;
}
this._xrefSectionsComplete = false;
info("(while reading XRef): " + e);
}
this.startXRefQueue.shift();
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions test/pdfs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -948,3 +948,4 @@
!saslprep-r6.pdf
!jpx_smaskindata.pdf
!nonisolated_blend_smask.pdf
!signed_verified.pdf
Binary file added test/pdfs/signed_verified.pdf
Binary file not shown.
77 changes: 77 additions & 0 deletions test/unit/api_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2147,6 +2147,7 @@ describe("api", function () {
revisionIndex: 0,
parentId: null,
coversWholeDocument: true,
modificationsAfterSignature: 0,
},
{
id: "49R:0-60285-74083-357914",
Expand All @@ -2163,6 +2164,7 @@ describe("api", function () {
revisionIndex: 1,
parentId: "70R:0-435961-437515-34098",
coversWholeDocument: false,
modificationsAfterSignature: 1,
},
]);

Expand Down Expand Up @@ -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);
Expand Down
Loading