pdf.js.mirror/web/firefoxcom.js
Benjamin Beurdouche 07b1c625e1 Add Digital signature properties verification panel
Adds a new "Digital signature properties" doorhanger to the pdf.js
toolbar that lists every digital signature found in the opened PDF,
verifies each one (via NSS in the Firefox build through a new chrome
bridge), and shows per-signature status + certificate state.

The viewer side parses /Sig dicts in the worker
(`PDFDocument.signatures`), strict-validates the /ByteRange offsets
before slicing, and ships only signature metadata across the worker
boundary. The PKCS#7 blob and signed-data byte spans live in a
worker-side map and are fetched lazily one signature at a time via
a new `getSignatureData(id)` RPC, immediately before verification
runs, so the bytes never sit in main-thread memory for the
document's lifetime.

The panel is feature-gated by `pdfjs.enableSignatureVerification`
(true in MOZCENTRAL/TESTING, off by default in the GENERIC build).
External services expose a `createSignatureVerifier()` factory that
the Firefox build wires up to `nsIX509CertDB.asyncVerifyPKCS7Object`;
GENERIC builds return null and the toolbar button stays hidden.

UI summary:
- Toolbar button states: loading dots while in flight, then green
  check, orange `!`, or red `✕` based on the worst aggregate
  signature status.
- Doorhanger contains a banner summarising the document state, then
  one card per signature with status row + certificate row (sub-
  signatures nested under their outer revision via /ByteRange
  containment).
- Icons are mono SVGs themed via `mask-image` + `background-color`
  so they pick up light/dark/HCM via `--sig-icon-*` vars; flipped
  under RTL via `scaleX(var(--dir-factor))`. The HCM mapping reuses
  the alt-text vocabulary (ButtonFace / ButtonText / ButtonBorder /
  GrayText / AccentColor / LinkText) so this panel reads the same
  as the rest of the editor toolbar in high-contrast mode.
- All visible strings are localized via Fluent
  (`pdfjs-digital-signature-properties-*`); status row, banner, and
  certificate row use explicit lookup tables instead of generated
  ids so a grep finds them.
- Esc + outside-click close the panel through the viewer's existing
  handlers; the manager exposes `isOpen`, `close()`, and
  `shouldCloseOnClick(target)` for that.

This commit also adds a `test/pdfs/sig_corpus/` directory holding a
Python generator that produces a corpus of signed PDFs covering
every visible state of the doorhanger (verified / untrusted /
expired / invalid / unknown / multi-signature variants). The corpus
is intentionally NOT part of the automated test suite — it is a
manual-test tool. Generated `.pdf` files are gitignored; only the
generator, README, and a `user.js.example` snippet are tracked.
The generator shells out to mozilla-central's
`security/manager/tools/pycms.py` (resolved via `--mozilla-central
<path>` or the `MOZILLA_CENTRAL_SRC` env var) and the embedded test
trust anchors (`pdf-sign-ca` / `pdf-sign-ca-expired`), gated by
`security.pdf_signature_verification.enable_test_trust_anchors` so
the test certificates never validate in shipping Firefox.
2026-06-30 13:25:09 +02:00

770 lines
20 KiB
JavaScript

