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.
This commit is contained in:
Benjamin Beurdouche 2026-06-30 13:25:09 +02:00
parent 25eae30e4e
commit 07b1c625e1
24 changed files with 2894 additions and 2 deletions

View File

@ -225,6 +225,8 @@ function createWebpackAlias(defines) {
"web-print_service": "",
"web-secondary_toolbar": "web/secondary_toolbar.js",
"web-signature_manager": "web/signature_manager.js",
"web-digital_signature_properties_manager":
"web/digital_signature_properties_manager.js",
"web-toolbar": "web/toolbar.js",
"web-views_manager": "web/views_manager.js",
};

View File

@ -785,3 +785,96 @@ pdfjs-views-manager-paste-button-after =
pdfjs-new-badge-content = NEW
pdfjs-views-manager-waiting-for-file = Uploading file…
## Digital signature properties (signature verification panel)
pdfjs-digital-signature-properties-button =
.title = Digital signature properties
.aria-label = Digital signature properties
pdfjs-digital-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-digital-signature-properties-banner-verified = Document was signed with a valid digital signature
pdfjs-digital-signature-properties-banner-unknown =
{ $count ->
[one] Document signed but { $count } digital signature could not be verified
*[other] Document signed but { $count } digital signatures could not be verified
}
pdfjs-digital-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-digital-signature-properties-banner-expired =
{ $count ->
[one] Document signed with { $count } expired certificate
*[other] Document signed with { $count } expired certificates
}
pdfjs-digital-signature-properties-banner-invalid =
{ $count ->
[one] Document has { $count } invalid digital signature
*[other] Document has { $count } invalid digital signatures
}
pdfjs-digital-signature-properties-banner-revoked =
{ $count ->
[one] Document signed with { $count } revoked certificate
*[other] Document signed with { $count } revoked certificates
}
## Per-signature status row. Only three distinct strings are needed:
## the signature crypto either verified (the cert chain may still be
## untrusted/expired/revoked, but that's surfaced on the cert row
## below), or it failed, or its sub-format isn't supported.
pdfjs-digital-signature-properties-status-verified = Status: Signature verified
pdfjs-digital-signature-properties-status-invalid = Status: Signature invalid
pdfjs-digital-signature-properties-status-unknown = Status: Unable to verify (unsupported)
## Per-signature certificate row. The variants with an issuer / date in
## parentheses embed fully-localized context — no English fall-through.
##
## Variables:
## $issuer (String) - issuer or subject common name from the cert.
## $dateObj (Date) - notAfter date for the expired-with-date form.
pdfjs-digital-signature-properties-certificate-trusted = Certificate: Trusted ({ $issuer })
pdfjs-digital-signature-properties-certificate-unknown = Certificate: Unavailable
pdfjs-digital-signature-properties-certificate-untrusted = Certificate: Untrusted
pdfjs-digital-signature-properties-certificate-untrusted-unknown-issuer = Certificate: Unknown issuer ({ $issuer })
pdfjs-digital-signature-properties-certificate-untrusted-self-signed = Certificate: Self-signed ({ $issuer })
pdfjs-digital-signature-properties-certificate-untrusted-untrusted-issuer = Certificate: Untrusted issuer ({ $issuer })
pdfjs-digital-signature-properties-certificate-expired = Certificate: Expired
pdfjs-digital-signature-properties-certificate-expired-with-date = Certificate: Expired ({ DATETIME($dateObj, dateStyle: "medium") })
pdfjs-digital-signature-properties-certificate-revoked = Certificate: Revoked
##
pdfjs-digital-signature-properties-view-certificate = View certificate
# Shown beneath an invalid signature card to explain why verification
# failed. The text comes from NSS (e.g. "Signature integrity has been
# compromised", "PKCS#7 signature could not be parsed") and is not
# itself localized — it is the underlying error message produced by
# the verification backend.
# Variables:
# $reason (String) - error message describing why the signature
# could not be verified.
pdfjs-digital-signature-properties-reason = Reason: { $reason }
# Variables:
# $dateObj (Date) - the signing time from the /Sig dict's /M entry.
pdfjs-digital-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-digital-signature-properties-sub-signatures =
{ $count ->
[one] Sub-signature ({ $count })
*[other] Sub-signatures ({ $count })
}

View File

@ -1004,6 +1004,13 @@ function find(stream, signature, limit = 1024, backwards = false) {
class PDFDocument {
#pagePromises = new Map();
// Map<id, {data: Uint8Array[2], pkcs7: Uint8Array}> — populated by the
// `signatures` getter, consumed by `getSignatureData`. We deliberately
// keep the byte payload out of the metadata array so it doesn't ride
// the worker→main `postMessage` boundary unless the viewer actually
// asks to verify (one shot per signature).
#signatureData = null;
#version = null;
constructor(pdfManager, stream) {
@ -1987,6 +1994,203 @@ class PDFDocument {
return shadow(this, "fieldObjects", promise);
}
#collectSignatureFields(fields, out, visitedRefs) {
if (!Array.isArray(fields)) {
return;
}
for (const fieldRef of fields) {
if (fieldRef instanceof Ref) {
if (visitedRefs.has(fieldRef)) {
continue;
}
visitedRefs.put(fieldRef);
}
const field = this.xref.fetchIfRef(fieldRef);
if (!(field instanceof Dict)) {
continue;
}
if (field.has("Kids")) {
this.#collectSignatureFields(field.get("Kids"), out, visitedRefs);
continue;
}
if (!isName(field.get("FT"), "Sig")) {
continue;
}
const sigDict = this.xref.fetchIfRef(field.get("V"));
if (!(sigDict instanceof Dict)) {
continue;
}
const parsed = this.#parseSignatureDict(field, sigDict, fieldRef);
if (parsed) {
out.push(parsed);
}
}
}
// The /ByteRange of a signature that covers the whole document usually
// ends a few bytes before the file's actual end — the trailer,
// `startxref` offset and `%%EOF` marker are conventionally left outside
// the signed range. 100 bytes covers the worst case (large
// `startxref` offsets, optional whitespace) without false positives.
static #WHOLE_DOCUMENT_TAIL_FUZZ = 100;
#parseSignatureDict(field, sigDict, fieldRef) {
const byteRange = sigDict.get("ByteRange");
if (
!Array.isArray(byteRange) ||
byteRange.length !== 4 ||
byteRange.some(n => !Number.isInteger(n) || n < 0)
) {
return null;
}
const contents = sigDict.get("Contents");
if (typeof contents !== "string" || contents.length === 0) {
return null;
}
const filterName = sigDict.get("Filter");
const filter = filterName instanceof Name ? filterName.name : null;
const subFilterName = sigDict.get("SubFilter");
const subFilter = subFilterName instanceof Name ? subFilterName.name : null;
let signatureType = null;
if (subFilter === "adbe.pkcs7.detached") {
signatureType = 0;
} else if (subFilter === "adbe.pkcs7.sha1") {
signatureType = 1;
}
// Slice the two ByteRange byte spans out of the underlying PDF stream.
// ByteRange = [a, b, c, d] means signed bytes are [a..a+b] and [c..c+d];
// the gap covers the /Contents hex blob itself.
const [a, b, c, d] = byteRange;
const stream = this.stream;
// `/ByteRange` offsets are absolute, so compare against `stream.end`
// (raw buffer end), not `stream.length` (post-`moveStart` payload).
const fileLength = stream.end || 0;
// Reject signatures whose /ByteRange is structurally implausible: it
// must start at the file head, define a non-empty first span, leave
// room for the /Contents blob between the two spans, and fit within
// the file. Without this a crafted PDF can claim to cover the whole
// document while only signing a small prologue.
if (
a !== 0 ||
b <= 0 ||
d < 0 ||
a + b > c ||
c + d > fileLength ||
fileLength === 0
) {
return null;
}
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}`;
// A signature "covers the whole document" iff the gap between the
// last signed byte and EOF is no more than the conventional
// trailer-slack window. Compare on the gap (not on the absolute
// lastSignedByte) so a tiny file with a tiny ByteRange isn't
// mis-flagged as full-coverage.
const tailGap = fileLength - (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:
tailGap >= 0 && tailGap <= PDFDocument.#WHOLE_DOCUMENT_TAIL_FUZZ,
};
}
get signatures() {
const promise = this.pdfManager
.ensureDoc("formInfo")
.then(async formInfo => {
if (!formInfo.hasSignatures || !formInfo.hasFields) {
this.#signatureData = null;
return null;
}
const annotationGlobals = await this.annotationGlobals;
if (!annotationGlobals) {
this.#signatureData = null;
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;
}
}
}
// Split bytes (`data`, `pkcs7`) out of the metadata so the array
// we ship to the main thread stays small. The viewer fetches the
// bytes on demand via `getSignatureData(id)`, one signature at a
// time, only when verification is about to run.
const signatureData = new Map();
const metadata = collected.map(sig => {
const { data, pkcs7, ...rest } = sig;
signatureData.set(sig.id, { data, pkcs7 });
return rest;
});
this.#signatureData = signatureData;
return metadata.length ? metadata : null;
});
return shadow(this, "signatures", promise);
}
async getSignatureData(id) {
// Ensure parsing is finished and `#signatureData` is populated.
await this.signatures;
return this.#signatureData?.get(id) ?? null;
}
get hasJSActions() {
const promise = this.pdfManager.ensureDoc("_parseHasJSActions");
return shadow(this, "hasJSActions", promise);

View File

@ -579,6 +579,14 @@ class WorkerMessageHandler {
.then(fieldObjects => fieldObjects?.allFields || null);
});
handler.on("GetSignatures", function (data) {
return pdfManager.ensureDoc("signatures");
});
handler.on("GetSignatureData", function (id) {
return pdfManager.ensureDoc("getSignatureData", [id]);
});
handler.on("HasJSActions", function (data) {
return pdfManager.ensureDoc("hasJSActions");
});

