Skip to content
Open
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
1 change: 1 addition & 0 deletions gulpfile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ function createWebpackAlias(defines) {
"web-print_service": "",
"web-secondary_toolbar": "web/secondary_toolbar.js",
"web-signature_manager": "web/signature_manager.js",
"web-signature_properties_manager": "web/signature_properties_manager.js",
"web-toolbar": "web/toolbar.js",
"web-views_manager": "web/views_manager.js",
};
Expand Down
83 changes: 83 additions & 0 deletions l10n/en-US/viewer.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -785,3 +785,86 @@ pdfjs-views-manager-paste-button-after =
pdfjs-new-badge-content = NEW

pdfjs-views-manager-waiting-for-file = Uploading file…

## Signature Properties (digital signature verification)

pdfjs-signature-properties-button =
.title = Digital Signature properties
Comment thread
beurdouche marked this conversation as resolved.
.aria-label = Digital Signature properties
pdfjs-signature-properties-button-label = Digital Signature properties

## Banner shown above the signature list summarising the overall
## verification state of the document. Each variant is selected by the
## viewer based on the worst per-signature status; one signature is
## enough to lower the banner.
##
## Variables:
## $count (Number) - number of signatures at the worst level.

pdfjs-signature-properties-banner-verified = Document has a valid digital signature
pdfjs-signature-properties-banner-unknown =
{ $count ->
[one] Document signed but { $count } digital signature could not be verified
*[other] Document signed but { $count } digital signatures could not be verified
}
pdfjs-signature-properties-banner-untrusted =
{ $count ->
[one] Document signed with { $count } certificate that is not trusted
*[other] Document signed with { $count } certificates that are not trusted
}
pdfjs-signature-properties-banner-expired =
{ $count ->
[one] Document signed with { $count } expired certificate
*[other] Document signed with { $count } expired certificates
}
pdfjs-signature-properties-banner-invalid =
{ $count ->
[one] Document has { $count } invalid digital signature
*[other] Document has { $count } invalid digital signatures
}
pdfjs-signature-properties-banner-revoked =
{ $count ->
[one] Document signed with { $count } revoked certificate
*[other] Document signed with { $count } revoked certificates
}

## Per-signature status row.

pdfjs-signature-properties-status-verified = Status: Signature verified
pdfjs-signature-properties-status-unknown = Status: Unable to verify (unsupported)
pdfjs-signature-properties-status-untrusted = Status: Signature verified
pdfjs-signature-properties-status-expired = Status: Signature verified
pdfjs-signature-properties-status-invalid = Status: Signature invalid
pdfjs-signature-properties-status-revoked = Status: Signature verified

## Per-signature certificate row. The variants with an issuer / date in
## parentheses embed fully-localized context — no English fall-through.
##
## Variables:
## $issuer (String) - issuer or subject common name from the cert.
## $dateObj (Date) - notAfter date for the expired-with-date form.

pdfjs-signature-properties-certificate-trusted = Certificate: Trusted ({ $issuer })
pdfjs-signature-properties-certificate-unknown = Certificate: Unavailable
pdfjs-signature-properties-certificate-untrusted = Certificate: Untrusted
pdfjs-signature-properties-certificate-untrusted-unknown-issuer = Certificate: Unknown issuer ({ $issuer })
pdfjs-signature-properties-certificate-untrusted-self-signed = Certificate: Self-signed ({ $issuer })
pdfjs-signature-properties-certificate-untrusted-untrusted-issuer = Certificate: Untrusted issuer ({ $issuer })
pdfjs-signature-properties-certificate-expired = Certificate: Expired
pdfjs-signature-properties-certificate-expired-with-date = Certificate: Expired ({ DATETIME($dateObj, dateStyle: "medium") })
pdfjs-signature-properties-certificate-revoked = Certificate: Revoked

##

pdfjs-signature-properties-view-certificate = View certificate

# Variables:
# $reason (String) - the reason text from the signature dictionary.
pdfjs-signature-properties-reason = Reason: { $reason }
# Variables:
# $dateObj (Date) - the signing time from the /Sig dict's /M entry.
pdfjs-signature-properties-timestamp = Timestamp: { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") }
# Variables:
# $count (Number) - number of nested sub-signatures (one per earlier
# incremental revision of the document).
pdfjs-signature-properties-sub-signatures = Sub-signatures ({ $count })
157 changes: 157 additions & 0 deletions src/core/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -1990,6 +1990,163 @@ class PDFDocument {
return shadow(this, "fieldObjects", promise);
}

#collectSignatureFields(fields, out, visitedRefs) {
if (!Array.isArray(fields)) {
return;
}
for (const fieldRef of fields) {
if (fieldRef instanceof Ref) {
if (visitedRefs.has(fieldRef)) {
continue;
}
visitedRefs.put(fieldRef);
}
const field = this.xref.fetchIfRef(fieldRef);
if (!(field instanceof Dict)) {
continue;
}
if (field.has("Kids")) {
this.#collectSignatureFields(field.get("Kids"), out, visitedRefs);
continue;
}
if (!isName(field.get("FT"), "Sig")) {
continue;
}
const sigDict = this.xref.fetchIfRef(field.get("V"));
if (!(sigDict instanceof Dict)) {
continue;
}
const parsed = this.#parseSignatureDict(field, sigDict, fieldRef);
if (parsed) {
out.push(parsed);
}
}
}

// 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;

#parseSignatureDict(field, sigDict, fieldRef) {
const byteRange = sigDict.get("ByteRange");
if (
!Array.isArray(byteRange) ||
byteRange.length !== 4 ||
byteRange.some(n => !Number.isInteger(n) || n < 0)
) {
return null;
}
const contents = sigDict.get("Contents");
if (typeof contents !== "string" || contents.length === 0) {
return null;
}

