I'm trying to upgrade a PAdES B-LT PDF to PAdES B-LTA using Node.js with an incremental update approach. The final goal is to append a /DocTimeStamp (RFC3161) object while keeping the original signature intact.
However, once the new document timestamp is added, Adobe Acrobat throws the following error:
Error during signature verification.
Unexpected byte range values defining scope of signed data. Details: The signature byte range is invalid
I have also written an incremental update in Node.js that successfully upgraded a PAdES B-T PDF to PAdES B-LT, but the incremental update for PAdES B-LTA results in an 'Unexpected byte range' error.
The incremental update adds the new objects, calculates their offsets, create a temporary pdf buffer (fullPDF) by concatenating the previous PDF buffer with the new object buffers.
Data is then extracted for signing, excluding the placeholder content within < and >. This data is sent to the TSA server (DigiCert), which returns a timestamp token that is inserted into the /Contents placeholder.
async function addPadesLTA(pdfBuffer) {
const { pdfString, lastObjNumber } = getLastObjectNumber(pdfBuffer);
const dssObjNumber = getDSSObjectNumber(pdfBuffer);
const newCatalogObj = lastObjNumber + 1;
const newSigObj = newCatalogObj + 1;
const newWidgetObj = newSigObj + 1;
const newAcroformObj = newWidgetObj + 1;
const placeholderSize = 8192;
const contentsPlaceholder = "0".repeat(placeholderSize * 2);
const prevStartXref = pdfBuffer.lastIndexOf("startxref") + 10;
const prevTrailerStart = pdfBuffer.lastIndexOf("trailer", prevStartXref);
const prevTrailerString = pdfBuffer
.subarray(prevTrailerStart, pdfBuffer.lastIndexOf("%%EOF"))
.toString();
const prevXrefOffset = parseInt(pdfString.slice(prevStartXref).trim());
const { id, infoObj, rootObj } = extractTrailerInfo(prevTrailerString);
const idString = `/ID [ <${id[0]}> <${id[1]}> ]`;
const infoString = infoObj ? `/Info ${infoObj} 0 R` : "";
const pageObject = `7 0 obj
<<
/Type /Page
/Parent 1 0 R
/MediaBox [0 0 595.28 841.89]
/Contents 5 0 R
/Resources 6 0 R
/Annots [12 0 R ${newWidgetObj} 0 R]
>>
endobj\n`;
const catalogObject = `${newCatalogObj} 0 obj\n<<\n/Type /Catalog\n/Pages 1 0 R\n/Names 2 0 R\n/AcroForm ${newAcroformObj} 0 R\n/DSS ${dssObjNumber} 0 R\n>>\nendobj\n`;
const sigObject = `${newSigObj} 0 obj\n<<\n/Type /DocTimeStamp\n/Filter /Adobe.PPKLite\n/SubFilter /ETSI.RFC3161\n/ByteRange [0 ********** ********** **********]\n/Contents <${contentsPlaceholder}>\n>>\nendobj\n`;
const widgetObject = `${newWidgetObj} 0 obj\n<<\n/Type /Annot\n/Subtype /Widget\n/FT /Sig\n/Rect [0 0 0 0]\n/V ${newSigObj} 0 R\n/T (Signature2)\n/F 132\n/P 7 0 R\n>>\nendobj\n`;
const acroformObject = `${newAcroformObj} 0 obj\n<<\n/Type /AcroForm\n/SigFlags 3\n/Fields [12 0 R ${newWidgetObj} 0 R]\n>>\nendobj\n`;
const pageBuffer = Buffer.from(pageObject, "utf-8");
const sigBuffer = Buffer.from(sigObject, "utf8");
const widgetBuffer = Buffer.from(widgetObject, "utf8");
const acroformBuffer = Buffer.from(acroformObject, "utf8");
const catalogBuffer = Buffer.from(catalogObject, "utf8");
const pageOffset = pdfBuffer.length + 1;
const catalogOffset = pageOffset + pageBuffer.length;
const sigOffset = catalogOffset + catalogBuffer.length;
const widgetOffset = sigOffset + sigBuffer.length;
const acroformOffset = widgetOffset + widgetBuffer.length;
const xrefBuffer = Buffer.from(
`xref\n0 1\n0000000000 65535 f\n7 1\n${pageOffset
.toString()
.padStart(10, "0")} 00000 n\n${newCatalogObj} 1\n${catalogOffset
.toString()
.padStart(10, "0")} 00000 n\n${newSigObj} 1\n${sigOffset
.toString()
.padStart(10, "0")} 00000 n\n${newWidgetObj} 1\n${widgetOffset
.toString()
.padStart(10, "0")} 00000 n\n${newAcroformObj} 1\n${acroformOffset
.toString()
.padStart(10, "0")} 00000 n\n`,
"utf8"
);
const trailerBuffer = Buffer.from(
`trailer\n<< /Size ${
newAcroformObj + 1
} /Root ${newCatalogObj} 0 R ${infoString} ${idString} /Prev ${prevXrefOffset} >>\nstartxref\n${
acroformOffset + acroformBuffer.length
}\n%%EOF`,
"utf8"
);
const fullPDF = Buffer.concat([
pdfBuffer,
pageBuffer,
catalogBuffer,
sigBuffer,
widgetBuffer,
acroformBuffer,
xrefBuffer,
trailerBuffer,
]);
const byteRangePos = fullPDF.indexOf("/ByteRange [0 **********");
if (byteRangePos === -1)
throw new Error("Could not find /ByteRange in PDF");
const rangeEnd = fullPDF.indexOf("]", byteRangePos);
const contentsOffset = fullPDF.indexOf("/Contents");
if (contentsOffset === -1)
throw new Error("Could not find /Contents in PDF");
const startOfContents = fullPDF.indexOf(
"<" + contentsPlaceholder.slice(0, 16),
contentsOffset
);
const endOfContents = fullPDF.indexOf(">", startOfContents);
const byteRange = [
0,
startOfContents,
endOfContents + 1,
fullPDF.length - (endOfContents + 1),
];
const byteRangeString = `/ByteRange [${byteRange.join(" ")}]`;
const paddedByteRange =
byteRangeString +
" ".repeat(
fullPDF.subarray(byteRangePos, rangeEnd + 1).length -
byteRangeString.length
);
const pdfWithByteRange = Buffer.concat([
fullPDF.subarray(0, byteRangePos),
Buffer.from(paddedByteRange),
fullPDF.subarray(rangeEnd + 1),
]);
const dataToHash = Buffer.concat([
pdfWithByteRange.subarray(0, byteRange[1]),
pdfWithByteRange.subarray(byteRange[2]),
]);
const hash = await crypto.subtle.digest("SHA-256", dataToHash);
const tsRequest = await createTimestampRequest(hash);
const tsResponse = await getTimestampResponse(tsRequest);
const { token } = await parseTimestampResponse(tsResponse);
const tokenHex = Buffer.from(token)
.toString("hex")
.padEnd(placeholderSize * 2, "0");
const finalPDF = Buffer.concat([
pdfWithByteRange.subarray(0, byteRange[1]),
Buffer.from(`<${tokenHex}>`),
pdfWithByteRange.subarray(byteRange[2]),
]);
return finalPDF;
}
the script is able to add this to the pdf document
7 0 obj
<<
/Type /Page
/Parent 1 0 R
/MediaBox [0 0 595.28 841.89]
/Contents 5 0 R
/Resources 6 0 R
/Annots [12 0 R 25 0 R]
>>
endobj
23 0 obj
<<
/Type /Catalog
/Pages 1 0 R
/Names 2 0 R
/AcroForm 26 0 R
/DSS 21 0 R
>>
endobj
24 0 obj
<<
/Type /DocTimeStamp
/Filter /Adobe.PPKLite
/SubFilter /ETSI.RFC3161
/ByteRange [0 56124 72510 511]
/Contents
/Contents <3082172...000>
>>
endobj
25 0 obj
<<
/Type /Annot
/Subtype /Widget
/FT /Sig
/Rect [0 0 0 0]
/V 24 0 R
/T (Signature2)
/F 132
/P 7 0 R
>>
endobj
26 0 obj
<<
/Type /AcroForm
/SigFlags 3
/Fields [12 0 R 25 0 R]
>>
endobj
xref
0 1
0000000000 65535 f
7 1
0000055761 00000 n
23 1
0000055895 00000 n
24 1
0000055987 00000 n
25 1
0000072522 00000 n
26 1
0000072641 00000 n
trailer
<< /Size 27 /Root 23 0 R /Info 13 0 R /ID [ <e8dde2b1733a63615929e7d21e486287> <e8dde2b1733a63615929e7d21e486287> ] /Prev 55520 >>
startxref
72715
%%EOF
I am not able to pinpoint what is causing this issue.
I have checked the byte range logic and extracted data to hash and it seems correct to me.
Maybe I am calculating the xref incorrectly.
Or maybe I didn’t fully understand the requirements for PAdES B-LTA.
This incremental update is able to add DocTimeStamp signature (DigiCert)
Is my interpretation of how to apply a /DocTimeStamp in an incremental update correct?
For a /DocTimeStamp in PAdES B-LTA, should the /ByteRange include the xref and trailer in the hashed data?"
Here is the sample output file:
Solution: xref formatting and offset error fixed
async function addPadesLTA(pdfBuffer) {
const { pdfString, lastObjNumber } = getLastObjectNumber(pdfBuffer);
const dssObjNumber = getDSSObjectNumber(pdfBuffer);
const newCatalogObj = lastObjNumber + 1;
const newSigObj = newCatalogObj + 1;
const newWidgetObj = newSigObj + 1;
const newAcroformObj = newWidgetObj + 1;
const placeholderSize = 8192;
const contentsPlaceholder = "0".repeat(placeholderSize * 2);
const prevStartXref = pdfBuffer.lastIndexOf("startxref") + 10;
const prevTrailerStart = pdfBuffer.lastIndexOf("trailer", prevStartXref);
const prevTrailerString = pdfBuffer
.subarray(prevTrailerStart, pdfBuffer.lastIndexOf("%%EOF"))
.toString();
const prevXrefOffset = parseInt(pdfString.slice(prevStartXref).trim());
const { id, infoObj, rootObj } = extractTrailerInfo(prevTrailerString);
const idString = `/ID [ <${id[0]}> <${id[1]}> ]`;
const infoString = infoObj ? `/Info ${infoObj} 0 R` : "";
const pageObject = `7 0 obj
<<
/Type /Page
/Parent 1 0 R
/MediaBox [0 0 595.28 841.89]
/Contents 5 0 R
/Resources 6 0 R
/Annots [12 0 R ${newWidgetObj} 0 R]
>>
endobj\n`;
const catalogObject = `${newCatalogObj} 0 obj\n<<\n/Type /Catalog\n/Pages 1 0 R\n/Names 2 0 R\n/AcroForm ${newAcroformObj} 0 R\n/DSS ${dssObjNumber} 0 R\n>>\nendobj\n`;
const sigObject = `${newSigObj} 0 obj\n<<\n/Type /DocTimeStamp\n/Filter /Adobe.PPKLite\n/SubFilter /ETSI.RFC3161\n/ByteRange [0 ********** ********** **********]\n/Contents <${contentsPlaceholder}>\n>>\nendobj\n`;
const widgetObject = `${newWidgetObj} 0 obj\n<<\n/Type /Annot\n/Subtype /Widget\n/FT /Sig\n/Rect [0 0 0 0]\n/V ${newSigObj} 0 R\n/T (Signature2)\n/F 132\n/P 7 0 R\n>>\nendobj\n`;
const acroformObject = `${newAcroformObj} 0 obj\n<<\n/Type /AcroForm\n/SigFlags 3\n/Fields [12 0 R ${newWidgetObj} 0 R]\n>>\nendobj\n`;
const pageBuffer = Buffer.from(pageObject, "utf-8");
const sigBuffer = Buffer.from(sigObject, "utf8");
const widgetBuffer = Buffer.from(widgetObject, "utf8");
const acroformBuffer = Buffer.from(acroformObject, "utf8");
const catalogBuffer = Buffer.from(catalogObject, "utf8");
const pageOffset = pdfBuffer.length;
const catalogOffset = pageOffset + pageBuffer.length;
const sigOffset = catalogOffset + catalogBuffer.length;
const widgetOffset = sigOffset + sigBuffer.length;
const acroformOffset = widgetOffset + widgetBuffer.length;
const xrefBuffer = Buffer.from(
`xref\n0 1\n0000000000 65535 f \n7 1\n${pageOffset
.toString()
.padStart(10, "0")} 00000 n \n${newCatalogObj} 1\n${catalogOffset
.toString()
.padStart(10, "0")} 00000 n \n${newSigObj} 1\n${sigOffset
.toString()
.padStart(10, "0")} 00000 n \n${newWidgetObj} 1\n${widgetOffset
.toString()
.padStart(10, "0")} 00000 n \n${newAcroformObj} 1\n${acroformOffset
.toString()
.padStart(10, "0")} 00000 n \n`,
"utf8"
);
const trailerBuffer = Buffer.from(
`trailer\n<< /Size ${
newAcroformObj + 1
} /Root ${newCatalogObj} 0 R ${infoString} ${idString} /Prev ${prevXrefOffset} >>\nstartxref\n${
acroformOffset + acroformBuffer.length
}\n%%EOF`,
"utf8"
);
const fullPDF = Buffer.concat([
pdfBuffer,
pageBuffer,
catalogBuffer,
sigBuffer,
widgetBuffer,
acroformBuffer,
xrefBuffer,
trailerBuffer,
]);
const byteRangePos = fullPDF.indexOf("/ByteRange [0 **********");
if (byteRangePos === -1)
throw new Error("Could not find /ByteRange in PDF");
const rangeEnd = fullPDF.indexOf("]", byteRangePos);
const contentsOffset = fullPDF.indexOf("/Contents");
if (contentsOffset === -1)
throw new Error("Could not find /Contents in PDF");
const startOfContents = fullPDF.indexOf(
"<" + contentsPlaceholder.slice(0, 16),
contentsOffset
);
const endOfContents = fullPDF.indexOf(">", startOfContents);
const byteRange = [
0,
startOfContents,
endOfContents + 1,
fullPDF.length - (endOfContents + 1),
];
const byteRangeString = `/ByteRange [${byteRange.join(" ")}]`;
const paddedByteRange =
byteRangeString +
" ".repeat(
fullPDF.subarray(byteRangePos, rangeEnd + 1).length -
byteRangeString.length
);
const pdfWithByteRange = Buffer.concat([
fullPDF.subarray(0, byteRangePos),
Buffer.from(paddedByteRange),
fullPDF.subarray(rangeEnd + 1),
]);
const dataToHash = Buffer.concat([
pdfWithByteRange.subarray(0, byteRange[1]),
pdfWithByteRange.subarray(byteRange[2]),
]);
const hash = await crypto.subtle.digest("SHA-256", dataToHash);
const tsRequest = await createTimestampRequest(hash);
const tsResponse = await getTimestampResponse(tsRequest);
const { token } = await parseTimestampResponse(tsResponse);
const tokenHex = Buffer.from(token)
.toString("hex")
.padEnd(placeholderSize * 2, "0");
const finalPDF = Buffer.concat([
pdfWithByteRange.subarray(0, byteRange[1]),
Buffer.from(`<${tokenHex}>`),
pdfWithByteRange.subarray(byteRange[2]),
]);
return finalPDF;
}
As you say yourself in a comment,
the issue is with the new incremental update that adds /DocTimeStamps
and it isn't an issue of byte ranges but an issue of completely broken cross references.
In detail, the cross reference table section of that incremental update has the following issues:
Furthermore, the startxref value is incorrect, it's 72715 while the xref keyword actually starts at 72714.
In particular such errors in the cross references of a PDF file make Acrobat use an internal repaired version of the file upon loading instead of the actual file, and as such repairs can re-organize whole file parts, the signed byte range values in your file are incorrect in the repaired file version.
As far as your code is concerned, I'm not into JavaScript, but I assume the following changes should fix those issues:
To get rid of the off-by-1 issues, it should suffice to either remove the +1
in
const pageOffset = pdfBuffer.length + 1;
or to add another single byte buffer (e.g. containing a \n
) in your Buffer.concat
call between pdfBuffer
and pageBuffer
.
To fix the cross reference table entry sizes, it should suffice to either use a CRLF two byte end-of-line marker or to insert a space after each n or f.