Skip to content

Commit 9bc254b

Browse files
beurdoucheclaude
andcommitted
Add Signature Properties verification panel
Adds a toolbar doorhanger that lists every digital signature in the opened PDF, runs verification asynchronously on document load, and reports per-signature trust status with grouped sub-revisions and the certificate chain. Worker (src/core/document.js, worker.js, src/display/api.js): getSignatures() walks /AcroForm/Fields, slices ByteRange detached payloads, maps SubFilter to the PDFSignatureAlgorithm enum, and groups sub-signatures by ByteRange containment. Bridge: web/external_services.js exposes createSignatureVerifier(); web/firefoxcom.js implements it via FirefoxCom.requestAsync against NSS (verifyPdfSignature, viewPdfCertificate); web/genericcom.js + web/generic_signature_verifier.js opt-in via enableSignatureVerification with mock fixtures so the GENERIC build doesn't ship a button that can't return real results. UI: web/signature_properties_manager.js owns the doorhanger DOM, auto-verifies on load, and updates the toolbar button with one of four state icons (loading dots, green verified, orange warn, red error). View certificate opens about:certificate in MOZCENTRAL. Banner severity collapses to verified / warn / error with messages keyed to the actual return-code combination (verified, untrusted, expired, revoked, invalid, unknown). Sub-signatures render recursively; the green status / certificate check is reserved for the top-level card when every signature in the document is verified. The "Certificate: <kind>" parenthetical shows specific information per case: actual expiration date for expired (walking the chain to find the truly-expired entry), issuer CN for unknown-issuer / self-signed / untrusted-issuer, one-word reason for revoked or generic untrusted. Three full-color SVGs ship for the toolbar state icons (verified, warn, error). The loading state uses pure-CSS animated dots. Gated by the enableSignatureVerification AppOption — true in MOZCENTRAL (real NSS path), false in GENERIC by default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e86e9d9 commit 9bc254b

20 files changed

Lines changed: 2035 additions & 1 deletion

gulpfile.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ function createWebpackAlias(defines) {
225225
"web-print_service": "",
226226
"web-secondary_toolbar": "web/secondary_toolbar.js",
227227
"web-signature_manager": "web/signature_manager.js",
228+
"web-signature_properties_manager": "web/signature_properties_manager.js",
228229
"web-toolbar": "web/toolbar.js",
229230
"web-views_manager": "web/views_manager.js",
230231
};