const filterName = sigDict.get("Filter");
const filter = filterName instanceof Name ? filterName.name : null;
const subFilterName = sigDict.get("SubFilter");
const subFilter = subFilterName instanceof Name ? subFilterName.name : null;

let signatureType = null;
if (subFilter === "adbe.pkcs7.detached") {
signatureType = 0;
} else if (subFilter === "adbe.pkcs7.sha1") {
signatureType = 1;
}

// Slice the two ByteRange byte spans out of the underlying PDF stream.
// ByteRange = [a, b, c, d] means signed bytes are [a..a+b] and [c..c+d];
// the gap covers the /Contents hex blob itself.
const [a, b, c, d] = byteRange;
const stream = this.stream;
const data = [stream.getByteRange(a, a + b), stream.getByteRange(c, c + d)];

const pkcs7 = stringToBytes(contents);

const t = field.get("T");
const fieldName = typeof t === "string" ? stringToPDFString(t) : "";
const name = sigDict.get("Name");
const reason = sigDict.get("Reason");
const location = sigDict.get("Location");
const contactInfo = sigDict.get("ContactInfo");
const m = sigDict.get("M");

const refKey = fieldRef instanceof Ref ? fieldRef.toString() : "inline";
const id = `${refKey}:${a}-${b}-${c}-${d}`;

const fileLength = stream.end || 0;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if it's correct in general.
@Snuffleupagus could it be wrong if the pdf is from a ranged request ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that PDFDocument is initialized either with a Stream or a ChunkedStream, where the latter extends the former, this part ought to be fine.

However, looking at this now I wonder why stream.end is being used and if that's actually intended?
What is fileLength actually supposed to represent here, is it either:

  • The length of the entire raw file passed to PDF.js?
    If this is the answer, then I suppose that stream.end is appropriate (although quite unexpected, see below).

  • The length of the actual PDF-data contained within the raw file?
    That can be shorter if there's "garbage" in the file before the %PDF- header, which marks the start of the actual PDF-data (in which case stream.moveStart() will update the start-position of the stream).

    Given that all "normal" data in a PDF document is always referenced from the %PDF- header position, naively I'd thus expect that you actually want stream.length here (which is fine as long as you're only dealing with Stream instances).

const lastSignedByte = c + d;

return {
id,
fieldName,
signerName: typeof name === "string" ? stringToPDFString(name) : null,
reason: typeof reason === "string" ? stringToPDFString(reason) : null,
location:
typeof location === "string" ? stringToPDFString(location) : null,
contactInfo:
typeof contactInfo === "string" ? stringToPDFString(contactInfo) : null,
signingTime: typeof m === "string" ? m : null,
filter,
subFilter,
signatureType,
byteRange,
pkcs7,
data,
revisionIndex: 0,
parentId: null,
coversWholeDocument:
fileLength > 0 &&
lastSignedByte >= fileLength - PDFDocument.#WHOLE_DOCUMENT_TAIL_FUZZ,
};
}

get signatures() {
const promise = this.pdfManager
.ensureDoc("formInfo")
.then(async formInfo => {
if (!formInfo.hasSignatures || !formInfo.hasFields) {
return null;
}
const annotationGlobals = await this.annotationGlobals;
if (!annotationGlobals) {
return null;
}
const fields = annotationGlobals.acroForm.get("Fields");

const collected = [];
this.#collectSignatureFields(fields, collected, new RefSet());

// 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
// that came before it.
collected.sort(
(a, b) =>
b.byteRange[2] + b.byteRange[3] - (a.byteRange[2] + a.byteRange[3])
);
for (let i = 0, ii = collected.length; i < ii; i++) {
const sig = collected[i];
sig.revisionIndex = i;
for (let j = i - 1; j >= 0; j--) {
const candidate = collected[j];
if (
candidate.byteRange[2] + candidate.byteRange[3] >
sig.byteRange[2] + sig.byteRange[3]
) {
sig.parentId = candidate.id;
break;
}
}
}
return collected.length ? collected : null;
});

return shadow(this, "signatures", promise);
}

get hasJSActions() {
const promise = this.pdfManager.ensureDoc("_parseHasJSActions");
return shadow(this, "hasJSActions", promise);
Expand Down
4 changes: 4 additions & 0 deletions src/core/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,10 @@ class WorkerMessageHandler {
.then(fieldObjects => fieldObjects?.allFields || null);
});

handler.on("GetSignatures", function (data) {
return pdfManager.ensureDoc("signatures");
});

handler.on("HasJSActions", function (data) {
return pdfManager.ensureDoc("hasJSActions");
});
Expand Down
15 changes: 15 additions & 0 deletions src/display/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,17 @@ class PDFDocumentProxy {
return this._transport.getFieldObjects();
}

/**
* @returns {Promise<Array<Object> | null>} A promise that is resolved
* with an {Array} of digital signatures present in the document
* (each with metadata such as signerName, reason, signingTime,
* ... plus the PKCS#7 blob and signed-data byte spans needed for
* verification), or `null` when the document has no signatures.
*/
getSignatures() {
return this._transport.getSignatures();
}

/**
* @returns {Promise<boolean>} A promise that is resolved with `true`
* if some /AcroForm fields have JavaScript actions.
Expand Down Expand Up @@ -3017,6 +3028,10 @@ class WorkerTransport {
return this.#cacheSimpleMethod("GetFieldObjects");
}

getSignatures() {
return this.#cacheSimpleMethod("GetSignatures");
}

hasJSActions() {
return this.#cacheSimpleMethod("HasJSActions");
}
Expand Down
3 changes: 3 additions & 0 deletions test/pdfs/sig_corpus/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.pdf
*.p7s
*.pkcs7spec
Loading
Loading