/* Copyright 2012 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 { MathClamp, PDFDataRangeTransport } from "pdfjs-lib";
import { AppOptions } from "./app_options.js";
import { BaseDownloadManager } from "./base_download_manager.js";
import { BaseExternalServices } from "./external_services.js";
import { BasePreferences } from "./preferences.js";
import { DEFAULT_SCALE_VALUE } from "./ui_utils.js";
import { internalOpt } from "./internal_evt.js";
import { L10n } from "./l10n.js";
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
throw new Error(
'Module "./firefoxcom.js" shall not be used outside MOZCENTRAL builds.'
);
}
let viewerApp = { initialized: false };
function initCom(app) {
viewerApp = app;
}
class FirefoxCom {
/**
* Creates an event that the extension is listening for and will
* asynchronously respond to.
* @param {string} action - The action to trigger.
* @param {Object|string} [data] - The data to send.
* @returns {Promise<any>} A promise that is resolved with the response data.
*/
static requestAsync(action, data) {
return new Promise(resolve => {
this.request(action, data, resolve);
});
}
/**
* Creates an event that the extension is listening for and will, optionally,
* asynchronously respond to.
* @param {string} action - The action to trigger.
* @param {Object|string} [data] - The data to send.
*/
static request(action, data, callback = null) {
const request = document.createTextNode("");
if (callback) {
request.addEventListener(
"pdf.js.response",
event => {
const response = event.detail.response;
event.target.remove();
callback(response);
},
{ once: true }
);
}
document.documentElement.append(request);
const sender = new CustomEvent("pdf.js.message", {
bubbles: true,
cancelable: false,
detail: {
action,
data,
responseExpected: !!callback,
},
});
request.dispatchEvent(sender);
}
}
class DownloadManager extends BaseDownloadManager {
_triggerDownload(blobUrl, originalUrl, filename, isAttachment = false) {
FirefoxCom.request("download", {
blobUrl,
originalUrl,
filename,
isAttachment,
});
}
_getOpenDataUrl(blobUrl, filename, dest = null) {
// Let Firefox's content handler catch the URL and display the PDF.
// NOTE: This cannot use a query string for the filename, see
// https://bugzilla.mozilla.org/show_bug.cgi?id=1632644#c5
let url = blobUrl + "#filename=" + encodeURIComponent(filename);
if (dest) {
url += `&filedest=${escape(dest)}`;
}
return url;
}
}
class Preferences extends BasePreferences {
async _readFromStorage(prefObj) {
return FirefoxCom.requestAsync("getPreferences", prefObj);
}
async _writeToStorage(prefObj) {
return FirefoxCom.requestAsync("setPreferences", prefObj);
}
}
(function listenFindEvents() {
const events = [
"find",
"findagain",
"findhighlightallchange",
"findcasesensitivitychange",
"findentirewordchange",
"findbarclose",
"finddiacriticmatchingchange",
];
const findLen = "find".length;
const handleEvent = function ({ type, detail }) {
if (!viewerApp.initialized) {
return;
}
if (type === "findbarclose") {
viewerApp.eventBus.dispatch(type, { source: window });
return;
}
viewerApp.eventBus.dispatch("find", {
source: window,
type: type.substring(findLen),
query: detail.query,
caseSensitive: !!detail.caseSensitive,
entireWord: !!detail.entireWord,
highlightAll: !!detail.highlightAll,
findPrevious: !!detail.findPrevious,
matchDiacritics: !!detail.matchDiacritics,
});
};
for (const event of events) {
window.addEventListener(event, handleEvent);
}
})();
(function listenZoomEvents() {
const events = ["zoomin", "zoomout", "zoomreset"];
const handleEvent = function ({ type, detail }) {
if (!viewerApp.initialized) {
return;
}
// Avoid attempting to needlessly reset the zoom level *twice* in a row,
// when using the `Ctrl + 0` keyboard shortcut.
if (
type === "zoomreset" &&
viewerApp.pdfViewer.currentScaleValue === DEFAULT_SCALE_VALUE
) {
return;
}
viewerApp.eventBus.dispatch(type, { source: window });
};
for (const event of events) {
window.addEventListener(event, handleEvent);
}
})();
(function listenSaveEvent() {
const handleEvent = function ({ type, detail }) {
if (!viewerApp.initialized) {
return;
}
viewerApp.eventBus.dispatch("download", { source: window });
};
window.addEventListener("save", handleEvent);
})();
(function listenEditingEvent() {
const handleEvent = function ({ detail }) {
if (!viewerApp.initialized) {
return;
}
viewerApp.eventBus.dispatch("editingaction", {
source: window,
name: detail.name,
});
};
window.addEventListener("editingaction", handleEvent);
})();
if (PDFJSDev.test("GECKOVIEW")) {
(function listenQueryEvents() {
window.addEventListener("pdf.js.query", async ({ detail: { queryId } }) => {
let result = null;
if (viewerApp.initialized && queryId === "canDownloadInsteadOfPrint") {
result = false;
const { pdfDocument, pdfViewer } = viewerApp;
if (pdfDocument) {
try {
const hasUnchangedAnnotations =
pdfDocument.annotationStorage.size === 0;
// WillPrint is called just before printing the document and could
// lead to have modified annotations.
const hasWillPrint =
pdfViewer.enableScripting &&
!!(await pdfDocument.getJSActions())?.WillPrint;
result = hasUnchangedAnnotations && !hasWillPrint;
} catch {
console.warn("Unable to check if the document can be downloaded.");
}
}
}
window.dispatchEvent(
new CustomEvent("pdf.js.query.answer", {
bubbles: true,
cancelable: false,
detail: {
queryId,
value: result,
},
})
);
});
})();
}
class FirefoxComDataRangeTransport extends PDFDataRangeTransport {
requestDataRange(begin, end) {
FirefoxCom.request("requestDataRange", { begin, end });
}
// NOTE: This method is currently not invoked in the Firefox PDF Viewer.
abort() {
FirefoxCom.request("abortLoading", null);
}
}
class FirefoxScripting {
static async createSandbox(data) {
const success = await FirefoxCom.requestAsync("createSandbox", data);
if (!success) {
throw new Error("Cannot create sandbox.");
}
}
static async dispatchEventInSandbox(event) {
FirefoxCom.request("dispatchEventInSandbox", event);
}
static async destroySandbox() {
FirefoxCom.request("destroySandbox", null);
}
}
class MLManager {
#abortSignal = null;
#enabled = null;
#eventBus = null;
#ready = null;
#requestResolvers = null;
hasProgress = false;
static #AI_ALT_TEXT_MODEL_NAME = "moz-image-to-text";
constructor({
altTextLearnMoreUrl,
enableGuessAltText,
enableAltTextModelDownload,
}) {
// The `altTextLearnMoreUrl` is used to provide a link to the user to learn
// more about the "alt text" feature.
// The link is used in the Alt Text dialog or in the Image Settings.
this.altTextLearnMoreUrl = altTextLearnMoreUrl;
this.enableAltTextModelDownload = enableAltTextModelDownload;
this.enableGuessAltText = enableGuessAltText;
}
setEventBus(eventBus, abortSignal) {
this.#eventBus = eventBus;
this.#abortSignal = abortSignal;
const evtOpts = { signal: abortSignal, ...internalOpt };
eventBus.on(
"enablealttextmodeldownload",
({ value }) => {
if (this.enableAltTextModelDownload === value) {
return;
}
if (value) {
this.downloadModel("altText");
} else {
this.deleteModel("altText");
}
},
evtOpts
);
eventBus.on(
"enableguessalttext",
({ value }) => {
this.toggleService("altText", value);
},
evtOpts
);
}
async isEnabledFor(name) {
return this.enableGuessAltText && !!(await this.#enabled?.get(name));
}
isReady(name) {
return this.#ready?.has(name) ?? false;
}
async deleteModel(name) {
if (name !== "altText" || !this.enableAltTextModelDownload) {
return;
}
this.enableAltTextModelDownload = false;
this.#ready?.delete(name);
this.#enabled?.delete(name);
await this.toggleService("altText", false);
await FirefoxCom.requestAsync(
"mlDelete",
MLManager.#AI_ALT_TEXT_MODEL_NAME
);
}
async loadModel(name) {
if (name === "altText" && this.enableAltTextModelDownload) {
await this.#loadAltTextEngine(false);
}
}
async downloadModel(name) {
if (name !== "altText" || this.enableAltTextModelDownload) {
return null;
}
this.enableAltTextModelDownload = true;
return this.#loadAltTextEngine(true);
}
async guess(data) {
if (data?.name !== "altText") {
return null;
}
const resolvers = (this.#requestResolvers ||= new Set());
const resolver = Promise.withResolvers();
resolvers.add(resolver);
data.service = MLManager.#AI_ALT_TEXT_MODEL_NAME;
FirefoxCom.requestAsync("mlGuess", data)
.then(response => {
if (resolvers.has(resolver)) {
resolver.resolve(response);
resolvers.delete(resolver);
}
})
.catch(reason => {
if (resolvers.has(resolver)) {
resolver.reject(reason);
resolvers.delete(resolver);
}
});
return resolver.promise;
}
async #cancelAllRequests() {
if (!this.#requestResolvers) {
return;
}
for (const resolver of this.#requestResolvers) {
resolver.resolve({ cancel: true });
}
this.#requestResolvers.clear();
this.#requestResolvers = null;
}
async toggleService(name, enabled) {
if (name !== "altText" || this.enableGuessAltText === enabled) {
return;
}
this.enableGuessAltText = enabled;
if (enabled) {
if (this.enableAltTextModelDownload) {
await this.#loadAltTextEngine(false);
}
} else {
this.#cancelAllRequests();
}
}
async #loadAltTextEngine(listenToProgress) {
if (this.#enabled?.has("altText")) {
// We already have a promise for the "altText" service.
return;
}
this.#ready ||= new Set();
const promise = FirefoxCom.requestAsync("loadAIEngine", {
service: MLManager.#AI_ALT_TEXT_MODEL_NAME,
listenToProgress,
}).then(ok => {
if (ok) {
this.#ready.add("altText");
}
return ok;
});
(this.#enabled ||= new Map()).set("altText", promise);
if (listenToProgress) {
const ac = new AbortController();
const signal = AbortSignal.any([this.#abortSignal, ac.signal]);
this.hasProgress = true;
window.addEventListener(
"loadAIEngineProgress",
({ detail }) => {
this.#eventBus.dispatch("loadaiengineprogress", {
source: this,
detail,
});
if (detail.finished) {
ac.abort();
this.hasProgress = false;
}
},
{ signal }
);
promise.then(ok => {
if (!ok) {
ac.abort();
this.hasProgress = false;
}
});
}
await promise;
}
}
class SignatureStorage {
#eventBus = null;
#signatures = null;
#signal = null;
constructor(eventBus, signal) {
this.#eventBus = eventBus;
this.#signal = signal;
}
#handleSignature(data) {
return FirefoxCom.requestAsync("handleSignature", data);
}
async getAll() {
if (this.#signal) {
window.addEventListener(
"storedSignaturesChanged",
() => {
this.#signatures = null;
this.#eventBus?.dispatch("storedsignatureschanged", { source: this });
},
{ signal: this.#signal }
);
this.#signal = null;
}
if (!this.#signatures) {
this.#signatures = new Map();
const data = await this.#handleSignature({ action: "get" });
if (data) {
for (const { uuid, description, signatureData } of data) {
this.#signatures.set(uuid, { description, signatureData });
}
}
}
return this.#signatures;
}
async isFull() {
// We want to store at most 5 signatures.
return (await this.size()) === 5;
}
async size() {
return (await this.getAll()).size;
}
async create(data) {
if (await this.isFull()) {
return null;
}
const uuid = await this.#handleSignature({
action: "create",
...data,
});
if (!uuid) {
return null;
}
this.#signatures.set(uuid, data);
return uuid;
}
async delete(uuid) {
const signatures = await this.getAll();
if (!signatures.has(uuid)) {
return false;
}
if (await this.#handleSignature({ action: "delete", uuid })) {
signatures.delete(uuid);
return true;
}
return false;
}
}
// `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",
]),
// Only definite-revoked codes belong here. We intentionally do NOT
// include OCSP-response-missing-style codes (e.g.
// MOZILLA_PKIX_ERROR_OCSP_RESPONSE_FOR_CERT_MISSING), since those
// mean "we couldn't reach the responder" — they fall through to the
// generic untrusted bucket.
REVOKED: new Set([
"SEC_ERROR_REVOKED_CERTIFICATE",
"SEC_ERROR_REVOKED_KEY",
"MOZILLA_PKIX_ERROR_REVOKED_CERTIFICATE",
]),
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 {
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 {
status: "unknown",
errorCode: "BRIDGE_ERROR",
message: ex?.message ?? null,
certificate: null,
documentModifiedAfterSigning: !signature.coversWholeDocument,
};
}
if (!response || response.error) {
return {
status: "unknown",
errorCode: response?.error ?? "EMPTY_RESPONSE",
message: null,
certificate: null,
documentModifiedAfterSigning: !signature.coversWholeDocument,
};
}
// The chrome side returns an Array<nsIPDFVerificationResult>, but for
// a single PKCS#7 input it has exactly one entry.
const entry = Array.isArray(response) ? response[0] : response;
if (!entry) {
return {
status: "unknown",
errorCode: "EMPTY_RESPONSE",
message: null,
certificate: null,
documentModifiedAfterSigning: !signature.coversWholeDocument,
};
}
const { status, errorCode } = mapVerificationStatus(
entry.signatureResult,
entry.certificateResult
);
return {
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);
}
updateFindMatchesCount(data) {
FirefoxCom.request("updateFindMatchesCount", data);
}
initPassiveLoading() {
let pdfDataRangeTransport;
window.addEventListener("message", function windowMessage(e) {
if (e.source !== null) {
// The message MUST originate from Chrome code.
console.warn("Rejected untrusted message from " + e.origin);
return;
}
const args = e.data;
if (typeof args !== "object" || !("pdfjsLoadAction" in args)) {
return;
}
switch (args.pdfjsLoadAction) {
case "supportsRangedLoading":
if (args.done && !args.data) {
viewerApp._documentError(null);
break;
}
pdfDataRangeTransport = new FirefoxComDataRangeTransport(
args.length,
args.data,
args.done,
args.filename
);
viewerApp.open({ range: pdfDataRangeTransport });
break;
case "range":
pdfDataRangeTransport.onDataRange(args.begin, args.chunk);
break;
case "progressiveRead":
pdfDataRangeTransport.onDataProgressiveRead(args.chunk);
break;
case "progressiveDone":
pdfDataRangeTransport?.onDataProgressiveDone();
break;
case "progress":
const percent = args.total
? MathClamp(Math.round((args.loaded / args.total) * 100), 0, 100)
: NaN;
viewerApp.progress(percent);
break;
case "complete":
if (!args.data) {
viewerApp._documentError(null, { message: args.errorCode });
break;
}
viewerApp.open({ data: args.data, filename: args.filename });
break;
}
});
FirefoxCom.request("initPassiveLoading", null);
}
reportTelemetry(data) {
FirefoxCom.request("reportTelemetry", data);
}
reportText(data) {
FirefoxCom.request("reportText", data);
}
updateEditorStates(data) {
FirefoxCom.request("updateEditorStates", data);
}
async createL10n() {
await document.l10n.ready;
return new L10n(AppOptions.get("localeProperties"), document.l10n);
}
createScripting() {
return FirefoxScripting;
}
createSignatureStorage(eventBus, signal) {
return new SignatureStorage(eventBus, signal);
}
createSignatureVerifier() {
return new SignatureVerifier();
}
dispatchGlobalEvent(event) {
FirefoxCom.request("dispatchGlobalEvent", event);
}
}
export { DownloadManager, ExternalServices, initCom, MLManager, Preferences };