l10n/en-US/viewer.ftl

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,3 +785,101 @@ pdfjs-views-manager-paste-button-after =
785785
pdfjs-new-badge-content = NEW
786786
787787
pdfjs-views-manager-waiting-for-file = Uploading file…
788+
789+
## Signature Properties (digital signature verification)
790+
791+
pdfjs-signature-properties-button =
792+
.title = Signature Properties
793+
.aria-label = Signature Properties
794+
pdfjs-signature-properties-button-label = Signature Properties
795+
796+
# Banner shown above the signature list summarizing the overall state.
797+
pdfjs-signature-properties-banner-verified = Document signed and verified
798+
# Variables:
799+
# $signer (String) - display name of the top-level signer.
800+
pdfjs-signature-properties-banner-verified-with-signer = Document signed by <strong data-l10n-name="signer">{ $signer }</strong>
801+
# Variables:
802+
# $count (Number) - number of signatures whose status is `unknown`.
803+
pdfjs-signature-properties-banner-unknown =
804+
{ $count ->
805+
[one] Document signed but the signature could not be verified
806+
*[other] Document signed but { $count } signatures could not be verified
807+
}
808+
# Variables:
809+
# $count (Number) - number of signatures whose certificate is untrusted.
810+
pdfjs-signature-properties-banner-untrusted =
811+
{ $count ->
812+
[one] Document signed with a certificate that is not trusted
813+
*[other] Document signed with certificates that are not trusted
814+
}
815+
# Variables:
816+
# $count (Number) - number of signatures whose certificate is expired.
817+
pdfjs-signature-properties-banner-expired =
818+
{ $count ->
819+
[one] Document signed with an expired certificate
820+
*[other] Document signed with expired certificates
821+
}
822+
# Variables:
823+
# $count (Number) - number of signatures whose CMS verification failed.
824+
pdfjs-signature-properties-banner-invalid =
825+
{ $count ->
826+
[one] Document has an invalid signature
827+
*[other] Document has invalid signatures
828+
}
829+
# Variables:
830+
# $count (Number) - number of signatures whose certificate is revoked.
831+
pdfjs-signature-properties-banner-revoked =
832+
{ $count ->
833+
[one] Document signed with a revoked certificate
834+
*[other] Document signed with revoked certificates
835+
}
836+
837+
# Per-signature status row.
838+
pdfjs-signature-properties-status-verified = Status: Signature verified
839+
pdfjs-signature-properties-status-unknown = Status: Unable to verify (unsupported)
840+
pdfjs-signature-properties-status-untrusted = Status: Signature verified
841+
pdfjs-signature-properties-status-expired = Status: Signature verified
842+
pdfjs-signature-properties-status-invalid = Status: Signature invalid
843+
pdfjs-signature-properties-status-revoked = Status: Signature verified
844+
845+
# Per-signature certificate row.
846+
# Variables:
847+
# $issuer (String) - issuer common name, e.g. "DigiTrust CA 3"
848+
pdfjs-signature-properties-certificate-trusted = Certificate: Trusted ({ $issuer })
849+
pdfjs-signature-properties-certificate-unknown = Certificate: Unavailable
850+
pdfjs-signature-properties-certificate-untrusted = Certificate: Untrusted
851+
# Variables:
852+
# $reason (String) - one-word reason word, e.g. "self-signed".
853+
pdfjs-signature-properties-certificate-untrusted-with-reason = Certificate: Untrusted ({ $reason })
854+
# Variables:
855+
# $issuer (String) - issuer common name from the leaf certificate.
856+
pdfjs-signature-properties-certificate-untrusted-unknown-issuer = Certificate: Unknown issuer ({ $issuer })
857+
# Variables:
858+
# $issuer (String) - subject common name of the self-signed certificate.
859+
pdfjs-signature-properties-certificate-untrusted-self-signed = Certificate: Self-signed ({ $issuer })
860+
# Variables:
861+
# $issuer (String) - common name of the untrusted issuer.
862+
pdfjs-signature-properties-certificate-untrusted-untrusted-issuer = Certificate: Untrusted issuer ({ $issuer })
863+
pdfjs-signature-properties-certificate-expired = Certificate: Expired
864+
# Variables:
865+
# $reason (String) - locale-formatted notAfter date of the expired cert.
866+
pdfjs-signature-properties-certificate-expired-with-reason = Certificate: Expired ({ $reason })
867+
pdfjs-signature-properties-certificate-revoked = Certificate: Revoked
868+
# Variables:
869+
# $reason (String) - one-word reason word, e.g. "revoked".
870+
pdfjs-signature-properties-certificate-revoked-with-reason = Certificate: Revoked ({ $reason })
871+
872+
pdfjs-signature-properties-view-certificate = View certificate
873+
874+
# Variables:
875+
# $reason (String) - the reason text from the signature dictionary.
876+
pdfjs-signature-properties-reason = Reason: { $reason }
877+
# Variables:
878+
# $timestamp (String) - the formatted signing timestamp.
879+
pdfjs-signature-properties-timestamp = Timestamp: { $timestamp }
880+
# Variables:
881+
# $count (Number) - number of sub-signatures (revisions).
882+
pdfjs-signature-properties-sub-signatures = Sub-signatures ({ $count })
883+
884+
pdfjs-signature-properties-verify = Verify
885+
pdfjs-signature-properties-reverify = Re-verify

src/core/document.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1990,6 +1990,160 @@ class PDFDocument {
19901990
return shadow(this, "fieldObjects", promise);
19911991
}
19921992