View File

@ -1077,6 +1077,29 @@ class PDFDocumentProxy {
return this._transport.getFieldObjects();
}
/**
* @returns {Promise<Array<Object> | null>} A promise that is resolved
* with an {Array} of digital signature metadata (signerName, reason,
* signingTime, byteRange, subFilter, ), or `null` when the document
* has no signatures. The PKCS#7 blob and signed-data byte spans
* needed for verification are fetched separately via
* {@link PDFDocumentProxy.getSignatureData} so they don't ride the
* worker boundary unless verification is actually requested.
*/
getSignatures() {
return this._transport.getSignatures();
}
/**
* @param {string} id Signature `id` from a {@link getSignatures} entry.
* @returns {Promise<{ data: Uint8Array[], pkcs7: Uint8Array } | null>}
* The byte payload needed to verify the signature, or `null` if the
* id is unknown.
*/
getSignatureData(id) {
return this._transport.getSignatureData(id);
}
/**
* @returns {Promise<boolean>} A promise that is resolved with `true`
* if some /AcroForm fields have JavaScript actions.
@ -3035,6 +3058,17 @@ class WorkerTransport {
return this.#cacheSimpleMethod("GetFieldObjects");
}
getSignatures() {
return this.#cacheSimpleMethod("GetSignatures");
}
getSignatureData(id) {
// Not cached: bytes should be one-shot. Holding them in the
// `#methodPromises` map would keep them alive for the document's
// lifetime, which defeats the metadata/data split.
return this.messageHandler.sendWithPromise("GetSignatureData", id);
}
hasJSActions() {
return this.#cacheSimpleMethod("HasJSActions");
}

3
test/pdfs/sig_corpus/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.pdf
*.p7s
*.pkcs7spec

View File

@ -0,0 +1,122 @@
# Digital signature properties — manual-test PDF corpus
This directory ships a Python generator that produces a small corpus
of digitally signed PDFs covering every visible state of the
**Digital signature properties** doorhanger. The intent is manual visual
testing: open each PDF in a Firefox build that has the signature
verification UI enabled, and compare what the toolbar / banner /
cards render against what the PDF's own page content says they
should render.
The PDFs themselves are **not committed** (`*.pdf` is ignored). Only
`generate.py` and this README are tracked, so you regenerate the
corpus when you need it.
## Prerequisites
1. A built mozilla-central checkout. The generator shells out to its
`security/manager/tools/pycms.py` and reuses its vendored Python
modules under
`third_party/python/{ecdsa,rsa,pyasn1,pyasn1_modules,six}`.
2. A Firefox build (Nightly or a local artefact / full build) that
includes the pdf.js viewer + chrome bridge for the Signature
Properties UI. Any Nightly built after the Bug 1943059 landing
contains both pieces.
The generator finds your mozilla-central checkout in this order:
1. `--mozilla-central </path/to/mozilla-central>` CLI flag.
2. `MOZILLA_CENTRAL_SRC` environment variable.
3. `/opt/mozilla/firefox` (fallback default; prints a warning).
## Generate
From the pdf.js root, with the path resolved via any of the methods
above:
```sh
python3 test/pdfs/sig_corpus/generate.py \
--mozilla-central ~/src/mozilla-central
# …or…
MOZILLA_CENTRAL_SRC=~/src/mozilla-central \
python3 test/pdfs/sig_corpus/generate.py
```
You should see eight `.pdf` files appear in this directory.
## Enable the test trust anchors pref
Three of the cases (`signed_verified`, both verified multi-sig PDFs,
and the outer half of `signed_multi_outer_verified_inner_expired`)
need Firefox to trust the bundled `pdf-sign-ca` test root. That root
is gated behind one pref:
```
security.pdf_signature_verification.enable_test_trust_anchors = true
```
The pref defaults to `false` in every Firefox build (Release, Beta,
Nightly, local), so by default a Firefox cannot validate PDFs
signed with these test certs. To enable it for manual testing:
- Easiest: append the contents of `user.js.example` (next to this
README) to your dev profile's `user.js` and (re)launch Firefox.
- Or via `about:config` → search for the pref name → toggle to
`true`.
⚠️ **Do not enable this in your normal browsing profile.** With the
pref on, any PDF signed with the publicly known mozilla-central test
private key validates as "trusted" until those certs expire
(`pdf-sign-ca` notAfter = 2027-01-01).
## Open the PDFs
Launch any Firefox build that bundles the Digital signature properties UI
and open the PDFs via `file:///` URLs, e.g.:
```sh
firefox file:///$(pwd)/test/pdfs/sig_corpus/signed_verified.pdf
```
Or `./mach run -- file:///…/signed_verified.pdf` from your
mozilla-central checkout.
The page content of every PDF describes the expected toolbar icon,
banner, status row, and certificate row. Compare it against the
doorhanger.
## Cases
| File | Toolbar icon | Banner | Notes |
|---|---|---|---|
| `signed_verified.pdf` | green ✓ | green | leaf ← `pdf-sign-ca` |
| `signed_untrusted.pdf` | orange ! | orange | self-signed root |
| `signed_expired.pdf` | orange ! | orange | leaf ← `pdf-sign-ca-expired` |
| `signed_invalid.pdf` | red × | red | one byte tampered post-sign |
| `signed_unknown.pdf` | red × | red | `/SubFilter /ETSI.CAdES.detached` (unsupported by pdf.js) |
| `signed_multi_verified.pdf` | green ✓ | green | both sigs valid, "Sub-signatures (1)" |
| `signed_multi_mixed.pdf` | orange ! | orange | outer verified, inner self-signed/untrusted |
| `signed_multi_outer_verified_inner_expired.pdf` | orange ! | orange | outer verified, inner expired — exercises worst-status-wins logic |
The last entry is the most informative for verifying that the
banner aggregation isn't accidentally clamped to the outermost
signature.
## Out of scope
- **`revoked` status.** Producing a revoked-certificate state
end-to-end against NSS requires either an OCSP responder, a CRL
file in the right path, or a OneCRL fixture — none of which are
feasible to ship as a static PDF corpus. The UI path for
`revoked` (red banner / red cert row / red toolbar icon) is
exercised only via the existing xpcshell tests.
- **CAdES validation.** `signed_unknown.pdf` only proves that pdf.js
short-circuits to `unknown` for `ETSI.CAdES.detached`; real CAdES
signature validation is follow-up Firefox work.
## Sanity check the safeguard
Open `signed_verified.pdf` with the pref **off** (default). Every
single PDF should now report `untrusted (unknown issuer)`. That's
the expected behavior in shipping Firefox and confirms the pref
guard is doing its job.

View File

@ -0,0 +1,775 @@
#!/usr/bin/env python3
# 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
"""Generate the Digital signature properties test-PDF corpus.
Produces one PDF per verification UI state. Each PDF embeds a PKCS#7
detached signature minted by ``pycms.py`` from mozilla-central, with the
``messageDigest`` matching the actual ``/ByteRange`` bytes of the PDF.
The visible page content of every PDF is the description of the expected
UI state for that case open the file in Firefox and compare what the
page says against what the Digital signature properties doorhanger renders.
Run from the pdf.js root:
python3 test/pdfs/sig_corpus/generate.py
Requires a built mozilla-central checkout so we can call its bundled
pycms.py / pycert.py / pykey.py and reuse the vendored Python deps under
third_party/python. The checkout location is resolved in this order:
1. The --mozilla-central CLI flag (highest priority).
2. The MOZILLA_CENTRAL_SRC environment variable.
3. /opt/mozilla/firefox (fallback default; will print a warning).
"""
import argparse
import base64
import hashlib
import os
import re
import subprocess
import sys
from pathlib import Path
CORPUS_DIR = Path(__file__).resolve().parent
# Default location, used only when no CLI flag or env var overrides it.
# Patched at runtime in main() once the path is resolved.
DEFAULT_MOZILLA_CENTRAL_DIR = Path("/opt/mozilla/firefox")
FIREFOX_DIR = DEFAULT_MOZILLA_CENTRAL_DIR
TOOLS_DIR = FIREFOX_DIR / "security/manager/tools"
PYCMS = TOOLS_DIR / "pycms.py"
def _resolve_mozilla_central_dir(cli_value):
"""CLI flag → MOZILLA_CENTRAL_SRC env var → default."""
if cli_value:
return Path(cli_value).expanduser().resolve()
env = os.environ.get("MOZILLA_CENTRAL_SRC")
if env:
return Path(env).expanduser().resolve()
sys.stderr.write(
f"warning: no --mozilla-central / MOZILLA_CENTRAL_SRC set, "
f"falling back to {DEFAULT_MOZILLA_CENTRAL_DIR}\n"
)
return DEFAULT_MOZILLA_CENTRAL_DIR
# Vendored Python modules pycms transitively imports.
VENDORED_DEPS = ["ecdsa", "rsa", "pyasn1", "pyasn1_modules", "six"]
# /Contents placeholder is sized so any single PKCS#7 we generate fits.
# The blobs we mint are ~1.2 KB each, so 4 KB of payload = 8192 hex chars.
PLACEHOLDER_PKCS7_LEN = 4096
def python_path_for_pycms():
parts = [str(FIREFOX_DIR / "third_party/python" / dep) for dep in VENDORED_DEPS]
parts.append(str(TOOLS_DIR))
return os.pathsep.join(parts)
def run_pycms(spec_text):
"""Invoke pycms.py with the given spec, return raw DER bytes of the PKCS#7."""
env = os.environ.copy()
env["PYTHONPATH"] = python_path_for_pycms()
proc = subprocess.run(
[sys.executable, str(PYCMS)],
input=spec_text.encode("ascii"),
env=env,
capture_output=True,
check=False,
)
if proc.returncode != 0:
sys.stderr.write(proc.stderr.decode("utf-8", "replace"))
raise SystemExit(f"pycms.py failed for spec:\n{spec_text}")
pem = proc.stdout.decode("ascii")
body = re.sub(r"(-----.*?-----|\s)", "", pem)
return base64.b64decode(body)
# ---------------------------------------------------------------------
# Tiny PDF builder
# ---------------------------------------------------------------------
def _wrap_lines(text, max_width=72):
out = []
for paragraph in text.split("\n"):
if not paragraph:
out.append("")
continue
words = paragraph.split(" ")
line = ""
for w in words:
if not line:
line = w
elif len(line) + 1 + len(w) <= max_width:
line += " " + w
else:
out.append(line)
line = w
if line:
out.append(line)
return out
_ASCII_REPLACEMENTS = {
"": "--", # em dash
"": "-", # en dash
"": "'",
"": "'",
"": '"',
"": '"',
"": "...",
"": "->",
"": "<-",
"×": "x",
"": "v",
"": "x",
}
def _escape_pdf_string(s):
for src, dst in _ASCII_REPLACEMENTS.items():
s = s.replace(src, dst)
# Strip any remaining non-ASCII characters as a safety net.
s = s.encode("ascii", "replace").decode("ascii")
return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
def _content_stream_for(text):
"""Build a content stream that paints the text top-down with Helvetica."""
lines = _wrap_lines(text)
cmds = ["BT", "/F1 11 Tf", "12 TL", "50 780 Td"]
for i, line in enumerate(lines):
if i == 0:
cmds.append(f"({_escape_pdf_string(line)}) Tj")
else:
cmds.append(f"T*")
cmds.append(f"({_escape_pdf_string(line)}) Tj")
cmds.append("ET")
return "\n".join(cmds).encode("latin-1")
class PdfBuilder:
"""Minimal one-page PDF builder with a single /Sig field.
Returned bytes have placeholder ByteRange [0 0 0 0] and a /Contents hex
string of zero bytes. The caller patches ByteRange and /Contents in
place after computing the offsets.
"""
def __init__(self, page_text, sub_filter="/adbe.pkcs7.detached"):
self.page_text = page_text
self.sub_filter = sub_filter
def build(self):
contents_stream = _content_stream_for(self.page_text)
objs = {}
objs[1] = (
b"<< /Type /Catalog /Pages 2 0 R "
b"/AcroForm << /Fields [4 0 R] /SigFlags 3 >> >>"
)
objs[2] = b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>"
objs[3] = (
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] "
b"/Contents 7 0 R /Resources << /Font << /F1 8 0 R >> >> >>"
)
objs[4] = (
b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) "
b"/V 5 0 R /Rect [0 0 0 0] /F 4 /P 3 0 R >>"
)
# Object 5 (the /Sig dict) is built later because it contains the
# placeholders we need to patch.
objs[7] = (
b"<< /Length " + str(len(contents_stream)).encode("ascii") + b" >>\n"
b"stream\n" + contents_stream + b"\nendstream"
)
objs[8] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
# Emit header + objects 1-4, then build the /Sig dict knowing
# /Contents will sit at a precise offset.
chunks = [b"%PDF-1.7\n%\xc2\xa5\xc2\xb1\xc3\xab\n"]
offsets = {}
def emit_obj(num, body):
header = f"{num} 0 obj\n".encode("ascii")
offsets[num] = sum(len(c) for c in chunks)
chunks.append(header + body + b"\nendobj\n")
emit_obj(1, objs[1])
emit_obj(2, objs[2])
emit_obj(3, objs[3])
emit_obj(4, objs[4])
# Object 5 — the /Sig dict. We build the prefix, capture the
# offset of the '<' that starts /Contents, append the placeholder
# zeros, then continue with the rest.
sig_prefix = (
b"<< /Type /Sig "
b"/Filter /Adobe.PPKLite "
b"/SubFilter " + self.sub_filter.encode("ascii") + b" "
b"/M (D:20260509000000Z) "
b"/Reason (Test signature for pdf.js Digital signature properties UI) "
b"/ByteRange [0000000000 0000000000 0000000000 0000000000] "
b"/Contents <"
)
sig_suffix_zeros = b"0" * (PLACEHOLDER_PKCS7_LEN * 2)
sig_suffix_close = b"> >>"
obj5_header = b"5 0 obj\n"
offsets[5] = sum(len(c) for c in chunks)
chunks.append(obj5_header + sig_prefix)
contents_start_in_obj = sum(len(c) for c in chunks) - 1 # the '<' byte
chunks.append(sig_suffix_zeros)
chunks.append(sig_suffix_close)
chunks.append(b"\nendobj\n")
emit_obj(7, objs[7])
emit_obj(8, objs[8])
# xref + trailer.
xref_offset = sum(len(c) for c in chunks)
xref_lines = [b"xref\n0 9\n", b"0000000000 65535 f \n"]
for num in range(1, 9):
if num == 6:
xref_lines.append(b"0000000000 65535 f \n")
continue
off = offsets[num]
xref_lines.append(f"{off:010d} 00000 n \n".encode("ascii"))
chunks.extend(xref_lines)
chunks.append(
b"trailer\n<< /Size 9 /Root 1 0 R >>\nstartxref\n"
+ str(xref_offset).encode("ascii")
+ b"\n%%EOF\n"
)
pdf = b"".join(chunks)
# Locate placeholders in the final byte string.
contents_open = pdf.index(b"/Contents <") + len(b"/Contents ")
# contents_open points at '<'; the hex blob starts right after.
hex_start = contents_open + 1
hex_end = hex_start + PLACEHOLDER_PKCS7_LEN * 2
assert pdf[hex_start:hex_end] == sig_suffix_zeros
assert pdf[hex_end:hex_end + 1] == b">"
return bytearray(pdf), contents_open, hex_start, hex_end
def _patch_byte_range(pdf, byte_range):
placeholder = b"/ByteRange [0000000000 0000000000 0000000000 0000000000]"
a, b_, c, d = byte_range
replacement = (
f"/ByteRange [{a:010d} {b_:010d} {c:010d} {d:010d}]".encode("ascii")
)
assert len(replacement) == len(placeholder)
idx = pdf.index(placeholder)
pdf[idx:idx + len(placeholder)] = replacement
def _splice_pkcs7(pdf, hex_start, hex_end, pkcs7_der):
pkcs7_hex = pkcs7_der.hex().upper().encode("ascii")
if len(pkcs7_hex) > (hex_end - hex_start):
raise SystemExit(
f"PKCS#7 ({len(pkcs7_hex) // 2} bytes) larger than placeholder "
f"({(hex_end - hex_start) // 2})"
)
padded = pkcs7_hex.ljust(hex_end - hex_start, b"0")
pdf[hex_start:hex_end] = padded
# ---------------------------------------------------------------------
# Cases
# ---------------------------------------------------------------------
CASES = []
def _register(name, page_text, spec_template, *, sub_filter="/adbe.pkcs7.detached", post_process=None):
"""Register a single-signature case.
``spec_template`` is a string with ``{sha256}`` replaced by the
computed digest at generation time.
``post_process(pdf)`` runs after splicing the PKCS#7, allowing the
"invalid" case to flip a byte inside the ByteRange.
"""
CASES.append({
"name": name,
"page_text": page_text,
"spec_template": spec_template,
"sub_filter": sub_filter,
"post_process": post_process,
})
PAGE_HEADER = """\
Digital signature properties pdf.js test corpus
=========================================
This PDF is part of the manual-test corpus for the Signature
Properties UI. The text below describes what the toolbar button
and the doorhanger should look like when this file is opened in
a Firefox build with
security.pdf_signature_verification.enable_test_trust_anchors
set to true.
"""
_register(
"signed_verified",
PAGE_HEADER + """\
Expected verification state: VERIFIED
Toolbar icon: GREEN circle with white check.
Banner: GREEN. "Document signed and verified".
Status row: GREEN check, "Status: Signature verified".
Certificate row: GREEN check, "Certificate: Trusted (pdf-sign-ca)".
(Green only because this is the top-level card AND
every signature in the document is verified.)
"View certificate" link below the timestamp opens about:certificate.
No sub-signatures.
""",
"""\
sha256:{sha256}
signer:
issuer:pdf-sign-ca
subject:test-pdf-signer
""",
)
_register(
"signed_untrusted",
PAGE_HEADER + """\
Expected verification state: UNTRUSTED
The signing certificate is self-signed and does not chain to any
trusted root, so even with the test trust anchors pref enabled the
chain validation reports SEC_ERROR_UNKNOWN_ISSUER.
Toolbar icon: ORANGE circle with white exclamation.
Banner: ORANGE. "Document signed with a certificate that
is not trusted".
Status row: grey check, "Status: Signature verified".
Certificate row: orange exclamation, "Certificate: Unknown issuer
(Untrusted Self-Signed Test Root)" -- the
parenthetical is the issuer CN from the cert.
""",
"""\
sha256:{sha256}
signer:
issuer:Untrusted Self-Signed Test Root
subject:Untrusted Self-Signed Test Root
""",
)
_register(
"signed_expired",
PAGE_HEADER + """\
Expected verification state: EXPIRED
The signer is issued by pdf-sign-ca-expired, whose validity ended
in 2020. The CMS signature itself is fine (NS_OK) but chain
validation reports SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE.
Toolbar icon: ORANGE circle with white exclamation.
Banner: ORANGE. "Document signed with an expired
certificate".
Status row: grey check, "Status: Signature verified" -- the
cryptographic signature is fine, only the
certificate has expired.
Certificate row: orange exclamation, "Certificate: Expired
(Dec 31, 2020)" -- the parenthetical is the
leaf cert's notAfter date.
""",
"""\
sha256:{sha256}
signer:
issuer:pdf-sign-ca-expired
subject:test-pdf-signer-expired
""",
)
def _tamper_byterange(pdf):
# Flip one byte inside the page content stream so the CMS hash
# check fails.
marker = b"signed_invalid"
# The page text contains the file's name, so flipping a letter inside
# the marker is guaranteed to fall within the ByteRange.
idx = pdf.index(marker)
pdf[idx] = ord("S") if pdf[idx] == ord("s") else ord("s")
_register(
"signed_invalid",
PAGE_HEADER + """\
Expected verification state: INVALID signed_invalid
After signing, one byte inside the document was flipped, so the
PKCS#7 messageDigest no longer matches the actual ByteRange data.
NSS returns SEC_ERROR_PKCS7_BAD_SIGNATURE the signature is no
longer valid evidence that the document is intact.
Toolbar icon: RED circle with white cross.
Banner: RED. "Document has an invalid signature".
Status row: red cross, "Status: Signature invalid".
Certificate row: green check (cert was fine, only the signature
broke), "Certificate: Trusted (pdf-sign-ca)".
""",
"""\
sha256:{sha256}
signer:
issuer:pdf-sign-ca
subject:test-pdf-signer
""",
post_process=_tamper_byterange,
)
_register(
"signed_unknown",
PAGE_HEADER + """\
Expected verification state: UNKNOWN (unsupported)
The /Sig dict uses /SubFilter /ETSI.CAdES.detached, which pdf.js
maps to signatureType: null and never sends to NSS. The result
short-circuits to status: unknown without any cryptographic check.
Toolbar icon: RED circle with white cross (verification failed).
Banner: RED. "Document signed but the signature could
not be verified".
Status row: red cross, "Status: Unable to verify (unsupported)".
Certificate row: red cross, "Certificate: Unavailable".
No "View certificate" button (no certificate to show).
""",
# Spec is irrelevant (we do not actually call pycms for this case);
# the placeholder zeros stay in /Contents.
None,
sub_filter="/ETSI.CAdES.detached",
)
# ---------------------------------------------------------------------
# Driver
# ---------------------------------------------------------------------
def _build_single(case):
builder = PdfBuilder(case["page_text"], sub_filter=case["sub_filter"])
pdf, contents_open, hex_start, hex_end = builder.build()
file_len = len(pdf)
a = 0
b = contents_open # bytes up to and including '<'? We want bytes up to '<' EXclusive.
# /Contents <abcdef...> — the bytes covered by ByteRange are everything
# except the hex string between '<' and '>'. So b = contents_open + 1
# (include the '<') and c = hex_end (the '>'), d = file_len - hex_end.
b = contents_open + 1
c = hex_end
d = file_len - hex_end
_patch_byte_range(pdf, (a, b, c, d))
if case["spec_template"] is None:
# "unknown" case — leave /Contents zeros.
return pdf
digest = hashlib.sha256(bytes(pdf[a:a + b]) + bytes(pdf[c:c + d])).hexdigest()
spec_text = case["spec_template"].format(sha256=digest)
pkcs7_der = run_pycms(spec_text)
_splice_pkcs7(pdf, hex_start, hex_end, pkcs7_der)
if case.get("post_process"):
case["post_process"](pdf)
return pdf
# Multi-signature cases use incremental updates: build an inner-signed
# PDF first, then append additional objects + a second /Sig field whose
# /ByteRange covers the entire updated file.
def _build_multi(name, page_text, inner_spec_template, outer_spec_template):
"""Two-signature PDF.
The inner signature is created normally over a single-page PDF.
Then we incrementally append a new /AcroForm referencing both Sig
fields and a second /Sig dict whose /ByteRange covers the full
file. Both signatures are independent CMS messages over their own
/ByteRange spans, exactly the pattern issue17169.pdf uses.
"""
inner = PdfBuilder(page_text, sub_filter="/adbe.pkcs7.detached")
pdf, c_open, h_start, h_end = inner.build()
a, b = 0, c_open + 1
c = h_end
d = len(pdf) - h_end
_patch_byte_range(pdf, (a, b, c, d))
digest = hashlib.sha256(bytes(pdf[a:a + b]) + bytes(pdf[c:c + d])).hexdigest()
pkcs7_der = run_pycms(inner_spec_template.format(sha256=digest))
_splice_pkcs7(pdf, h_start, h_end, pkcs7_der)
inner_pdf = bytes(pdf)
# Incremental update: append updated catalog with a second /Sig field
# plus the new /Sig dict + xref + trailer. Each new object gets a new
# number; we reuse 9, 10, 11, 12 for second sig field, second sig
# dict, updated catalog, and updated AcroForm.
# Build the appended chunk with placeholders we can patch in place.
update_offsets = {}
update_chunks = []
# Object 9: second Sig field annot.
update_chunks.append(b"\n9 0 obj\n")
update_offsets[9] = len(inner_pdf) + sum(len(c) for c in update_chunks) - len(b"\n9 0 obj\n")
update_chunks.append(
b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature2) "
b"/V 10 0 R /Rect [0 0 0 0] /F 4 /P 3 0 R >>\nendobj\n"
)
# Object 10: second Sig dict (placeholders).
sig_prefix = (
b"<< /Type /Sig "
b"/Filter /Adobe.PPKLite "
b"/SubFilter /adbe.pkcs7.detached "
b"/M (D:20260509000001Z) "
b"/Reason (Outer signature for sub-signature test) "
b"/ByteRange [0000000000 0000000000 0000000000 0000000000] "
b"/Contents <"
)
sig_zeros = b"0" * (PLACEHOLDER_PKCS7_LEN * 2)
update_chunks.append(b"10 0 obj\n")
update_offsets[10] = len(inner_pdf) + sum(len(c) for c in update_chunks) - len(b"10 0 obj\n")
update_chunks.append(sig_prefix)
contents_open_2 = len(inner_pdf) + sum(len(c) for c in update_chunks) - 1 # '<'
update_chunks.append(sig_zeros)
update_chunks.append(b">")
update_chunks.append(b" >>\nendobj\n")
# Object 1 (catalog) updated to point at a new AcroForm with both
# fields. We rewrite the same number; the xref will mark it as
# generation 0 again with a new offset.
update_chunks.append(b"1 0 obj\n")
update_offsets[1] = len(inner_pdf) + sum(len(c) for c in update_chunks) - len(b"1 0 obj\n")
update_chunks.append(
b"<< /Type /Catalog /Pages 2 0 R "
b"/AcroForm << /Fields [4 0 R 9 0 R] /SigFlags 3 >> >>\nendobj\n"
)
appended = b"".join(update_chunks)
pdf = bytearray(inner_pdf + appended)
# Compute final positions of the new placeholder.
hex_start_2 = contents_open_2 + 1
hex_end_2 = hex_start_2 + PLACEHOLDER_PKCS7_LEN * 2
assert pdf[contents_open_2:contents_open_2 + 1] == b"<"
assert pdf[hex_end_2:hex_end_2 + 1] == b">"
# Build incremental xref table.
# Pre-xref offset for startxref.
pre_xref = bytearray(pdf)
# Patch ByteRange of outer Sig: covers everything except the hex
# blob inside the second /Contents.
outer_a = 0
outer_b = contents_open_2 + 1
outer_c = hex_end_2
# We don't know `d` until we know where the xref/trailer is, which
# depends on `d` itself if the offset/`d` lengths change... but
# ByteRange numbers are fixed-width 010d so they don't change size.
# So we can compute `d` once we know where the file ends.
# We'll construct the rest assuming `d` is the bytes from hex_end_2
# to EOF. Build the xref + trailer relative to current position.
xref_offset = len(pre_xref)
xref = (
b"xref\n"
+ b"1 1\n" + f"{update_offsets[1]:010d} 00000 n \n".encode("ascii")
+ b"9 2\n"
+ f"{update_offsets[9]:010d} 00000 n \n".encode("ascii")
+ f"{update_offsets[10]:010d} 00000 n \n".encode("ascii")
)
trailer = (
b"trailer\n"
+ b"<< /Size 11 /Root 1 0 R /Prev "
+ str(_find_startxref(inner_pdf)).encode("ascii")
+ b" >>\nstartxref\n"
+ str(xref_offset).encode("ascii")
+ b"\n%%EOF\n"
)
pdf = bytearray(pre_xref + xref + trailer)
outer_d = len(pdf) - outer_c
_patch_byte_range_from(
pdf, outer_a, outer_b, outer_c, outer_d,
# The placeholder belongs to obj 10 only.
from_offset=update_offsets[10],
)
digest_outer = hashlib.sha256(
bytes(pdf[outer_a:outer_a + outer_b]) + bytes(pdf[outer_c:outer_c + outer_d])
).hexdigest()
pkcs7_outer = run_pycms(outer_spec_template.format(sha256=digest_outer))
_splice_pkcs7(pdf, hex_start_2, hex_end_2, pkcs7_outer)
return pdf
def _find_startxref(pdf_bytes):
idx = pdf_bytes.rindex(b"startxref")
after = pdf_bytes[idx + len(b"startxref"):]
m = re.search(rb"\d+", after)
return int(m.group(0))
def _patch_byte_range_from(pdf, a, b, c, d, *, from_offset):
placeholder = b"/ByteRange [0000000000 0000000000 0000000000 0000000000]"
replacement = (
f"/ByteRange [{a:010d} {b:010d} {c:010d} {d:010d}]".encode("ascii")
)
assert len(replacement) == len(placeholder)
idx = pdf.index(placeholder, from_offset)
pdf[idx:idx + len(placeholder)] = replacement
SPEC_VERIFIED = """\
sha256:{sha256}
signer:
issuer:pdf-sign-ca
subject:test-pdf-signer
"""
SPEC_EXPIRED = """\
sha256:{sha256}
signer:
issuer:pdf-sign-ca-expired
subject:test-pdf-signer-expired
"""
SPEC_UNTRUSTED = """\
sha256:{sha256}
signer:
issuer:Untrusted Self-Signed Test Root
subject:Untrusted Self-Signed Test Root
"""
MULTI_CASES = [
(
"signed_multi_verified",
PAGE_HEADER + """\
Expected verification state: VERIFIED (multi-signature)
Outer signature: leaf <- pdf-sign-ca -> verified.
Inner signature: leaf <- pdf-sign-ca -> verified.
Toolbar icon: GREEN check. Banner: GREEN, "Document signed and
verified" (count = 2). Outer card has GREEN status check + GREEN
"Certificate: Trusted (pdf-sign-ca)" (everything is fine, top-
level). The expanded inner card uses the muted GREY check on
both rows -- green is reserved for the top-level card.
""",
# inner_spec, outer_spec
SPEC_VERIFIED, SPEC_VERIFIED,
),
(
"signed_multi_mixed",
PAGE_HEADER + """\
Expected verification state: UNTRUSTED (multi-signature)
Outer signature: leaf <- pdf-sign-ca -> verified.
Inner signature: self-signed -> untrusted.
Toolbar icon: ORANGE exclamation. Banner: ORANGE, "Document signed
with a certificate that is not trusted" (count = 1, the inner sig).
Outer card shows GREY check, "Certificate: Trusted (pdf-sign-ca)"
(green is suppressed because the inner sig is untrusted). Expanded
inner card shows orange "Unknown issuer (Untrusted Self-Signed
Test Root)".
""",
# inner = untrusted, outer = verified
SPEC_UNTRUSTED, SPEC_VERIFIED,
),
(
"signed_multi_outer_verified_inner_expired",
PAGE_HEADER + """\
Expected verification state: EXPIRED (multi-signature)
Outer signature: leaf <- pdf-sign-ca -> verified.
Inner signature: leaf <- pdf-sign-ca-expired -> expired.
This case is the most informative test of the worst-status banner
aggregation: the top-level signature is fully valid, but the worst
status across the whole tree is "expired", so the banner must show
the orange "...with an expired certificate" message and the toolbar
button must use the orange warn icon.
Outer card: status grey check, certificate GREY "Trusted
(pdf-sign-ca)" (green suppressed because the inner sig
is expired).
Inner card: status grey check (signature itself is verified),
certificate orange "Expired (Dec 31, 2020)".
""",
# inner = expired, outer = verified
SPEC_EXPIRED, SPEC_VERIFIED,
),
]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--out",
type=Path,
default=CORPUS_DIR,
help="Output directory (default: this script's directory).",
)
parser.add_argument(
"--mozilla-central",
type=Path,
default=None,
help=(
"Path to a built mozilla-central checkout. Defaults to the "
"MOZILLA_CENTRAL_SRC env var, then /opt/mozilla/firefox."
),
)
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
global FIREFOX_DIR, TOOLS_DIR, PYCMS
FIREFOX_DIR = _resolve_mozilla_central_dir(args.mozilla_central)
TOOLS_DIR = FIREFOX_DIR / "security/manager/tools"
PYCMS = TOOLS_DIR / "pycms.py"
if not PYCMS.exists():
raise SystemExit(
f"pycms.py not found at {PYCMS}\n"
f"Pass --mozilla-central </path/to/mozilla-central> or set the "
f"MOZILLA_CENTRAL_SRC environment variable."
)
for case in CASES:
pdf = _build_single(case)
path = args.out / f"{case['name']}.pdf"
path.write_bytes(bytes(pdf))
print(f" wrote {path.name} ({len(pdf)} bytes)")
for name, page_text, inner_spec, outer_spec in MULTI_CASES:
# NB: Inner signature template has a unique tag so the post-process
# tampering of "signed_invalid" can target it; multi cases don't
# need post_process.
pdf = _build_multi(name, page_text, inner_spec, outer_spec)
path = args.out / f"{name}.pdf"
path.write_bytes(bytes(pdf))
print(f" wrote {path.name} ({len(pdf)} bytes)")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,9 @@
// Append this line to your dev Firefox profile's user.js to make
// the embedded test PDF-signing trust anchors participate in chain
// validation.
//
// DO NOT add this to a profile you use for normal browsing — it
// allows any PDF signed with the publicly known mozilla-central
// test CA private key to verify as "trusted" until those certs
// expire (2027-01-01 for pdf-sign-ca).
user_pref("security.pdf_signature_verification.enable_test_trust_anchors", true);

View File

@ -45,7 +45,11 @@ describe("document", function () {
});
describe("PDFDocument", function () {
const stream = new StringStream("Dummy_PDF_data");
// Padded to 1024 bytes so signature ByteRange tests using offsets
// like `[0, 100, 200, 800]` stay within `stream.end` (the new
// `#parseSignatureDict` validation rejects ByteRanges that exceed
// the file length).
const stream = new StringStream("Dummy_PDF_data".padEnd(1024, " "));
function getDocument(acroForm, xref = new XRefMock()) {
const catalog = { acroForm };
@ -188,6 +192,221 @@ 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 null when no signatures are present", async function () {
const acroForm = new Dict();
const pdfDocument = getDocument(acroForm);
const signatures = await pdfDocument.signatures;
expect(signatures).toBeNull();
});
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);
// The bytes (pkcs7 + signed-data spans) are no longer attached
// to the metadata array — they're fetched on demand via
// `getSignatureData(id)` so the worker→main message stays
// small.
expect(sig.pkcs7).toBeUndefined();
expect(sig.data).toBeUndefined();
const bytes = await pdfDocument.getSignatureData(sig.id);
expect(bytes.pkcs7).toBeInstanceOf(Uint8Array);
expect(Array.isArray(bytes.data)).toBeTrue();
expect(bytes.data.length).toEqual(2);
});
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).toBeNull();
});
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();

View File

@ -94,6 +94,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-digital_signature_properties_manager";
import { Toolbar } from "web-toolbar";
import { ViewHistory } from "./view_history.js";
import { ViewsManager } from "web-views_manager";
@ -164,6 +165,8 @@ const PDFViewerApplication = {
l10n: null,
/** @type {AnnotationEditorParams} */
annotationEditorParams: null,
/** @type {SignaturePropertiesManager|null} */
signaturePropertiesManager: null,
/** @type {ImageAltTextSettings} */
imageAltTextSettings: null,
isInitialViewSet: false,
@ -1204,6 +1207,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;
@ -1869,12 +1873,42 @@ const PDFViewerApplication = {
}
if (info.IsSignaturesPresent) {
console.warn("Warning: Digital signatures validation is not supported");
const success = this._maybeInitSignatureProperties(pdfDocument);
if (!success) {
console.warn("Warning: Digital signatures validation is not supported");
}
}
this.eventBus.dispatch("metadataloaded", { source: this });
},
/**
* @private
* @returns {boolean} `true` when signature verification was wired up;
* `false` when the runtime doesn't expose a verifier (everything
* except the Firefox build) or the option is turned off.
*/
_maybeInitSignatureProperties(pdfDocument) {
if (!AppOptions.get("enableSignatureVerification")) {
return false;
}
const verifier = this.externalServices.createSignatureVerifier();
if (!verifier) {
return false;
}
if (pdfDocument !== this.pdfDocument) {
// Don't warn about a previous document.
return true;
}
this.signaturePropertiesManager ??= new SignaturePropertiesManager({
appConfig: this.appConfig.toolbar,
verifier,
eventBus: this.eventBus,
});
this.signaturePropertiesManager.loadFromDocument(pdfDocument);
return true;
},
/**
* @private
*/
@ -2999,6 +3033,12 @@ function closeEditorUndoBar(evt) {
}
}
function closeSignatureProperties({ target }) {
if (this.signaturePropertiesManager?.shouldCloseOnClick(target)) {
this.signaturePropertiesManager.close();
}
}
function onBeforeUnload(evt) {
if (this._hasChanges()) {
evt.preventDefault();
@ -3011,6 +3051,7 @@ function onBeforeUnload(evt) {
function onClick(evt) {
closeSecondaryToolbar.call(this, evt);
closeEditorUndoBar.call(this, evt);
closeSignatureProperties.call(this, evt);
}
function onKeyUp(evt) {
@ -3226,6 +3267,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;

View File

@ -277,6 +277,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"),

View File

@ -0,0 +1,412 @@
/* 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.
*/
/* Digital 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.
*/
:root {
--sig-card-border: light-dark(rgb(228 228 232), rgb(82 82 86));
--sig-card-bg: var(--field-bg-color, light-dark(white, rgb(64 64 68)));
--sig-card-nested-bg: light-dark(rgb(252 252 253), rgb(72 72 76));
--sig-row-color: light-dark(rgb(58 58 60), rgb(228 228 232));
--sig-detail-color: light-dark(rgb(96 96 96), rgb(180 180 184));
--sig-divider-color: light-dark(rgb(228 228 232), rgb(82 82 86));
--sig-summary-hover-color: light-dark(rgb(28 67 138), rgb(126 169 255));
--sig-link-color: light-dark(rgb(28 113 216), rgb(126 169 255));
--sig-link-hover-bg: light-dark(
rgb(28 113 216 / 0.1),
rgb(126 169 255 / 0.15)
);
--sig-banner-verified-bg: light-dark(rgb(228 247 235), rgb(28 84 49));
--sig-banner-verified-color: light-dark(rgb(16 92 47), rgb(176 232 196));
--sig-banner-warn-bg: light-dark(rgb(255 247 217), rgb(95 67 9));
--sig-banner-warn-color: light-dark(rgb(124 84 9), rgb(255 222 153));
--sig-banner-error-bg: light-dark(rgb(254 226 235), rgb(122 21 51));
--sig-banner-error-color: light-dark(rgb(167 26 70), rgb(255 188 207));
/* Tint colours for the row / toolbar icons. These are paired with
* `mask-image` so the icons recolour for light/dark/HCM. The four
* semantics map to: default = grey (signature crypto verified but
* not endorsed), warn = orange (cert trust/validity issue),
* error = red (signature itself failed or could not be checked),
* verified = green (only used for the top-level "everything fine"
* row and the toolbar's verified badge). */
--sig-icon-default: light-dark(rgb(150 150 150), rgb(180 180 184));
--sig-icon-warn: light-dark(rgb(217 142 27), rgb(255 178 77));
--sig-icon-error: light-dark(rgb(196 31 71), rgb(255 117 145));
--sig-icon-verified: light-dark(rgb(29 142 61), rgb(106 210 126));
@media screen and (forced-colors: active) {
/* HCM keywords are picked by *semantic role*, not by hue the
* user's high-contrast theme resolves them to whatever palette it
* ships. The same role-vocabulary as alt-text
* (annotation_editor_layer_builder.css:261-275) is used here so
* the two panels read consistently:
* - ButtonFace / ButtonText: "control surface + text"
* (banner background + body text inside it).
* - AccentColor: the user's accent used as a saturated
* emphasis foreground for severity icons and the banner's
* left stripe (same role alt-text uses for its hover
* foreground).
* - ButtonBorder: dedicated control-border keyword for the
* outer card frame.
* - GrayText: muted text (detail rows, divider, default
* "everything OK" row icon).
* - LinkText: link colour.
* Background-typed keywords are never used as foregrounds. */
--sig-card-border: ButtonBorder;
--sig-card-bg: Canvas;
--sig-card-nested-bg: ButtonFace;
--sig-row-color: ButtonText;
--sig-detail-color: GrayText;
--sig-divider-color: GrayText;
--sig-summary-hover-color: AccentColor;
--sig-link-color: LinkText;
--sig-link-hover-bg: transparent;
--sig-banner-verified-bg: ButtonFace;
--sig-banner-verified-color: ButtonText;
--sig-banner-warn-bg: ButtonFace;
--sig-banner-warn-color: ButtonText;
--sig-banner-error-bg: ButtonFace;
--sig-banner-error-color: ButtonText;
/* Severities collapse to the same emphasis keyword (AccentColor)
* in HCM same trick as alt-text where `done` and `warning`
* share their hover colour. The glyph shape (check vs `!` vs ``)
* carries the remaining distinction. The neutral "row crypto
* verified" check stays muted (GrayText). */
--sig-icon-default: GrayText;
--sig-icon-warn: AccentColor;
--sig-icon-error: AccentColor;
--sig-icon-verified: AccentColor;
}
}
#signaturePropertiesButton {
/* Default (no state class yet): use the regular signature icon.
* Mirror under RTL via the shared --dir-factor so the icon stays
* visually aligned with the rest of the toolbar (same pattern as
* the comment button). The state-* rules below override
* `mask-image` only the transform is inherited. */
&::before {
mask-image: var(--toolbarButton-editorSignature-icon);
transform: scaleX(var(--dir-factor));
}
/* When a verification state is set, switch the mask to the matching
* state badge and tint via background-color so the badge inherits
* light/dark/HCM via the `--sig-icon-*` vars. */
&.state-verified::before,
&.state-warn::before,
&.state-error::before {
opacity: 1;
}
&.state-verified::before {
mask-image: var(--toolbarButton-signaturePropertiesVerified-icon);
background-color: var(--sig-icon-verified);
}
&.state-warn::before {
mask-image: var(--toolbarButton-signaturePropertiesWarn-icon);
background-color: var(--sig-icon-warn);
}
&.state-error::before {
mask-image: var(--toolbarButton-signaturePropertiesError-icon);
background-color: var(--sig-icon-error);
}
/* Loading state: three .loadingDot spans pulse in sequence via
* `animation-delay`. The spans are injected once at construction (see
* SignaturePropertiesManager) and are width/height 0 by default thanks
* to the `.toolbarButton > span` rule they only become visible when
* this `state-loading` modifier is set. */
&.state-loading {
&::before {
display: none;
}
.loadingDot {
display: inline-block;
width: 4px;
height: 4px;
margin: 0 1px;
border-radius: 50%;
background: var(--toolbar-icon-bg-color);
animation: signaturePropertiesDot 1.2s ease-in-out infinite both;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
}
@keyframes signaturePropertiesDot {
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.85);
}
40% {
opacity: 1;
transform: scale(1);
}
}
#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;
/* The left stripe reuses the per-severity icon tint so the banner
* keeps a meaningful accent in HCM (where the bg/fg both flatten to
* Canvas/CanvasText). The icon tint vars are themed semantically
* ("danger" / "accent" / "muted"), so the stripe colour follows the
* user's high-contrast palette without us hard-coding any hue. */
border-inline-start: 3px solid currentcolor;
&.verified {
background: var(--sig-banner-verified-bg);
color: var(--sig-banner-verified-color);
border-inline-start-color: var(--sig-icon-verified);
}
&.warn {
background: var(--sig-banner-warn-bg);
color: var(--sig-banner-warn-color);
border-inline-start-color: var(--sig-icon-warn);
}
&.error {
background: var(--sig-banner-error-bg);
color: var(--sig-banner-error-color);
border-inline-start-color: var(--sig-icon-error);
}
}
.signaturePropertiesList {
list-style: none;
margin: 0;
padding: 0 12px 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.sigCard {
border: 1px solid var(--sig-card-border);
border-radius: 6px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 3px;
background: var(--sig-card-bg);
.signer {
font-weight: 600;
font-size: 13px;
letter-spacing: 0.1px;
margin-bottom: 1px;
}
.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: var(--sig-row-color);
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;
/* 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;
/* The icon shape is a mask-image; the tint comes from
* `background-color`, which lets every row icon adapt to
* light/dark/HCM via the `--sig-icon-*` vars in `:root`. */
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
mask-image: var(--signatureProperties-rowCheck-icon);
background-color: var(--sig-icon-default);
/* Mirror under RTL the per-status modifiers below override
* `mask-image` only, so the transform applies uniformly. */
transform: scaleX(var(--dir-factor));
}
/* "Everything-OK" rows (signature crypto verified, even if the
* cert chain is untrusted/expired/etc.) and the trusted-cert row
* keep the muted grey check. */
&.status--verified::before,
&.status--untrusted::before,
&.status--expired::before,
&.status--revoked::before,
&.cert--trusted::before {
mask-image: var(--signatureProperties-rowCheck-icon);
background-color: var(--sig-icon-default);
}
&.cert--untrusted::before,
&.cert--expired::before {
mask-image: var(--toolbarButton-signaturePropertiesWarn-icon);
background-color: var(--sig-icon-warn);
}
&.cert--revoked::before,
&.status--invalid::before,
&.status--unknown::before,
&.cert--unknown::before {
mask-image: var(--toolbarButton-signaturePropertiesError-icon);
background-color: var(--sig-icon-error);
}
}
/* 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` modifier is set in #render. */
&.sigCard--top-allfine > .row.status--verified::before,
&.sigCard--top-allfine > .row.cert--trusted::before {
mask-image: var(--toolbarButton-signaturePropertiesVerified-icon);
background-color: var(--sig-icon-verified);
}
.detail {
font-size: 11.5px;
color: var(--sig-detail-color);
margin-inline-start: 20px;
line-height: 1.35;
}
.viewCert {
align-self: center;
margin-top: 4px;
color: var(--sig-link-color);
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
white-space: nowrap;
&:hover {
background: var(--sig-link-hover-bg);
text-decoration: underline;
}
&:focus-visible {
outline: 2px solid var(--sig-link-color);
outline-offset: 1px;
}
}
.subSignatures {
margin-top: 4px;
border-top: 1px dashed var(--sig-divider-color);
padding-top: 4px;
font-size: 12px;
> summary {
cursor: pointer;
user-select: none;
list-style: none;
color: var(--sig-detail-color);
display: flex;
align-items: center;
gap: 4px;
padding: 2px 0;
&:hover {
color: var(--sig-summary-hover-color);
}
&::-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);
}
}
.nested,
.subSignatures > .signaturePropertiesList {
padding: 4px 0 2px;
margin-inline-start: 0;
gap: 4px;
}
.nested {
margin-top: 4px;
}
/* Cosmetics for cards nested inside another (sub-)signature. */
.subSignatures .sigCard,
.nested .sigCard {
padding: 6px 8px;
background: var(--sig-card-nested-bg);
gap: 2px;
}
.subSignatures .signer,
.nested .signer {
font-size: 12px;
font-weight: 500;
}
}

