From f53152d45abf0eb569b42ac7ecb609731c5055fb Mon Sep 17 00:00:00 2001 From: Benjamin Beurdouche Date: Sun, 10 May 2026 10:26:50 +0200 Subject: [PATCH 1/6] Add Signature Properties verification panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: " 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) --- gulpfile.mjs | 1 + l10n/en-US/viewer.ftl | 87 +++ src/core/document.js | 148 ++++ src/core/worker.js | 4 + src/display/api.js | 15 + test/unit/document_spec.js | 206 ++++++ web/app.js | 39 +- web/app_options.js | 9 + web/external_services.js | 22 + web/firefoxcom.js | 138 ++++ ...toolbarButton-signaturePropertiesError.svg | 4 + ...lbarButton-signaturePropertiesVerified.svg | 4 + .../toolbarButton-signaturePropertiesWarn.svg | 4 + web/signature_properties.css | 384 ++++++++++ web/signature_properties_manager.js | 682 ++++++++++++++++++ web/stubs-geckoview.js | 2 + web/viewer.css | 4 + web/viewer.html | 21 + web/viewer.js | 15 + 19 files changed, 1788 insertions(+), 1 deletion(-) create mode 100644 web/images/toolbarButton-signaturePropertiesError.svg create mode 100644 web/images/toolbarButton-signaturePropertiesVerified.svg create mode 100644 web/images/toolbarButton-signaturePropertiesWarn.svg create mode 100644 web/signature_properties.css create mode 100644 web/signature_properties_manager.js diff --git a/gulpfile.mjs b/gulpfile.mjs index 6bc953fbaf80b..8535ce3e0ee79 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -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", }; diff --git a/l10n/en-US/viewer.ftl b/l10n/en-US/viewer.ftl index 3186b9a4d9edb..dd9c80f0ffb29 100644 --- a/l10n/en-US/viewer.ftl +++ b/l10n/en-US/viewer.ftl @@ -785,3 +785,90 @@ 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 + .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 signature +pdfjs-signature-properties-banner-unknown = + { $count -> + [one] Document signed but { $count } signature could not be verified + *[other] Document signed but { $count } 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 signature + *[other] Document has { $count } invalid 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 "with-…" variants embed extra +## context inside the parentheses. +## +## Variables: +## $issuer (String) - issuer or subject common name from the cert. +## $reason (String) - one-word reason for the failure (e.g. +## "revoked", "self-signed"). +## $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-with-reason = Certificate: Untrusted ({ $reason }) +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-certificate-revoked-with-reason = Certificate: Revoked ({ $reason }) + +## + +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 }) diff --git a/src/core/document.js b/src/core/document.js index e6d61acfb544f..abfc42e690b7c 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -1990,6 +1990,154 @@ 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); + } + } + } + + #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; + 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 - 100, + }; + } + + 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); diff --git a/src/core/worker.js b/src/core/worker.js index 9dd67e7839abf..c193edfe122e1 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -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"); }); diff --git a/src/display/api.js b/src/display/api.js index 1fa44ddeb7e69..8af18fa9cf1a2 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1074,6 +1074,17 @@ class PDFDocumentProxy { return this._transport.getFieldObjects(); } + /** + * @returns {Promise | 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} A promise that is resolved with `true` * if some /AcroForm fields have JavaScript actions. @@ -3017,6 +3028,10 @@ class WorkerTransport { return this.#cacheSimpleMethod("GetFieldObjects"); } + getSignatures() { + return this.#cacheSimpleMethod("GetSignatures"); + } + hasJSActions() { return this.#cacheSimpleMethod("HasJSActions"); } diff --git a/test/unit/document_spec.js b/test/unit/document_spec.js index b0224e8821d59..7cfc4319783f5 100644 --- a/test/unit/document_spec.js +++ b/test/unit/document_spec.js @@ -186,6 +186,212 @@ describe("document", function () { }); }); + describe("getSignatures", function () { + function makeSigDict({ + byteRange, + contents = "00".repeat(8), + subFilter = "adbe.pkcs7.detached", + name = null, + reason = null, + location = null, + m = null, + }) { + const dict = new Dict(); + dict.set("Type", Name.get("Sig")); + dict.set("Filter", Name.get("Adobe.PPKLite")); + dict.set("SubFilter", Name.get(subFilter)); + dict.set("ByteRange", byteRange); + dict.set("Contents", contents); + if (name !== null) { + dict.set("Name", name); + } + if (reason !== null) { + dict.set("Reason", reason); + } + if (location !== null) { + dict.set("Location", location); + } + if (m !== null) { + dict.set("M", m); + } + return dict; + } + + function makeSigField({ T, sigRef }) { + const dict = new Dict(); + dict.set("FT", Name.get("Sig")); + dict.set("T", T); + dict.set("V", sigRef); + return dict; + } + + it("returns an empty array when no signatures are present", async function () { + const acroForm = new Dict(); + const pdfDocument = getDocument(acroForm); + const signatures = await pdfDocument.signatures; + expect(signatures).toEqual([]); + }); + + it("extracts metadata for a single signature", async function () { + const acroForm = new Dict(); + acroForm.set("SigFlags", 3); + + const sigRef = Ref.get(20, 0); + const fieldRef = Ref.get(21, 0); + const sigDict = makeSigDict({ + byteRange: [0, 100, 200, 300], + name: "Alice Becker", + reason: "Approved for release", + m: "D:20251014103200+00'00'", + }); + const fieldDict = makeSigField({ T: "sig_alice", sigRef }); + + const xref = new XRefMock([ + { ref: sigRef, data: sigDict }, + { ref: fieldRef, data: fieldDict }, + ]); + acroForm.set("Fields", [fieldRef]); + + const pdfDocument = getDocument(acroForm, xref); + const signatures = await pdfDocument.signatures; + expect(signatures.length).toEqual(1); + const [sig] = signatures; + expect(sig.signerName).toEqual("Alice Becker"); + expect(sig.reason).toEqual("Approved for release"); + expect(sig.signingTime).toEqual("D:20251014103200+00'00'"); + expect(sig.fieldName).toEqual("sig_alice"); + expect(sig.subFilter).toEqual("adbe.pkcs7.detached"); + expect(sig.signatureType).toEqual(0); + expect(sig.byteRange).toEqual([0, 100, 200, 300]); + expect(sig.parentId).toEqual(null); + expect(sig.revisionIndex).toEqual(0); + expect(sig.pkcs7).toBeInstanceOf(Uint8Array); + }); + + it("walks Kids recursively to find nested signature fields", async function () { + const acroForm = new Dict(); + acroForm.set("SigFlags", 3); + + const sigRef = Ref.get(30, 0); + const sigFieldRef = Ref.get(31, 0); + const containerRef = Ref.get(32, 0); + + const sigDict = makeSigDict({ + byteRange: [0, 50, 100, 150], + name: "John Smith", + }); + const sigField = makeSigField({ T: "sig_john", sigRef }); + const container = new Dict(); + container.set("Kids", [sigFieldRef]); + + const xref = new XRefMock([ + { ref: sigRef, data: sigDict }, + { ref: sigFieldRef, data: sigField }, + { ref: containerRef, data: container }, + ]); + acroForm.set("Fields", [containerRef]); + + const pdfDocument = getDocument(acroForm, xref); + const signatures = await pdfDocument.signatures; + expect(signatures.length).toEqual(1); + expect(signatures[0].signerName).toEqual("John Smith"); + }); + + it("skips signatures with malformed ByteRange", 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, 100] }); // wrong length + const fieldDict = makeSigField({ T: "bad", sigRef }); + + const xref = new XRefMock([ + { ref: sigRef, data: sigDict }, + { ref: fieldRef, data: fieldDict }, + ]); + acroForm.set("Fields", [fieldRef]); + + const pdfDocument = getDocument(acroForm, xref); + expect(await pdfDocument.signatures).toEqual([]); + }); + + it("groups sub-signatures under the outer revision", async function () { + const acroForm = new Dict(); + acroForm.set("SigFlags", 3); + + // Outer covers more bytes (c+d larger) → parent. + // Inner covers fewer bytes → sub-signature of outer. + const outerSigRef = Ref.get(50, 0); + const outerFieldRef = Ref.get(51, 0); + const innerSigRef = Ref.get(52, 0); + const innerFieldRef = Ref.get(53, 0); + + const outerSig = makeSigDict({ + byteRange: [0, 100, 200, 800], + name: "Outer", + }); + const innerSig = makeSigDict({ + byteRange: [0, 50, 100, 200], + name: "Inner", + }); + + const xref = new XRefMock([ + { ref: outerSigRef, data: outerSig }, + { + ref: outerFieldRef, + data: makeSigField({ T: "outer", sigRef: outerSigRef }), + }, + { ref: innerSigRef, data: innerSig }, + { + ref: innerFieldRef, + data: makeSigField({ T: "inner", sigRef: innerSigRef }), + }, + ]); + acroForm.set("Fields", [outerFieldRef, innerFieldRef]); + + const pdfDocument = getDocument(acroForm, xref); + const signatures = await pdfDocument.signatures; + expect(signatures.length).toEqual(2); + // Sorted descending by c+d, so outer comes first. + expect(signatures[0].signerName).toEqual("Outer"); + expect(signatures[0].parentId).toEqual(null); + expect(signatures[0].revisionIndex).toEqual(0); + expect(signatures[1].signerName).toEqual("Inner"); + expect(signatures[1].parentId).toEqual(signatures[0].id); + expect(signatures[1].revisionIndex).toEqual(1); + }); + + it("maps SubFilter to the PDFSignatureAlgorithm enum", async function () { + const acroForm = new Dict(); + acroForm.set("SigFlags", 3); + + async function signatureType(subFilter) { + const sigRef = Ref.get(60, 0); + const fieldRef = Ref.get(61, 0); + const sigDict = makeSigDict({ + byteRange: [0, 10, 20, 30], + subFilter, + }); + const xref = new XRefMock([ + { ref: sigRef, data: sigDict }, + { + ref: fieldRef, + data: makeSigField({ T: "sig", sigRef }), + }, + ]); + acroForm.set("Fields", [fieldRef]); + const pdfDocument = getDocument(acroForm, xref); + const [sig] = await pdfDocument.signatures; + return sig.signatureType; + } + + expect(await signatureType("adbe.pkcs7.detached")).toEqual(0); + expect(await signatureType("adbe.pkcs7.sha1")).toEqual(1); + expect(await signatureType("ETSI.CAdES.detached")).toEqual(null); + }); + }); + it("should get calculation order array or null", function () { const acroForm = new Dict(); diff --git a/web/app.js b/web/app.js index e630ac5d52042..fe36ede56ba50 100644 --- a/web/app.js +++ b/web/app.js @@ -93,6 +93,7 @@ import { Preferences } from "web-preferences"; import { RenderingStates } from "./renderable_view.js"; import { SecondaryToolbar } from "web-secondary_toolbar"; import { SignatureManager } from "web-signature_manager"; +import { SignaturePropertiesManager } from "web-signature_properties_manager"; import { Toolbar } from "web-toolbar"; import { ViewHistory } from "./view_history.js"; import { ViewsManager } from "web-views_manager"; @@ -163,6 +164,8 @@ const PDFViewerApplication = { l10n: null, /** @type {AnnotationEditorParams} */ annotationEditorParams: null, + /** @type {SignaturePropertiesManager|null} */ + signaturePropertiesManager: null, /** @type {ImageAltTextSettings} */ imageAltTextSettings: null, isInitialViewSet: false, @@ -1171,6 +1174,7 @@ const PDFViewerApplication = { this.pdfViewer.setDocument(null); this.pdfLinkService.setDocument(null); this.pdfDocumentProperties?.setDocument(null); + this.signaturePropertiesManager?.reset(); } this.pdfLinkService.externalLinkEnabled = true; this.store = null; @@ -1808,12 +1812,34 @@ const PDFViewerApplication = { } if (info.IsSignaturesPresent) { - console.warn("Warning: Digital signatures validation is not supported"); + this._maybeInitSignatureProperties(pdfDocument); } this.eventBus.dispatch("metadataloaded", { source: this }); }, + /** + * @private + */ + async _maybeInitSignatureProperties(pdfDocument) { + if (!AppOptions.get("enableSignatureVerification")) { + return; + } + const verifier = this.externalServices.createSignatureVerifier?.(); + if (!verifier) { + return; + } + if (pdfDocument !== this.pdfDocument) { + return; + } + this.signaturePropertiesManager ??= new SignaturePropertiesManager({ + appConfig: this.appConfig.toolbar, + verifier, + eventBus: this.eventBus, + }); + this.signaturePropertiesManager.loadFromDocument(pdfDocument); + }, + /** * @private */ @@ -2935,6 +2961,12 @@ function closeEditorUndoBar(evt) { } } +function closeSignatureProperties({ target }) { + if (this.signaturePropertiesManager?.shouldCloseOnClick(target)) { + this.signaturePropertiesManager.close(); + } +} + function onBeforeUnload(evt) { if (this._hasChanges()) { evt.preventDefault(); @@ -2947,6 +2979,7 @@ function onBeforeUnload(evt) { function onClick(evt) { closeSecondaryToolbar.call(this, evt); closeEditorUndoBar.call(this, evt); + closeSignatureProperties.call(this, evt); } function onKeyUp(evt) { @@ -3162,6 +3195,10 @@ function onKeyDown(evt) { this.secondaryToolbar.close(); handled = true; } + if (this.signaturePropertiesManager?.isOpen) { + this.signaturePropertiesManager.close(); + handled = true; + } if (!this.supportsIntegratedFind && this.findBar?.opened) { this.findBar.close(); handled = true; diff --git a/web/app_options.js b/web/app_options.js index 06b9b3dc58543..1a3182820dc31 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -272,6 +272,15 @@ const defaultOptions = { value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + enableSignatureVerification: { + // On in MOZCENTRAL (NSS provides the verification path) and in + // local dev builds; off in shipping GENERIC builds, where no + // verifier is wired up by default. + /** @type {boolean} */ + value: + typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING || MOZCENTRAL"), + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, enableSplitMerge: { /** @type {boolean} */ value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), diff --git a/web/external_services.js b/web/external_services.js index 74642af48d850..3b3b260f56500 100644 --- a/web/external_services.js +++ b/web/external_services.js @@ -48,6 +48,28 @@ class BaseExternalServices { throw new Error("Not implemented: createSignatureStorage"); } + /** + * Build a signature verifier for the Signature Properties panel. + * + * The MOZCENTRAL build returns a verifier that calls into NSS via + * the chrome bridge. The default GENERIC implementation returns + * `null` — there is no portable cryptographic verification path + * outside Firefox, so the toolbar button stays hidden and the + * worker is never asked for `getSignatures()`. + * + * Downstream consumers of `pdfjs-dist` that want the Signature + * Properties UI should subclass `BaseExternalServices` and return + * an object exposing `verify(signature)` (and optionally + * `viewCertificate(certificate)`) that resolves to a + * `VerificationResult` — see `web/firefoxcom.js` for the exact + * shape. + * + * @returns {Object|null} + */ + createSignatureVerifier() { + return null; + } + updateEditorStates(data) { throw new Error("Not implemented: updateEditorStates"); } diff --git a/web/firefoxcom.js b/web/firefoxcom.js index b12645574933d..19812a4bbb8c7 100644 --- a/web/firefoxcom.js +++ b/web/firefoxcom.js @@ -529,6 +529,140 @@ class SignatureStorage { } } +// `nsresult` codes from PSM/NSS that we recognize. Names mirror +// Firefox's nsINSSErrorsService and security/nss codes; values aren't +// stable, so we compare strings instead of integers. +const NSS_ERR_CODES = { + EXPIRED: new Set([ + "SEC_ERROR_EXPIRED_CERTIFICATE", + "SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE", + "MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE", + "MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE", + ]), + REVOKED: new Set([ + "SEC_ERROR_REVOKED_CERTIFICATE", + "SEC_ERROR_REVOKED_KEY", + "MOZILLA_PKIX_ERROR_OCSP_RESPONSE_FOR_CERT_MISSING", + ]), + UNTRUSTED: new Set([ + "SEC_ERROR_UNKNOWN_ISSUER", + "SEC_ERROR_UNTRUSTED_CERT", + "SEC_ERROR_UNTRUSTED_ISSUER", + "MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT", + "MOZILLA_PKIX_ERROR_ADDITIONAL_POLICY_CONSTRAINT_FAILED", + ]), + CMS_NOT_YET_ATTEMPTED: "NS_ERROR_CMS_VERIFY_NOT_YET_ATTEMPTED", +}; + +function mapVerificationStatus(signatureCode, certificateCode) { + if (signatureCode === NSS_ERR_CODES.CMS_NOT_YET_ATTEMPTED) { + return { status: "unknown", errorCode: signatureCode }; + } + if (signatureCode && signatureCode !== "NS_OK") { + return { status: "invalid", errorCode: signatureCode }; + } + if (!certificateCode || certificateCode === "NS_OK") { + return { status: "verified", errorCode: null }; + } + if (NSS_ERR_CODES.REVOKED.has(certificateCode)) { + return { status: "revoked", errorCode: certificateCode }; + } + if (NSS_ERR_CODES.EXPIRED.has(certificateCode)) { + return { status: "expired", errorCode: certificateCode }; + } + if (NSS_ERR_CODES.UNTRUSTED.has(certificateCode)) { + return { status: "untrusted", errorCode: certificateCode }; + } + return { status: "untrusted", errorCode: certificateCode }; +} + +class SignatureVerifier { + async verify(signature) { + if (signature.signatureType === null) { + return { + signatureId: signature.id, + status: "unknown", + errorCode: "SUBFILTER_NOT_SUPPORTED", + message: signature.subFilter, + certificate: null, + documentModifiedAfterSigning: !signature.coversWholeDocument, + }; + } + + let response; + try { + response = await FirefoxCom.requestAsync("verifyPdfSignature", { + pkcs7: signature.pkcs7, + data: signature.data, + signatureType: signature.signatureType, + }); + } catch (ex) { + return { + signatureId: signature.id, + status: "unknown", + errorCode: "BRIDGE_ERROR", + message: ex?.message ?? null, + certificate: null, + documentModifiedAfterSigning: !signature.coversWholeDocument, + }; + } + if (!response || response.error) { + return { + signatureId: signature.id, + status: "unknown", + errorCode: response?.error ?? "EMPTY_RESPONSE", + message: null, + certificate: null, + documentModifiedAfterSigning: !signature.coversWholeDocument, + }; + } + + // The chrome side returns an Array, but for + // a single PKCS#7 input it has exactly one entry. + const entry = Array.isArray(response) ? response[0] : response; + if (!entry) { + return { + signatureId: signature.id, + status: "unknown", + errorCode: "EMPTY_RESPONSE", + message: null, + certificate: null, + documentModifiedAfterSigning: !signature.coversWholeDocument, + }; + } + const { status, errorCode } = mapVerificationStatus( + entry.signatureResult, + entry.certificateResult + ); + return { + signatureId: signature.id, + status, + errorCode, + message: entry.message ?? null, + certificate: entry.certificate ?? null, + documentModifiedAfterSigning: !signature.coversWholeDocument, + }; + } + + async viewCertificate(certificate) { + if (!certificate) { + return false; + } + const certs = + Array.isArray(certificate.chain) && certificate.chain.length + ? certificate.chain.map(c => c.derBase64).filter(Boolean) + : [certificate.derBase64].filter(Boolean); + if (certs.length === 0) { + return false; + } + try { + return await FirefoxCom.requestAsync("viewPdfCertificate", { certs }); + } catch { + return false; + } + } +} + class ExternalServices extends BaseExternalServices { updateFindControlState(data) { FirefoxCom.request("updateFindControlState", data); @@ -620,6 +754,10 @@ class ExternalServices extends BaseExternalServices { return new SignatureStorage(eventBus, signal); } + createSignatureVerifier() { + return new SignatureVerifier(); + } + dispatchGlobalEvent(event) { FirefoxCom.request("dispatchGlobalEvent", event); } diff --git a/web/images/toolbarButton-signaturePropertiesError.svg b/web/images/toolbarButton-signaturePropertiesError.svg new file mode 100644 index 0000000000000..fa84e90f1fcd5 --- /dev/null +++ b/web/images/toolbarButton-signaturePropertiesError.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/images/toolbarButton-signaturePropertiesVerified.svg b/web/images/toolbarButton-signaturePropertiesVerified.svg new file mode 100644 index 0000000000000..6ac9d68ea2192 --- /dev/null +++ b/web/images/toolbarButton-signaturePropertiesVerified.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/images/toolbarButton-signaturePropertiesWarn.svg b/web/images/toolbarButton-signaturePropertiesWarn.svg new file mode 100644 index 0000000000000..f41a1dc587cd0 --- /dev/null +++ b/web/images/toolbarButton-signaturePropertiesWarn.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/signature_properties.css b/web/signature_properties.css new file mode 100644 index 0000000000000..5849900c0a0d6 --- /dev/null +++ b/web/signature_properties.css @@ -0,0 +1,384 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* Signature Properties panel. + * + * Floating doorhanger anchored to #signaturePropertiesButton. Lists every + * signature in the open PDF as a card, with a banner summarising the + * overall verification state. + */ + +#signaturePropertiesButton { + /* Default (no state class yet): use the regular signature icon. */ + &::before { + mask-image: var(--toolbarButton-editorSignature-icon); + } + + /* When a verification state is set, switch to a full-color SVG. The + * base .toolbarButton::before still applies width / height, but the + * mask is cleared and background-color goes transparent so the + * SVG's own colours show. */ + &.state-verified::before, + &.state-warn::before, + &.state-error::before { + mask-image: none; + background-color: transparent; + background-position: center; + background-repeat: no-repeat; + background-size: contain; + opacity: 1; + } + &.state-verified::before { + background-image: var(--toolbarButton-signaturePropertiesVerified-icon); + } + &.state-warn::before { + background-image: var(--toolbarButton-signaturePropertiesWarn-icon); + } + &.state-error::before { + background-image: var(--toolbarButton-signaturePropertiesError-icon); + } + + /* Loading state: three dots that pulse in sequence. We replace the + * icon with dots drawn via CSS so it animates without touching the + * SVG layer. */ + &.state-loading::before { + mask-image: none; + background-color: transparent; + display: inline-flex; + align-items: center; + justify-content: space-between; + width: 18px; + height: var(--icon-size); + padding: 0 1px; + box-sizing: border-box; + opacity: 1; + content: ""; + background-repeat: no-repeat; + background-size: + 6px 16px, + 6px 16px, + 6px 16px; + background-position: + 0 0, + 6px 0, + 12px 0; + color: var(--toolbar-icon-bg-color); + animation: signaturePropertiesDots 1.2s linear infinite; + } +} + +@keyframes signaturePropertiesDots { + 0% { + background-image: + radial-gradient( + circle 2px at 3px 8px, + currentcolor 95%, + transparent 100% + ), + radial-gradient( + circle 2px at 9px 8px, + color-mix(in srgb, currentcolor 35%, transparent) 95%, + transparent 100% + ), + radial-gradient( + circle 2px at 15px 8px, + color-mix(in srgb, currentcolor 35%, transparent) 95%, + transparent 100% + ); + } + 33% { + background-image: + radial-gradient( + circle 2px at 3px 8px, + color-mix(in srgb, currentcolor 35%, transparent) 95%, + transparent 100% + ), + radial-gradient( + circle 2px at 9px 8px, + currentcolor 95%, + transparent 100% + ), + radial-gradient( + circle 2px at 15px 8px, + color-mix(in srgb, currentcolor 35%, transparent) 95%, + transparent 100% + ); + } + 66% { + background-image: + radial-gradient( + circle 2px at 3px 8px, + color-mix(in srgb, currentcolor 35%, transparent) 95%, + transparent 100% + ), + radial-gradient( + circle 2px at 9px 8px, + color-mix(in srgb, currentcolor 35%, transparent) 95%, + transparent 100% + ), + radial-gradient( + circle 2px at 15px 8px, + currentcolor 95%, + transparent 100% + ); + } +} + +#signaturePropertiesPanel { + width: 320px; + padding: 0; +} + +.signaturePropertiesContainer { + display: flex; + flex-direction: column; + max-height: 70vh; + overflow: hidden; +} + +.sigBanner { + margin: 12px 12px 8px; + padding: 10px 12px; + border-radius: 6px; + display: flex; + align-items: center; + gap: 10px; + font-size: 12.5px; + line-height: 1.35; + border-inline-start: 3px solid currentcolor; + + &.verified { + background: rgb(228 247 235); + color: rgb(16 92 47); + } + &.warn { + background: rgb(255 247 217); + color: rgb(124 84 9); + } + &.error { + background: rgb(254 226 235); + color: rgb(167 26 70); + } +} + +.signaturePropertiesList { + list-style: none; + margin: 0; + padding: 0 12px 12px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; +} + +.sigCard { + border: 1px solid rgb(228 228 232); + border-radius: 6px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 3px; + background: var(--field-bg-color, white); +} + +.sigCard__signer { + font-weight: 600; + font-size: 13px; + letter-spacing: 0.1px; + margin-bottom: 1px; +} + +.sigCard__row { + display: flex; + flex-wrap: nowrap; + gap: 2px 6px; + /* Icon aligns to the first line of multi-line text, not the centre + * of the wrapped block. */ + align-items: flex-start; + font-size: 12px; + color: rgb(58 58 60); + min-height: 18px; + + > span { + flex: 1 1 auto; + /* Allow the span to shrink below its intrinsic min-content width + * so long text wraps inside the row instead of pushing the whole + * label below the icon. */ + min-width: 0; + overflow-wrap: break-word; + } + + &::before { + content: ""; + display: inline-block; + width: 14px; + height: 14px; + flex-shrink: 0; + border-radius: 50%; + /* Keep the icon at the same vertical rhythm as a single line of + * text so it visually pairs with the first row of the wrapped + * label. */ + margin-top: 1px; + background: rgb(150 150 150) center / 10px no-repeat; + } + + /* Default "everything-OK" check is grey: the signature crypto + * verified, but we don't want a strong green endorsement just + * because one row is fine. */ + &.status--verified::before, + &.status--untrusted::before, + &.status--expired::before, + &.status--revoked::before, + &.cert--trusted::before { + background-color: transparent; + background-image: url("data:image/svg+xml;utf8,"); + background-size: contain; + } + &.cert--untrusted::before, + &.cert--expired::before { + background-color: transparent; + background-image: var(--toolbarButton-signaturePropertiesWarn-icon); + background-size: contain; + } + &.cert--revoked::before, + &.status--invalid::before, + &.status--unknown::before, + &.cert--unknown::before { + background-color: transparent; + background-image: var(--toolbarButton-signaturePropertiesError-icon); + background-size: contain; + } +} + +/* Promote to a real green tick only on the top-level card AND only when + * every signature in the document is verified. The .sigCard--top-allfine + * class is set in #render. */ +.sigCard--top-allfine > .sigCard__row.status--verified::before, +.sigCard--top-allfine > .sigCard__row.cert--trusted::before { + background-image: url("data:image/svg+xml;utf8,"); +} + +.sigCard__detail { + font-size: 11.5px; + color: rgb(96 96 96); + margin-inline-start: 20px; + line-height: 1.35; +} + +.sigCard__viewCert { + align-self: center; + margin-top: 4px; + color: rgb(28 113 216); + background: none; + border: none; + cursor: pointer; + font-size: 12px; + padding: 2px 4px; + border-radius: 4px; + white-space: nowrap; + + &:hover { + background: rgb(28 113 216 / 0.1); + text-decoration: underline; + } + &:focus-visible { + outline: 2px solid rgb(28 113 216); + outline-offset: 1px; + } +} + +.sigCard__subSignatures { + margin-top: 4px; + border-top: 1px dashed rgb(228 228 232); + padding-top: 4px; + font-size: 12px; + + > summary { + cursor: pointer; + user-select: none; + list-style: none; + color: rgb(96 96 96); + display: flex; + align-items: center; + gap: 4px; + padding: 2px 0; + + &:hover { + color: rgb(28 67 138); + } + &::-webkit-details-marker { + display: none; + } + &::before { + content: ""; + display: inline-block; + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-inline-start: 5px solid currentcolor; + transition: transform 0.15s; + } + } + &[open] > summary::before { + transform: rotate(90deg); + } +} + +.sigCard__nested, +.sigCard__subSignatures > .signaturePropertiesList { + padding: 4px 0 2px; + margin-inline-start: 0; + gap: 4px; +} + +.sigCard__nested { + margin-top: 4px; +} + +.sigCard__subSignatures .sigCard, +.sigCard__nested .sigCard { + padding: 6px 8px; + background: rgb(252 252 253); + gap: 2px; +} + +.sigCard__subSignatures .sigCard__signer, +.sigCard__nested .sigCard__signer { + font-size: 12px; + font-weight: 500; +} + +.sigCard__skeleton { + height: 14px; + background: linear-gradient( + 90deg, + rgb(238 238 238) 0%, + rgb(248 248 248) 50%, + rgb(238 238 238) 100% + ); + border-radius: 4px; + animation: sigSkeleton 1.2s infinite; +} + +@keyframes sigSkeleton { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} diff --git a/web/signature_properties_manager.js b/web/signature_properties_manager.js new file mode 100644 index 0000000000000..ed8ac09645fc8 --- /dev/null +++ b/web/signature_properties_manager.js @@ -0,0 +1,682 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnnotationEditorType, PDFDateString } from "pdfjs-lib"; + +const STATUS_PRIORITY = { + invalid: 5, + revoked: 4, + expired: 3, + untrusted: 2, + unknown: 1, + verified: 0, +}; + +function bannerStateForResults(results) { + if (results.length === 0) { + return { worst: "unknown", severity: "error", count: 0 }; + } + let worst = "verified"; + for (const r of results) { + if (r && r.status && STATUS_PRIORITY[r.status] > STATUS_PRIORITY[worst]) { + worst = r.status; + } + } + // Count how many signatures are at the worst level — this drives the + // singular/plural variant of the banner message. + let count = 0; + for (const r of results) { + if (r?.status === worst) { + count++; + } + } + // The banner reduces to 3 visual severities. The message text still picks + // the wording specific to `worst`. + // verified → green (all signatures verified) + // untrusted / expired → orange (signature is cryptographically fine, + // only cert trust or validity is the + // issue) + // invalid / unknown → red (signature itself failed or could not + // be checked) + let severity; + switch (worst) { + case "verified": + severity = "verified"; + break; + case "untrusted": + case "expired": + severity = "warn"; + break; + default: + severity = "error"; + } + return { worst, severity, count }; +} + +// For an `untrusted` certificate, pick the most specific Fluent label / +// args we can — preferring the structured "Certificate: +// ()" form when we recognise the error code, and falling back to +// the generic "Certificate: Untrusted ()". +function untrustedCertLabel(errorCode, issuerCN) { + const code = (errorCode || "").toUpperCase(); + const args = issuerCN ? { issuer: issuerCN } : null; + if (code.includes("UNKNOWN_ISSUER") && args) { + return { + id: "pdfjs-signature-properties-certificate-untrusted-unknown-issuer", + args, + }; + } + if (code.includes("SELF_SIGNED") && args) { + return { + id: "pdfjs-signature-properties-certificate-untrusted-self-signed", + args, + }; + } + if (code.includes("UNTRUSTED_ISSUER") && args) { + return { + id: "pdfjs-signature-properties-certificate-untrusted-untrusted-issuer", + args, + }; + } + const reason = shortCertReason(errorCode); + if (reason) { + return { + id: "pdfjs-signature-properties-certificate-untrusted-with-reason", + args: { reason }, + }; + } + return { + id: "pdfjs-signature-properties-certificate-untrusted", + args: null, + }; +} + +// Map a chrome-side errorCode string to a single short word that fits in the +// "Certificate: Expired ()" / generic "Certificate: Untrusted ()" +// labels. Returns null when no concise reason is available. +function shortCertReason(errorCode) { + if (!errorCode || errorCode === "NS_OK") { + return null; + } + const code = errorCode.toUpperCase(); + if (code.includes("UNKNOWN_ISSUER")) { + return "unknown issuer"; + } + if (code.includes("UNTRUSTED_ISSUER") || code.includes("UNTRUSTED_CERT")) { + return "untrusted"; + } + if (code.includes("SELF_SIGNED")) { + return "self-signed"; + } + if (code.includes("REVOKED")) { + return "revoked"; + } + if (code.includes("EXPIRED")) { + return "expired"; + } + if (code.includes("NOT_YET_VALID")) { + return "not-yet-valid"; + } + if (code.includes("KEY_USAGE")) { + return "key-usage"; + } + if (code.includes("SIGNATURE")) { + return "bad-signature"; + } + return null; +} + +// For an `expired` certificate: NSS may have flagged either the leaf +// (SEC_ERROR_EXPIRED_CERTIFICATE) or any issuer up the chain +// (SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE). We want the parenthetical +// to show the date that actually expired, so walk the chain and +// return the first notAfter that is already in the past as a Date. +function expirationDateForCert(cert) { + if (!cert) { + return null; + } + const now = Date.now(); + const entries = + Array.isArray(cert.chain) && cert.chain.length ? cert.chain : [cert]; + for (const entry of entries) { + if (typeof entry?.notAfter === "string" && entry.notAfter) { + const date = new Date(entry.notAfter); + const ts = date.getTime(); + if (Number.isFinite(ts) && ts < now) { + return date; + } + } + } + if (typeof cert.notAfter === "string" && cert.notAfter) { + const date = new Date(cert.notAfter); + return Number.isNaN(date.getTime()) ? null : date; + } + return null; +} + +class SignaturePropertiesManager { + #appConfig; + + #verifier; + + #eventBus; + + #signatures = []; + + #results = new Map(); // signatureId -> VerificationResult + + #pendingVerify = new Set(); // signatureId set, in-flight + + #isOpen = false; + + #isLoading = false; + + #docOpen = false; + + constructor({ appConfig, verifier, eventBus }) { + this.#appConfig = appConfig; + this.#verifier = verifier; + this.#eventBus = eventBus; + + appConfig.signaturePropertiesButton.addEventListener("click", () => { + this.#toggle(); + }); + } + + /** + * @returns {boolean} `true` while the doorhanger is visible. + */ + get isOpen() { + return this.#isOpen; + } + + /** + * Close the doorhanger if it is open. The viewer's existing Escape + * handler and outside-click logic call this — the manager doesn't + * register its own document-level listeners. + */ + close() { + if (this.#isOpen) { + this.#close(); + } + } + + /** + * @param {Element} target Click target. The viewer's outside-click + * handler uses this to decide whether to close the panel. + * @returns {boolean} `true` if the click is outside both the toolbar + * button and the doorhanger and the panel should be closed. + */ + shouldCloseOnClick(target) { + if (!this.#isOpen) { + return false; + } + return !( + this.#appConfig.signaturePropertiesButton.contains(target) || + this.#appConfig.signaturePropertiesPanel.contains(target) + ); + } + + async loadFromDocument(pdfDocument) { + this.#docOpen = true; + this.#signatures = []; + this.#results.clear(); + this.#pendingVerify.clear(); + this.#isLoading = true; + this.#render(); + + let signatures; + try { + signatures = await pdfDocument.getSignatures(); + } catch (ex) { + console.warn("getSignatures failed:", ex); + signatures = []; + } + if (!this.#docOpen) { + // Document closed during fetch. + return; + } + this.#signatures = signatures || []; + this.#isLoading = false; + + if (this.#signatures.length === 0) { + this.#hideButton(); + return; + } + this.#showButton(); + + // Seed each signature with an "unknown" placeholder result so the + // banner / badge / cards have something to render while the worker + // verifies them in the background. + for (const sig of this.#signatures) { + this.#results.set(sig.id, { + signatureId: sig.id, + status: "unknown", + errorCode: null, + message: null, + certificate: null, + documentModifiedAfterSigning: !sig.coversWholeDocument, + }); + } + this.#render(); + this.#updateButtonState(); + // Kick off verification automatically — the toolbar button reflects the + // aggregate state and updates as each signature resolves. + for (const sig of this.#signatures) { + this.#verify(sig); + } + } + + reset() { + this.#docOpen = false; + this.#signatures = []; + this.#results.clear(); + this.#pendingVerify.clear(); + this.#hideButton(); + this.#close(); + this.#updateButtonState(); + } + + // --- internal --- + + #showButton() { + const root = this.#appConfig.signaturePropertiesButton.parentElement; + if (root) { + root.hidden = false; + } + } + + #hideButton() { + const root = this.#appConfig.signaturePropertiesButton.parentElement; + if (root) { + root.hidden = true; + } + } + + #toggle() { + if (this.#isOpen) { + this.#close(); + } else { + this.#open(); + } + } + + #open() { + this.#isOpen = true; + // Close any other open editor doorhanger (Ink, FreeText, Highlight, …) + // and the find bar / secondary toolbar via global onClick — same pattern + // the Comment doorhanger uses. + this.#eventBus?.dispatch("switchannotationeditormode", { + source: this, + mode: AnnotationEditorType.NONE, + }); + this.#eventBus?.dispatch("findbarclose", { source: this }); + this.#appConfig.signaturePropertiesPanel.classList.remove("hidden"); + this.#appConfig.signaturePropertiesButton.setAttribute( + "aria-expanded", + "true" + ); + } + + #close() { + this.#isOpen = false; + this.#appConfig.signaturePropertiesPanel.classList.add("hidden"); + this.#appConfig.signaturePropertiesButton.setAttribute( + "aria-expanded", + "false" + ); + } + + #render() { + const list = this.#appConfig.signaturePropertiesList; + const banner = this.#appConfig.signaturePropertiesBanner; + list.replaceChildren(); + + if (this.#isLoading) { + banner.hidden = true; + for (let i = 0; i < 2; i++) { + const li = document.createElement("li"); + li.className = "sigCard"; + for (let j = 0; j < 3; j++) { + const sk = document.createElement("div"); + sk.className = "sigCard__skeleton"; + li.append(sk); + } + list.append(li); + } + return; + } + + // Banner. + const { worst, severity, count } = bannerStateForResults([ + ...this.#results.values(), + ]); + banner.replaceChildren(); + banner.hidden = false; + banner.className = `sigBanner ${severity}`; + banner.setAttribute( + "data-l10n-id", + `pdfjs-signature-properties-banner-${worst}` + ); + banner.setAttribute("data-l10n-args", JSON.stringify({ count })); + + // Group sub-signatures under their parent. + const byParent = new Map(); + const topLevel = []; + for (const sig of this.#signatures) { + if (sig.parentId) { + if (!byParent.has(sig.parentId)) { + byParent.set(sig.parentId, []); + } + byParent.get(sig.parentId).push(sig); + } else { + topLevel.push(sig); + } + } + + // Green icons are reserved for the top-level card when *every* + // signature in the document is verified. Anywhere else (any + // sub-signature, or a top-level when something further down is + // expired/untrusted/etc.) keeps the muted grey check. + const everythingFine = severity === "verified"; + + for (const sig of topLevel) { + list.append( + this.#renderCard(sig, byParent, /* depth = */ 0, everythingFine) + ); + } + } + + #renderCard(sig, byParent, depth, everythingFine) { + const subs = byParent.get(sig.id) || []; + const li = document.createElement("li"); + li.className = "sigCard"; + if (depth === 0 && everythingFine) { + li.classList.add("sigCard--top-allfine"); + } + li.dataset.signatureId = sig.id; + + const result = this.#results.get(sig.id); + const inFlight = this.#pendingVerify.has(sig.id); + + const subjectCN = result?.certificate?.subjectCN; + if (subjectCN) { + const signer = document.createElement("div"); + signer.className = "sigCard__signer"; + signer.textContent = subjectCN; + li.append(signer); + } + + // Status row. + const statusRow = document.createElement("div"); + statusRow.className = `sigCard__row status--${result.status}`; + const statusLabel = document.createElement("span"); + statusLabel.setAttribute( + "data-l10n-id", + `pdfjs-signature-properties-status-${result.status}` + ); + statusRow.append(statusLabel); + li.append(statusRow); + + if (result.status === "invalid" && result.message) { + const reason = document.createElement("div"); + reason.className = "sigCard__detail"; + reason.setAttribute("data-l10n-id", "pdfjs-signature-properties-reason"); + reason.setAttribute( + "data-l10n-args", + JSON.stringify({ reason: result.message }) + ); + li.append(reason); + } + + // Certificate row. + const cert = result.certificate; + const certRow = document.createElement("div"); + let certKind = "unknown"; + if (cert) { + switch (result.status) { + case "verified": + case "invalid": + certKind = "trusted"; + break; + case "expired": + certKind = "expired"; + break; + case "revoked": + certKind = "revoked"; + break; + case "untrusted": + certKind = "untrusted"; + break; + default: + certKind = "unknown"; + } + } + certRow.className = `sigCard__row cert--${certKind}`; + const certLabel = document.createElement("span"); + let l10nId = `pdfjs-signature-properties-certificate-${certKind}`; + let l10nArgs = null; + if (cert?.issuerCN && certKind === "trusted") { + l10nArgs = { issuer: cert.issuerCN }; + } else if (certKind === "expired") { + // For expired, the parenthetical is the expiration date itself + // (could be the leaf or any issuer up the chain). Pass a Date + // through Fluent so the viewer locale formats it, not the + // browser locale. + const date = expirationDateForCert(cert); + if (date) { + l10nId = `${l10nId}-with-date`; + l10nArgs = { dateObj: date.valueOf() }; + } + } else if (certKind === "untrusted") { + const label = untrustedCertLabel(result.errorCode, cert?.issuerCN); + l10nId = label.id; + l10nArgs = label.args; + } else if (certKind === "revoked") { + const reason = shortCertReason(result.errorCode); + if (reason) { + l10nId = `${l10nId}-with-reason`; + l10nArgs = { reason }; + } + } + certLabel.setAttribute("data-l10n-id", l10nId); + if (l10nArgs) { + certLabel.setAttribute("data-l10n-args", JSON.stringify(l10nArgs)); + } + certRow.append(certLabel); + + li.append(certRow); + + if (result.status === "untrusted" && result.message) { + const detail = document.createElement("div"); + detail.className = "sigCard__detail"; + detail.textContent = result.message; + li.append(detail); + } + if (result.status === "expired" && result.message) { + const detail = document.createElement("div"); + detail.className = "sigCard__detail"; + detail.textContent = result.message; + li.append(detail); + } + + if (sig.reason) { + const reason = document.createElement("div"); + reason.className = "sigCard__detail"; + reason.setAttribute("data-l10n-id", "pdfjs-signature-properties-reason"); + reason.setAttribute( + "data-l10n-args", + JSON.stringify({ reason: sig.reason }) + ); + li.append(reason); + } + + const signingDate = PDFDateString.toDateObject(sig.signingTime); + if (signingDate) { + const ts = document.createElement("div"); + ts.className = "sigCard__detail"; + ts.setAttribute("data-l10n-id", "pdfjs-signature-properties-timestamp"); + ts.setAttribute( + "data-l10n-args", + JSON.stringify({ dateObj: signingDate.valueOf() }) + ); + li.append(ts); + } + + if (cert && typeof this.#verifier?.viewCertificate === "function") { + const viewCert = document.createElement("button"); + viewCert.className = "sigCard__viewCert"; + viewCert.type = "button"; + viewCert.setAttribute( + "data-l10n-id", + "pdfjs-signature-properties-view-certificate" + ); + viewCert.addEventListener("click", e => { + e.stopPropagation(); + this.#verifier.viewCertificate(cert); + }); + li.append(viewCert); + } + + if (subs.length > 0) { + const subList = document.createElement("ul"); + subList.className = "signaturePropertiesList sigCard__nested"; + for (const sub of subs) { + subList.append( + this.#renderCard(sub, byParent, depth + 1, everythingFine) + ); + } + + if (depth === 0) { + // Only the top-level card gets the collapsible header. Deeper + // signatures are always rendered inline; the nested border + indent + // already shows the parent→child relationship. + const details = document.createElement("details"); + details.className = "sigCard__subSignatures"; + details.open = true; + const summary = document.createElement("summary"); + summary.setAttribute( + "data-l10n-id", + "pdfjs-signature-properties-sub-signatures" + ); + summary.setAttribute( + "data-l10n-args", + JSON.stringify({ count: this.#countDescendants(sig.id, byParent) }) + ); + details.append(summary); + details.append(subList); + li.append(details); + } else { + li.append(subList); + } + } + + if (inFlight) { + const sk = document.createElement("div"); + sk.className = "sigCard__skeleton"; + li.append(sk); + } + + return li; + } + + #countDescendants(id, byParent) { + const direct = byParent.get(id); + if (!direct) { + return 0; + } + let total = direct.length; + for (const sub of direct) { + total += this.#countDescendants(sub.id, byParent); + } + return total; + } + + async #verify(signature) { + if (!this.#verifier || this.#pendingVerify.has(signature.id)) { + return; + } + this.#pendingVerify.add(signature.id); + this.#render(); + + let result; + try { + result = await this.#verifier.verify(signature); + } catch (ex) { + console.warn("signature verify failed:", ex); + result = { + signatureId: signature.id, + status: "unknown", + errorCode: "BRIDGE_ERROR", + message: ex?.message ?? null, + certificate: null, + documentModifiedAfterSigning: !signature.coversWholeDocument, + }; + } + this.#pendingVerify.delete(signature.id); + if (!this.#docOpen) { + return; + } + this.#results.set(signature.id, result); + this.#render(); + this.#updateButtonState(); + } + + #updateButtonState() { + const button = this.#appConfig.signaturePropertiesButton; + button.classList.remove( + "state-loading", + "state-verified", + "state-warn", + "state-error" + ); + if (this.#signatures.length === 0) { + return; + } + if (this.#pendingVerify.size > 0) { + button.classList.add("state-loading"); + return; + } + let worst = "verified"; + for (const r of this.#results.values()) { + if (!r) { + continue; + } + if (STATUS_PRIORITY[r.status] > STATUS_PRIORITY[worst]) { + worst = r.status; + } + } + switch (worst) { + case "invalid": + case "revoked": + case "unknown": + // `unknown` means the verifier completed but could not give a + // definitive answer (unsupported subfilter, bridge error, + // CMS NOT_YET_ATTEMPTED). Treat that as a verification failure + // — the loading dots are reserved for the in-flight case + // handled above. + button.classList.add("state-error"); + break; + case "expired": + case "untrusted": + button.classList.add("state-warn"); + break; + default: + button.classList.add("state-verified"); + } + } +} + +export { SignaturePropertiesManager }; diff --git a/web/stubs-geckoview.js b/web/stubs-geckoview.js index daf7d94ffbf0b..4aa20d6f4b0b8 100644 --- a/web/stubs-geckoview.js +++ b/web/stubs-geckoview.js @@ -27,6 +27,7 @@ const PDFPresentationMode = null; const PDFThumbnailViewer = null; const SecondaryToolbar = null; const SignatureManager = null; +const SignaturePropertiesManager = null; const ViewsManager = null; export { @@ -44,5 +45,6 @@ export { PDFThumbnailViewer, SecondaryToolbar, SignatureManager, + SignaturePropertiesManager, ViewsManager, }; diff --git a/web/viewer.css b/web/viewer.css index 832439af42344..8e3c62e962025 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -14,6 +14,7 @@ */ @import url(pdf_viewer.css); +@import url(signature_properties.css); :root { --dir-factor: 1; @@ -83,6 +84,9 @@ --toolbarButton-editorInk-icon: url(images/toolbarButton-editorInk.svg); --toolbarButton-editorStamp-icon: url(images/toolbarButton-editorStamp.svg); --toolbarButton-editorSignature-icon: url(images/toolbarButton-editorSignature.svg); + --toolbarButton-signaturePropertiesVerified-icon: url(images/toolbarButton-signaturePropertiesVerified.svg); + --toolbarButton-signaturePropertiesWarn-icon: url(images/toolbarButton-signaturePropertiesWarn.svg); + --toolbarButton-signaturePropertiesError-icon: url(images/toolbarButton-signaturePropertiesError.svg); --toolbarButton-menuArrow-icon: url(images/toolbarButton-menuArrow.svg); --toolbarButton-viewsManagerToggle-icon: url(images/toolbarButton-viewsManagerToggle.svg); --toolbarButton-secondaryToolbarToggle-icon: url(images/toolbarButton-secondaryToolbarToggle.svg); diff --git a/web/viewer.html b/web/viewer.html index 0d5d90c7d61ca..bed69239cba22 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -84,6 +84,7 @@ "web-print_service": "./pdf_print_service.js", "web-secondary_toolbar": "./secondary_toolbar.js", "web-signature_manager": "./signature_manager.js", + "web-signature_properties_manager": "./signature_properties_manager.js", "web-toolbar": "./toolbar.js", "web-views_manager": "./views_manager.js" } @@ -432,6 +433,26 @@ +