1993+
#collectSignatureFields(fields, out, depth) {
1994+
const RECURSION_LIMIT = 10;
1995+
if (depth > RECURSION_LIMIT) {
1996+
warn("#collectSignatureFields: maximum recursion depth reached");
1997+
return;
1998+
}
1999+
if (!Array.isArray(fields)) {
2000+
return;
2001+
}
2002+
for (const fieldRef of fields) {
2003+
const field = this.xref.fetchIfRef(fieldRef);
2004+
if (!(field instanceof Dict)) {
2005+
continue;
2006+
}
2007+
if (field.has("Kids")) {
2008+
this.#collectSignatureFields(field.get("Kids"), out, depth + 1);
2009+
continue;
2010+
}
2011+
if (!isName(field.get("FT"), "Sig")) {
2012+
continue;
2013+
}
2014+
const sigDict = this.xref.fetchIfRef(field.get("V"));
2015+
if (!(sigDict instanceof Dict)) {
2016+
continue;
2017+
}
2018+
const parsed = this.#parseSignatureDict(field, sigDict, fieldRef);
2019+
if (parsed) {
2020+
out.push(parsed);
2021+
}
2022+
}
2023+
}
2024+
2025+
#parseSignatureDict(field, sigDict, fieldRef) {
2026+
const byteRange = sigDict.get("ByteRange");
2027+
if (
2028+
!Array.isArray(byteRange) ||
2029+
byteRange.length !== 4 ||
2030+
byteRange.some(n => !Number.isInteger(n) || n < 0)
2031+
) {
2032+
return null;
2033+
}
2034+
const contents = sigDict.get("Contents");
2035+
if (typeof contents !== "string" || contents.length === 0) {
2036+
return null;
2037+
}
2038+
2039+
const filterName = sigDict.get("Filter");
2040+
const filter = filterName instanceof Name ? filterName.name : null;
2041+
const subFilterName = sigDict.get("SubFilter");
2042+
const subFilter = subFilterName instanceof Name ? subFilterName.name : null;
2043+
2044+
let signatureType = null;
2045+
if (subFilter === "adbe.pkcs7.detached") {
2046+
signatureType = 0;
2047+
} else if (subFilter === "adbe.pkcs7.sha1") {
2048+
signatureType = 1;
2049+
}
2050+
2051+
// Slice the two ByteRange byte spans out of the underlying PDF stream.
2052+
// ByteRange = [a, b, c, d] means signed bytes are [a..a+b] and [c..c+d];
2053+
// the gap covers the /Contents hex blob itself.
2054+
const [a, b, c, d] = byteRange;
2055+
const stream = this.stream;
2056+
const data = [
2057+
new Uint8Array(stream.getByteRange(a, a + b)),
2058+
new Uint8Array(stream.getByteRange(c, c + d)),
2059+
];
2060+
2061+
const pkcs7 = stringToBytes(contents);
2062+
2063+
const t = field.get("T");
2064+
const fieldName = typeof t === "string" ? stringToPDFString(t) : "";
2065+
const name = sigDict.get("Name");
2066+
const reason = sigDict.get("Reason");
2067+
const location = sigDict.get("Location");
2068+
const contactInfo = sigDict.get("ContactInfo");
2069+
const m = sigDict.get("M");
2070+
2071+
const refKey =
2072+
fieldRef instanceof Ref ? `${fieldRef.num}.${fieldRef.gen}` : "inline";
2073+
const id = `${refKey}:${a}-${b}-${c}-${d}`;
2074+
2075+
const fileLength = stream.end || 0;
2076+
const lastSignedByte = c + d;
2077+
2078+
return {
2079+
id,
2080+
fieldName,
2081+
signerName: typeof name === "string" ? stringToPDFString(name) : null,
2082+
reason: typeof reason === "string" ? stringToPDFString(reason) : null,
2083+
location:
2084+
typeof location === "string" ? stringToPDFString(location) : null,
2085+
contactInfo:
2086+
typeof contactInfo === "string" ? stringToPDFString(contactInfo) : null,
2087+
signingTime: typeof m === "string" ? m : null,
2088+
filter,
2089+
subFilter,
2090+
signatureType,
2091+
byteRange,
2092+
pkcs7,
2093+
data,
2094+
revisionIndex: 0,
2095+
parentId: null,
2096+
coversWholeDocument: fileLength > 0 && lastSignedByte >= fileLength - 100,
2097+
};
2098+
}
2099+
2100+
get signatures() {
2101+
const promise = this.pdfManager
2102+
.ensureDoc("formInfo")
2103+
.then(async formInfo => {
2104+
if (!formInfo.hasSignatures) {
2105+
return [];
2106+
}
2107+
const annotationGlobals = await this.annotationGlobals;
2108+
if (!annotationGlobals) {
2109+
return [];
2110+
}
2111+
const fields = annotationGlobals.acroForm.get("Fields");
2112+
if (!Array.isArray(fields) || fields.length === 0) {
2113+
return [];
2114+
}
2115+
2116+
const collected = [];
2117+
this.#collectSignatureFields(fields, collected, 0);
2118+
2119+
// Group sub-signatures by ByteRange containment: outer revision is
2120+
// the largest covering signature (largest c + d). Sort descending,
2121+
// then point each later signature at the smallest enclosing parent
2122+
// that came before it.
2123+
collected.sort(
2124+
(a, b) =>
2125+
b.byteRange[2] + b.byteRange[3] - (a.byteRange[2] + a.byteRange[3])
2126+
);
2127+
for (let i = 0; i < collected.length; i++) {
2128+
const sig = collected[i];
2129+
sig.revisionIndex = i;
2130+
for (let j = i - 1; j >= 0; j--) {
2131+
const candidate = collected[j];
2132+
if (
2133+
candidate.byteRange[2] + candidate.byteRange[3] >
2134+
sig.byteRange[2] + sig.byteRange[3]
2135+
) {
2136+
sig.parentId = candidate.id;
2137+
break;
2138+
}
2139+
}
2140+
}
2141+
return collected;
2142+
});
2143+
2144+
return shadow(this, "signatures", promise);
2145+
}
2146+
19932147
get hasJSActions() {
19942148
const promise = this.pdfManager.ensureDoc("_parseHasJSActions");
19952149
return shadow(this, "hasJSActions", promise);

src/core/worker.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,10 @@ class WorkerMessageHandler {
549549
.then(fieldObjects => fieldObjects?.allFields || null);
550550
});
551551