View File

@ -0,0 +1,725 @@
/* 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, makeArr, PDFDateString } from "pdfjs-lib";
// Per-status descriptor keyed by the status code returned by the verifier.
// `priority` drives worst-status aggregation, `severity` drives the banner
// colour bucket, and the two Fluent IDs are the explicit strings used by
// the banner / status rows (no `${status}` template construction so the
// IDs are greppable).
// The status row only ever shows one of three distinct strings: the
// signature crypto verified, the signature is invalid, or the format is
// unsupported. Cert chain issues (untrusted / expired / revoked) live
// on the cert row, so those statuses still report "Signature verified"
// on the status row.
const STATUS_ROW_VERIFIED =
"pdfjs-digital-signature-properties-status-verified";
const STATUS_ROW_INVALID = "pdfjs-digital-signature-properties-status-invalid";
const STATUS_ROW_UNKNOWN = "pdfjs-digital-signature-properties-status-unknown";
const STATUS_INFO = {
verified: {
priority: 0,
severity: "verified",
bannerId: "pdfjs-digital-signature-properties-banner-verified",
statusId: STATUS_ROW_VERIFIED,
},
unknown: {
priority: 1,
severity: "error",
bannerId: "pdfjs-digital-signature-properties-banner-unknown",
statusId: STATUS_ROW_UNKNOWN,
},
untrusted: {
priority: 2,
severity: "warn",
bannerId: "pdfjs-digital-signature-properties-banner-untrusted",
statusId: STATUS_ROW_VERIFIED,
},
expired: {
priority: 3,
severity: "warn",
bannerId: "pdfjs-digital-signature-properties-banner-expired",
statusId: STATUS_ROW_VERIFIED,
},
revoked: {
priority: 4,
severity: "error",
bannerId: "pdfjs-digital-signature-properties-banner-revoked",
statusId: STATUS_ROW_VERIFIED,
},
invalid: {
priority: 5,
severity: "error",
bannerId: "pdfjs-digital-signature-properties-banner-invalid",
statusId: STATUS_ROW_INVALID,
},
};
const CERT_L10N_IDS = {
trusted: "pdfjs-digital-signature-properties-certificate-trusted",
unknown: "pdfjs-digital-signature-properties-certificate-unknown",
untrusted: "pdfjs-digital-signature-properties-certificate-untrusted",
expired: "pdfjs-digital-signature-properties-certificate-expired",
revoked: "pdfjs-digital-signature-properties-certificate-revoked",
};
const CERT_EXPIRED_WITH_DATE_L10N_ID =
"pdfjs-digital-signature-properties-certificate-expired-with-date";
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_INFO[r.status].priority > STATUS_INFO[worst].priority
) {
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++;
}
}
return { worst, severity: STATUS_INFO[worst].severity, count };
}
// For an `untrusted` certificate, pick the most specific Fluent label.
// When the error code matches one of the recognised cases we have a
// structured "Certificate: <reason> (<issuer>)" string; otherwise we
// fall back to the bare "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-digital-signature-properties-certificate-untrusted-unknown-issuer",
args,
};
}
if (code.includes("SELF_SIGNED") && args) {
return {
id: "pdfjs-digital-signature-properties-certificate-untrusted-self-signed",
args,
};
}
if (code.includes("UNTRUSTED_ISSUER") && args) {
return {
id: "pdfjs-digital-signature-properties-certificate-untrusted-untrusted-issuer",
args,
};
}
return {
id: "pdfjs-digital-signature-properties-certificate-untrusted",
args: 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 leaf + chain and
// return the first notAfter that is already in the past as a Date.
// If nothing is in the past we return null and the caller renders the
// generic "Certificate: Expired" label without 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];
// Return the first past notAfter in the chain — that's the cert that
// actually caused the "expired" verdict. We deliberately do NOT fall
// back to a future date if no past date is found: NSS sometimes does
// not surface the expired issuer in `signerCertificate.issuer`, in
// which case the chain only has the leaf with a still-valid notAfter.
// Showing that as "Expired (Jan 1, 2027)" would be misleading, so the
// caller renders the dateless label instead.
for (const entry of entries) {
if (typeof entry?.notAfter !== "string" || !entry.notAfter) {
continue;
}
const date = new Date(entry.notAfter);
const ts = date.getTime();
if (Number.isFinite(ts) && ts < now) {
return 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;
// Set whenever state changes while the panel is closed, so that opening it
// forces a fresh render. While the panel is hidden, building the list /
// banner DOM is pure churn — only the toolbar button is visible, and that
// is updated via #updateButtonState().
#needsRender = false;
// Identifies the current `loadFromDocument` call. Each load rotates the
// token; in-flight `#verify()` promises capture the value at start and
// bail on resolve if the manager has since switched to another document.
// Prevents a stale verification for doc A from writing into doc B's
// results map after a fast doc-switch.
#loadToken = null;
// Held only to ferry per-signature byte payloads from the worker into
// `#verify`. Cleared in `reset()` so we don't pin a closed document.
#pdfDocument = null;
constructor({ appConfig, verifier, eventBus }) {
this.#appConfig = appConfig;
this.#verifier = verifier;
this.#eventBus = eventBus;
const button = appConfig.signaturePropertiesButton;
// Loading dots: three real spans (hidden by `.toolbarButton > span`)
// that the `state-loading` CSS modifier turns into pulsing circles
// with staggered `animation-delay`. Real elements (not gradient
// keyframes) let each dot animate independently.
for (let i = 0; i < 3; i++) {
const dot = document.createElement("span");
dot.className = "loadingDot";
button.append(dot);
}
button.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) {
const token = Symbol("sig-load");
this.#loadToken = token;
this.#pdfDocument = pdfDocument;
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.#loadToken !== token) {
// A newer document load (or reset) raced ahead — drop this result.
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, {
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, token);
}
}
reset() {
// Rotate the token so any in-flight verify from the previous document
// sees a mismatch and bails before mutating state.
this.#loadToken = null;
this.#pdfDocument = null;
this.#signatures = [];
this.#results.clear();
this.#pendingVerify.clear();
this.#needsRender = false;
this.#hideButton();
this.#close();
this.#updateButtonState();
}
// --- internal ---
#showButton() {
const root = this.#appConfig.signaturePropertiesButton.parentElement;
if (root) {
root.hidden = false;
}
// The separator only makes sense when our toolbar group is visible.
const sep = this.#appConfig.signaturePropertiesSeparator;
if (sep) {
sep.hidden = false;
}
}
#hideButton() {
const root = this.#appConfig.signaturePropertiesButton.parentElement;
if (root) {
root.hidden = true;
}
const sep = this.#appConfig.signaturePropertiesSeparator;
if (sep) {
sep.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"
);
if (this.#needsRender) {
this.#render();
}
}
#close() {
this.#isOpen = false;
this.#appConfig.signaturePropertiesPanel.classList.add("hidden");
this.#appConfig.signaturePropertiesButton.setAttribute(
"aria-expanded",
"false"
);
}
#render() {
if (!this.#isOpen) {
// Defer DOM work until the user actually opens the panel.
this.#needsRender = true;
return;
}
this.#needsRender = false;
const list = this.#appConfig.signaturePropertiesList;
const banner = this.#appConfig.signaturePropertiesBanner;
const fragment = document.createDocumentFragment();
if (this.#isLoading) {
// The worker call that populates `#signatures` resolves in tens of
// milliseconds — fast enough that we'd rather show nothing than
// flash a placeholder. The toolbar button's loading-dot animation
// already conveys "work in progress".
banner.hidden = true;
list.replaceChildren();
return;
}
// Banner.
const { worst, severity, count } = bannerStateForResults([
...this.#results.values(),
]);
banner.replaceChildren();
banner.hidden = false;
banner.className = `sigBanner ${severity}`;
banner.setAttribute("data-l10n-id", STATUS_INFO[worst].bannerId);
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) {
byParent.getOrInsertComputed(sig.parentId, makeArr).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) {
fragment.append(
this.#renderCard(sig, byParent, /* depth = */ 0, everythingFine)
);
}
list.replaceChildren(fragment);
}
#renderCard(sig, byParent, depth, everythingFine) {
const subs = byParent.get(sig.id) || [];
const li = document.createElement("li");
li.classList.add("sigCard");
if (depth === 0 && everythingFine) {
li.classList.add("sigCard--top-allfine");
}
const result = this.#results.get(sig.id);
const subjectCN = result?.certificate?.subjectCN;
if (subjectCN) {
const signer = document.createElement("div");
signer.className = "signer";
signer.textContent = subjectCN;
li.append(signer);
}
// Status row.
const statusRow = document.createElement("div");
statusRow.classList.add("row", `status--${result.status}`);
const statusLabel = document.createElement("span");
statusLabel.setAttribute(
"data-l10n-id",
STATUS_INFO[result.status].statusId
);
statusRow.append(statusLabel);
li.append(statusRow);
if (result.status === "invalid" && result.message) {
const reason = document.createElement("div");
reason.className = "detail";
reason.setAttribute(
"data-l10n-id",
"pdfjs-digital-signature-properties-reason"
);
reason.setAttribute(
"data-l10n-args",
JSON.stringify({ reason: result.message })
);
li.append(reason);
}
// Certificate row — skipped when the signature itself is invalid:
// NSS doesn't return a signerCertificate in that case, so the row
// would be empty noise next to the more informative "Reason: …"
// line.
const cert = result.certificate;
let certKind = "unknown";
if (cert) {
switch (result.status) {
case "verified":
certKind = "trusted";
break;
case "expired":
certKind = "expired";
break;
case "revoked":
certKind = "revoked";
break;
case "untrusted":
certKind = "untrusted";
break;
default:
certKind = "unknown";
}
}
if (result.status !== "invalid") {
const certRow = document.createElement("div");
certRow.classList.add("row", `cert--${certKind}`);
const certLabel = document.createElement("span");
let l10nId = CERT_L10N_IDS[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 = CERT_EXPIRED_WITH_DATE_L10N_ID;
l10nArgs = { dateObj: date.valueOf() };
}
} else if (certKind === "untrusted") {
const label = untrustedCertLabel(result.errorCode, cert?.issuerCN);
l10nId = label.id;
l10nArgs = label.args;
}
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 = "detail";
detail.textContent = result.message;
li.append(detail);
}
if (result.status === "expired" && result.message) {
const detail = document.createElement("div");
detail.className = "detail";
detail.textContent = result.message;
li.append(detail);
}
const signingDate = PDFDateString.toDateObject(sig.signingTime);
if (signingDate) {
const ts = document.createElement("div");
ts.className = "detail";
ts.setAttribute(
"data-l10n-id",
"pdfjs-digital-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 = "viewCert";
viewCert.type = "button";
viewCert.setAttribute(
"data-l10n-id",
"pdfjs-digital-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.classList.add("signaturePropertiesList", "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 = "subSignatures";
details.open = true;
const summary = document.createElement("summary");
summary.setAttribute(
"data-l10n-id",
"pdfjs-digital-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);
}
}
// Note: per-card progress is already conveyed via the toolbar
// button's loading-dot animation (state-loading class) — we used to
// drop an additional pulsing bar at the bottom of each in-flight
// card but it was visual noise without clear meaning.
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, loadToken) {
if (!this.#verifier || this.#pendingVerify.has(signature.id)) {
return;
}
this.#pendingVerify.add(signature.id);
this.#render();
let result;
try {
// Fetch the signature's byte payload (pkcs7 + signed-data spans)
// lazily, one signature at a time, so the bytes never sit in main
// thread memory for the document's lifetime. `bytes` goes out of
// scope as soon as the verifier returns.
const bytes = await this.#pdfDocument?.getSignatureData(signature.id);
if (this.#loadToken !== loadToken) {
return;
}
if (!bytes) {
throw new Error("missing signature data");
}
result = await this.#verifier.verify({ ...signature, ...bytes });
} catch (ex) {
console.warn("signature verify failed:", ex);
result = {
status: "unknown",
errorCode: "BRIDGE_ERROR",
message: ex?.message ?? null,
certificate: null,
documentModifiedAfterSigning: !signature.coversWholeDocument,
};
}
this.#pendingVerify.delete(signature.id);
if (this.#loadToken !== loadToken) {
// The user switched documents while this verify was in flight; the
// result belongs to a defunct load and would corrupt the new doc.
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_INFO[r.status].priority > STATUS_INFO[worst].priority) {
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 };

View File

@ -48,6 +48,28 @@ class BaseExternalServices {
throw new Error("Not implemented: createSignatureStorage");
}
/**
* Build a signature verifier for the Digital 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");
}

View File

@ -532,6 +532,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",
]),
// 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);
@ -623,6 +757,10 @@ class ExternalServices extends BaseExternalServices {
return new SignatureStorage(eventBus, signal);
}
createSignatureVerifier() {
return new SignatureVerifier();
}
dispatchGlobalEvent(event) {
FirefoxCom.request("dispatchGlobalEvent", event);
}

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7" stroke="black" stroke-width="1" fill="none"/>
<path d="M5 8.4l2.4 2.4L11.6 6" stroke="black" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<mask id="m">
<rect width="16" height="16" fill="white"/>
<path d="M5.2 5.2l5.6 5.6M10.8 5.2l-5.6 5.6" stroke="black" stroke-width="1.8" fill="none" stroke-linecap="round"/>
</mask>
<circle cx="8" cy="8" r="7" fill="black" mask="url(#m)"/>
</svg>

After

Width:  |  Height:  |  Size: 345 B

View File

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<mask id="m">
<rect width="16" height="16" fill="white"/>
<path d="M4.5 8.4l2.4 2.4L11.6 6" stroke="black" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</mask>
<circle cx="8" cy="8" r="7" fill="black" mask="url(#m)"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<mask id="m">
<rect width="16" height="16" fill="white"/>
<path d="M5.2 5.2l5.6 5.6M10.8 5.2l-5.6 5.6" stroke="black" stroke-width="1.8" fill="none" stroke-linecap="round"/>
</mask>
<circle cx="8" cy="8" r="7" fill="black" mask="url(#m)"/>
</svg>

After

Width:  |  Height:  |  Size: 345 B

View File

@ -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,
};

View File

@ -14,6 +14,7 @@
*/
@import url(pdf_viewer.css);
@import url(digital_signature_properties.css);
:root {
--dir-factor: 1;
@ -83,6 +84,10 @@
--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);
--signatureProperties-rowCheck-icon: url(images/signature-properties-row-check.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);

View File

@ -107,6 +107,7 @@ See https://github.com/adobe-type-tools/cmap-resources
"web-print_service": "./pdf_print_service.js",
"web-secondary_toolbar": "./secondary_toolbar.js",
"web-signature_manager": "./signature_manager.js",
"web-digital_signature_properties_manager": "./digital_signature_properties_manager.js",
"web-toolbar": "./toolbar.js",
"web-views_manager": "./views_manager.js"
}
@ -423,6 +424,27 @@ See https://github.com/adobe-type-tools/cmap-resources
</div>
<div id="toolbarViewerRight" class="toolbarHorizontalGroup">
<div id="editorModeButtons" class="toolbarHorizontalGroup">
<div id="signatureProperties" class="toolbarButtonWithContainer" hidden="true">
<button
id="signaturePropertiesButton"
class="toolbarButton"
type="button"
tabindex="0"
aria-expanded="false"
aria-haspopup="true"
aria-controls="signaturePropertiesPanel"
data-l10n-id="pdfjs-digital-signature-properties-button"
>
<span data-l10n-id="pdfjs-digital-signature-properties-button-label"></span>
</button>
<div class="editorParamsToolbar hidden doorHangerRight" id="signaturePropertiesPanel" role="region">
<div id="signaturePropertiesContainer" class="signaturePropertiesContainer">
<div id="signaturePropertiesBanner" class="sigBanner" hidden="true" role="status"></div>
<ul id="signaturePropertiesList" class="signaturePropertiesList" tabindex="-1"></ul>
</div>
</div>
</div>
<div id="signaturePropertiesSeparator" class="verticalToolbarSeparator" hidden="true"></div>
<div id="editorComment" class="toolbarButtonWithContainer" hidden="true">
<button
id="editorCommentButton"

View File

@ -71,6 +71,24 @@ function getViewerConfiguration() {
editorSignatureParamsToolbar: document.getElementById(
"editorSignatureParamsToolbar"
),
signaturePropertiesButton: document.getElementById(
"signaturePropertiesButton"
),
signaturePropertiesPanel: document.getElementById(
"signaturePropertiesPanel"
),
signaturePropertiesContainer: document.getElementById(
"signaturePropertiesContainer"
),
signaturePropertiesBanner: document.getElementById(
"signaturePropertiesBanner"
),
signaturePropertiesList: document.getElementById(
"signaturePropertiesList"
),
signaturePropertiesSeparator: document.getElementById(
"signaturePropertiesSeparator"
),
download: document.getElementById("downloadButton"),
},
secondaryToolbar: {