552+
handler.on("GetSignatures", function (data) {
553+
return pdfManager.ensureDoc("signatures");
554+
});
555+
552556
handler.on("HasJSActions", function (data) {
553557
return pdfManager.ensureDoc("hasJSActions");
554558
});

src/display/api.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,17 @@ class PDFDocumentProxy {
10741074
return this._transport.getFieldObjects();
10751075
}
10761076

1077+
/**
1078+
* @returns {Promise<Array<Object>>} A promise that is resolved with an
1079+
* {Array} of digital signatures present in the document, each with
1080+
* metadata (signerName, reason, signingTime, ...) plus the PKCS#7
1081+
* blob and signed-data byte spans needed for verification. Returns
1082+
* an empty array when no signatures are present.
1083+
*/
1084+
getSignatures() {
1085+
return this._transport.getSignatures();
1086+
}
1087+
10771088
/**
10781089
* @returns {Promise<boolean>} A promise that is resolved with `true`
10791090
* if some /AcroForm fields have JavaScript actions.
@@ -3017,6 +3028,10 @@ class WorkerTransport {
30173028
return this.#cacheSimpleMethod("GetFieldObjects");
30183029
}
30193030

3031+
getSignatures() {
3032+
return this.#cacheSimpleMethod("GetSignatures");
3033+
}
3034+
30203035
hasJSActions() {
30213036
return this.#cacheSimpleMethod("HasJSActions");
30223037
}

0 commit comments

Comments
 (0)