diff --git a/gulpfile.mjs b/gulpfile.mjs index 6bc953fbaf80b..8535ce3e0ee79 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -225,6 +225,7 @@ function createWebpackAlias(defines) { "web-print_service": "", "web-secondary_toolbar": "web/secondary_toolbar.js", "web-signature_manager": "web/signature_manager.js", + "web-signature_properties_manager": "web/signature_properties_manager.js", "web-toolbar": "web/toolbar.js", "web-views_manager": "web/views_manager.js", }; diff --git a/l10n/en-US/viewer.ftl b/l10n/en-US/viewer.ftl index 3186b9a4d9edb..f13c570d0fe0a 100644 --- a/l10n/en-US/viewer.ftl +++ b/l10n/en-US/viewer.ftl @@ -785,3 +785,86 @@ pdfjs-views-manager-paste-button-after = pdfjs-new-badge-content = NEW pdfjs-views-manager-waiting-for-file = Uploading file… + +## Signature Properties (digital signature verification) + +pdfjs-signature-properties-button = + .title = Digital Signature properties + .aria-label = Digital Signature properties +pdfjs-signature-properties-button-label = Digital Signature properties + +## Banner shown above the signature list summarising the overall +## verification state of the document. Each variant is selected by the +## viewer based on the worst per-signature status; one signature is +## enough to lower the banner. +## +## Variables: +## $count (Number) - number of signatures at the worst level. + +pdfjs-signature-properties-banner-verified = Document has a valid digital signature +pdfjs-signature-properties-banner-unknown = + { $count -> + [one] Document signed but { $count } digital signature could not be verified + *[other] Document signed but { $count } digital signatures could not be verified + } +pdfjs-signature-properties-banner-untrusted = + { $count -> + [one] Document signed with { $count } certificate that is not trusted + *[other] Document signed with { $count } certificates that are not trusted + } +pdfjs-signature-properties-banner-expired = + { $count -> + [one] Document signed with { $count } expired certificate + *[other] Document signed with { $count } expired certificates + } +pdfjs-signature-properties-banner-invalid = + { $count -> + [one] Document has { $count } invalid digital signature + *[other] Document has { $count } invalid digital signatures + } +pdfjs-signature-properties-banner-revoked = + { $count -> + [one] Document signed with { $count } revoked certificate + *[other] Document signed with { $count } revoked certificates + } + +## Per-signature status row. + +pdfjs-signature-properties-status-verified = Status: Signature verified +pdfjs-signature-properties-status-unknown = Status: Unable to verify (unsupported) +pdfjs-signature-properties-status-untrusted = Status: Signature verified +pdfjs-signature-properties-status-expired = Status: Signature verified +pdfjs-signature-properties-status-invalid = Status: Signature invalid +pdfjs-signature-properties-status-revoked = Status: Signature verified + +## Per-signature certificate row. The variants with an issuer / date in +## parentheses embed fully-localized context — no English fall-through. +## +## Variables: +## $issuer (String) - issuer or subject common name from the cert. +## $dateObj (Date) - notAfter date for the expired-with-date form. + +pdfjs-signature-properties-certificate-trusted = Certificate: Trusted ({ $issuer }) +pdfjs-signature-properties-certificate-unknown = Certificate: Unavailable +pdfjs-signature-properties-certificate-untrusted = Certificate: Untrusted +pdfjs-signature-properties-certificate-untrusted-unknown-issuer = Certificate: Unknown issuer ({ $issuer }) +pdfjs-signature-properties-certificate-untrusted-self-signed = Certificate: Self-signed ({ $issuer }) +pdfjs-signature-properties-certificate-untrusted-untrusted-issuer = Certificate: Untrusted issuer ({ $issuer }) +pdfjs-signature-properties-certificate-expired = Certificate: Expired +pdfjs-signature-properties-certificate-expired-with-date = Certificate: Expired ({ DATETIME($dateObj, dateStyle: "medium") }) +pdfjs-signature-properties-certificate-revoked = Certificate: Revoked + +## + +pdfjs-signature-properties-view-certificate = View certificate + +# Variables: +# $reason (String) - the reason text from the signature dictionary. +pdfjs-signature-properties-reason = Reason: { $reason } +# Variables: +# $dateObj (Date) - the signing time from the /Sig dict's /M entry. +pdfjs-signature-properties-timestamp = Timestamp: { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") } +# Variables: +# $count (Number) - number of nested sub-signatures (one per earlier +# incremental revision of the document). +pdfjs-signature-properties-sub-signatures = Sub-signatures ({ $count }) diff --git a/src/core/document.js b/src/core/document.js index e6d61acfb544f..5dade4a22b848 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -1990,6 +1990,163 @@ class PDFDocument { return shadow(this, "fieldObjects", promise); } + #collectSignatureFields(fields, out, visitedRefs) { + if (!Array.isArray(fields)) { + return; + } + for (const fieldRef of fields) { + if (fieldRef instanceof Ref) { + if (visitedRefs.has(fieldRef)) { + continue; + } + visitedRefs.put(fieldRef); + } + const field = this.xref.fetchIfRef(fieldRef); + if (!(field instanceof Dict)) { + continue; + } + if (field.has("Kids")) { + this.#collectSignatureFields(field.get("Kids"), out, visitedRefs); + continue; + } + if (!isName(field.get("FT"), "Sig")) { + continue; + } + const sigDict = this.xref.fetchIfRef(field.get("V")); + if (!(sigDict instanceof Dict)) { + continue; + } + const parsed = this.#parseSignatureDict(field, sigDict, fieldRef); + if (parsed) { + out.push(parsed); + } + } + } + + // The /ByteRange of a signature that covers the whole document usually + // ends a few bytes before the file's actual end — the trailer, + // `startxref` offset and `%%EOF` marker are conventionally left outside + // the signed range. 100 bytes covers the worst case (large + // `startxref` offsets, optional whitespace) without false positives. + static #WHOLE_DOCUMENT_TAIL_FUZZ = 100; + + #parseSignatureDict(field, sigDict, fieldRef) { + const byteRange = sigDict.get("ByteRange"); + if ( + !Array.isArray(byteRange) || + byteRange.length !== 4 || + byteRange.some(n => !Number.isInteger(n) || n < 0) + ) { + return null; + } + const contents = sigDict.get("Contents"); + if (typeof contents !== "string" || contents.length === 0) { + return null; + } + + const filterName = sigDict.get("Filter"); + const filter = filterName instanceof Name ? filterName.name : null; + const subFilterName = sigDict.get("SubFilter"); + const subFilter = subFilterName instanceof Name ? subFilterName.name : null; + + let signatureType = null; + if (subFilter === "adbe.pkcs7.detached") { + signatureType = 0; + } else if (subFilter === "adbe.pkcs7.sha1") { + signatureType = 1; + } + + // Slice the two ByteRange byte spans out of the underlying PDF stream. + // ByteRange = [a, b, c, d] means signed bytes are [a..a+b] and [c..c+d]; + // the gap covers the /Contents hex blob itself. + const [a, b, c, d] = byteRange; + const stream = this.stream; + const data = [stream.getByteRange(a, a + b), stream.getByteRange(c, c + d)]; + + const pkcs7 = stringToBytes(contents); + + const t = field.get("T"); + const fieldName = typeof t === "string" ? stringToPDFString(t) : ""; + const name = sigDict.get("Name"); + const reason = sigDict.get("Reason"); + const location = sigDict.get("Location"); + const contactInfo = sigDict.get("ContactInfo"); + const m = sigDict.get("M"); + + const refKey = fieldRef instanceof Ref ? fieldRef.toString() : "inline"; + const id = `${refKey}:${a}-${b}-${c}-${d}`; + + const fileLength = stream.end || 0; + const lastSignedByte = c + d; + + return { + id, + fieldName, + signerName: typeof name === "string" ? stringToPDFString(name) : null, + reason: typeof reason === "string" ? stringToPDFString(reason) : null, + location: + typeof location === "string" ? stringToPDFString(location) : null, + contactInfo: + typeof contactInfo === "string" ? stringToPDFString(contactInfo) : null, + signingTime: typeof m === "string" ? m : null, + filter, + subFilter, + signatureType, + byteRange, + pkcs7, + data, + revisionIndex: 0, + parentId: null, + coversWholeDocument: + fileLength > 0 && + lastSignedByte >= fileLength - PDFDocument.#WHOLE_DOCUMENT_TAIL_FUZZ, + }; + } + + get signatures() { + const promise = this.pdfManager + .ensureDoc("formInfo") + .then(async formInfo => { + if (!formInfo.hasSignatures || !formInfo.hasFields) { + return null; + } + const annotationGlobals = await this.annotationGlobals; + if (!annotationGlobals) { + return null; + } + const fields = annotationGlobals.acroForm.get("Fields"); + + const collected = []; + this.#collectSignatureFields(fields, collected, new RefSet()); + + // Group sub-signatures by ByteRange containment: outer revision is + // the largest covering signature (largest c + d). Sort descending, + // then point each later signature at the smallest enclosing parent + // that came before it. + collected.sort( + (a, b) => + b.byteRange[2] + b.byteRange[3] - (a.byteRange[2] + a.byteRange[3]) + ); + for (let i = 0, ii = collected.length; i < ii; i++) { + const sig = collected[i]; + sig.revisionIndex = i; + for (let j = i - 1; j >= 0; j--) { + const candidate = collected[j]; + if ( + candidate.byteRange[2] + candidate.byteRange[3] > + sig.byteRange[2] + sig.byteRange[3] + ) { + sig.parentId = candidate.id; + break; + } + } + } + return collected.length ? collected : null; + }); + + return shadow(this, "signatures", promise); + } + get hasJSActions() { const promise = this.pdfManager.ensureDoc("_parseHasJSActions"); return shadow(this, "hasJSActions", promise); diff --git a/src/core/worker.js b/src/core/worker.js index 9dd67e7839abf..c193edfe122e1 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -549,6 +549,10 @@ class WorkerMessageHandler { .then(fieldObjects => fieldObjects?.allFields || null); }); + handler.on("GetSignatures", function (data) { + return pdfManager.ensureDoc("signatures"); + }); + handler.on("HasJSActions", function (data) { return pdfManager.ensureDoc("hasJSActions"); }); diff --git a/src/display/api.js b/src/display/api.js index 1fa44ddeb7e69..8af18fa9cf1a2 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1074,6 +1074,17 @@ class PDFDocumentProxy { return this._transport.getFieldObjects(); } + /** + * @returns {Promise | null>} A promise that is resolved + * with an {Array} of digital signatures present in the document + * (each with metadata such as signerName, reason, signingTime, + * ... plus the PKCS#7 blob and signed-data byte spans needed for + * verification), or `null` when the document has no signatures. + */ + getSignatures() { + return this._transport.getSignatures(); + } + /** * @returns {Promise} A promise that is resolved with `true` * if some /AcroForm fields have JavaScript actions. @@ -3017,6 +3028,10 @@ class WorkerTransport { return this.#cacheSimpleMethod("GetFieldObjects"); } + getSignatures() { + return this.#cacheSimpleMethod("GetSignatures"); + } + hasJSActions() { return this.#cacheSimpleMethod("HasJSActions"); } diff --git a/test/pdfs/sig_corpus/.gitignore b/test/pdfs/sig_corpus/.gitignore new file mode 100644 index 0000000000000..eb045a26b51e3 --- /dev/null +++ b/test/pdfs/sig_corpus/.gitignore @@ -0,0 +1,3 @@ +*.pdf +*.p7s +*.pkcs7spec diff --git a/test/pdfs/sig_corpus/README.md b/test/pdfs/sig_corpus/README.md new file mode 100644 index 0000000000000..f6efb08a7e55d --- /dev/null +++ b/test/pdfs/sig_corpus/README.md @@ -0,0 +1,122 @@ +# 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 +**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 ` 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 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. diff --git a/test/pdfs/sig_corpus/generate.py b/test/pdfs/sig_corpus/generate.py new file mode 100644 index 0000000000000..ef2eaedf4b62c --- /dev/null +++ b/test/pdfs/sig_corpus/generate.py @@ -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 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 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 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 = """\ +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 — 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 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() diff --git a/test/pdfs/sig_corpus/user.js.example b/test/pdfs/sig_corpus/user.js.example new file mode 100644 index 0000000000000..3f3b5dabdf6d0 --- /dev/null +++ b/test/pdfs/sig_corpus/user.js.example @@ -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); diff --git a/test/unit/document_spec.js b/test/unit/document_spec.js index b0224e8821d59..7cfc4319783f5 100644 --- a/test/unit/document_spec.js +++ b/test/unit/document_spec.js @@ -186,6 +186,212 @@ describe("document", function () { }); }); + describe("getSignatures", function () { + function makeSigDict({ + byteRange, + contents = "00".repeat(8), + subFilter = "adbe.pkcs7.detached", + name = null, + reason = null, + location = null, + m = null, + }) { + const dict = new Dict(); + dict.set("Type", Name.get("Sig")); + dict.set("Filter", Name.get("Adobe.PPKLite")); + dict.set("SubFilter", Name.get(subFilter)); + dict.set("ByteRange", byteRange); + dict.set("Contents", contents); + if (name !== null) { + dict.set("Name", name); + } + if (reason !== null) { + dict.set("Reason", reason); + } + if (location !== null) { + dict.set("Location", location); + } + if (m !== null) { + dict.set("M", m); + } + return dict; + } + + function makeSigField({ T, sigRef }) { + const dict = new Dict(); + dict.set("FT", Name.get("Sig")); + dict.set("T", T); + dict.set("V", sigRef); + return dict; + } + + it("returns an empty array when no signatures are present", async function () { + const acroForm = new Dict(); + const pdfDocument = getDocument(acroForm); + const signatures = await pdfDocument.signatures; + expect(signatures).toEqual([]); + }); + + it("extracts metadata for a single signature", async function () { + const acroForm = new Dict(); + acroForm.set("SigFlags", 3); + + const sigRef = Ref.get(20, 0); + const fieldRef = Ref.get(21, 0); + const sigDict = makeSigDict({ + byteRange: [0, 100, 200, 300], + name: "Alice Becker", + reason: "Approved for release", + m: "D:20251014103200+00'00'", + }); + const fieldDict = makeSigField({ T: "sig_alice", sigRef }); + + const xref = new XRefMock([ + { ref: sigRef, data: sigDict }, + { ref: fieldRef, data: fieldDict }, + ]); + acroForm.set("Fields", [fieldRef]); + + const pdfDocument = getDocument(acroForm, xref); + const signatures = await pdfDocument.signatures; + expect(signatures.length).toEqual(1); + const [sig] = signatures; + expect(sig.signerName).toEqual("Alice Becker"); + expect(sig.reason).toEqual("Approved for release"); + expect(sig.signingTime).toEqual("D:20251014103200+00'00'"); + expect(sig.fieldName).toEqual("sig_alice"); + expect(sig.subFilter).toEqual("adbe.pkcs7.detached"); + expect(sig.signatureType).toEqual(0); + expect(sig.byteRange).toEqual([0, 100, 200, 300]); + expect(sig.parentId).toEqual(null); + expect(sig.revisionIndex).toEqual(0); + expect(sig.pkcs7).toBeInstanceOf(Uint8Array); + }); + + it("walks Kids recursively to find nested signature fields", async function () { + const acroForm = new Dict(); + acroForm.set("SigFlags", 3); + + const sigRef = Ref.get(30, 0); + const sigFieldRef = Ref.get(31, 0); + const containerRef = Ref.get(32, 0); + + const sigDict = makeSigDict({ + byteRange: [0, 50, 100, 150], + name: "John Smith", + }); + const sigField = makeSigField({ T: "sig_john", sigRef }); + const container = new Dict(); + container.set("Kids", [sigFieldRef]); + + const xref = new XRefMock([ + { ref: sigRef, data: sigDict }, + { ref: sigFieldRef, data: sigField }, + { ref: containerRef, data: container }, + ]); + acroForm.set("Fields", [containerRef]); + + const pdfDocument = getDocument(acroForm, xref); + const signatures = await pdfDocument.signatures; + expect(signatures.length).toEqual(1); + expect(signatures[0].signerName).toEqual("John Smith"); + }); + + it("skips signatures with malformed ByteRange", async function () { + const acroForm = new Dict(); + acroForm.set("SigFlags", 3); + + const sigRef = Ref.get(40, 0); + const fieldRef = Ref.get(41, 0); + const sigDict = makeSigDict({ byteRange: [0, 100] }); // wrong length + const fieldDict = makeSigField({ T: "bad", sigRef }); + + const xref = new XRefMock([ + { ref: sigRef, data: sigDict }, + { ref: fieldRef, data: fieldDict }, + ]); + acroForm.set("Fields", [fieldRef]); + + const pdfDocument = getDocument(acroForm, xref); + expect(await pdfDocument.signatures).toEqual([]); + }); + + it("groups sub-signatures under the outer revision", async function () { + const acroForm = new Dict(); + acroForm.set("SigFlags", 3); + + // Outer covers more bytes (c+d larger) → parent. + // Inner covers fewer bytes → sub-signature of outer. + const outerSigRef = Ref.get(50, 0); + const outerFieldRef = Ref.get(51, 0); + const innerSigRef = Ref.get(52, 0); + const innerFieldRef = Ref.get(53, 0); + + const outerSig = makeSigDict({ + byteRange: [0, 100, 200, 800], + name: "Outer", + }); + const innerSig = makeSigDict({ + byteRange: [0, 50, 100, 200], + name: "Inner", + }); + + const xref = new XRefMock([ + { ref: outerSigRef, data: outerSig }, + { + ref: outerFieldRef, + data: makeSigField({ T: "outer", sigRef: outerSigRef }), + }, + { ref: innerSigRef, data: innerSig }, + { + ref: innerFieldRef, + data: makeSigField({ T: "inner", sigRef: innerSigRef }), + }, + ]); + acroForm.set("Fields", [outerFieldRef, innerFieldRef]); + + const pdfDocument = getDocument(acroForm, xref); + const signatures = await pdfDocument.signatures; + expect(signatures.length).toEqual(2); + // Sorted descending by c+d, so outer comes first. + expect(signatures[0].signerName).toEqual("Outer"); + expect(signatures[0].parentId).toEqual(null); + expect(signatures[0].revisionIndex).toEqual(0); + expect(signatures[1].signerName).toEqual("Inner"); + expect(signatures[1].parentId).toEqual(signatures[0].id); + expect(signatures[1].revisionIndex).toEqual(1); + }); + + it("maps SubFilter to the PDFSignatureAlgorithm enum", async function () { + const acroForm = new Dict(); + acroForm.set("SigFlags", 3); + + async function signatureType(subFilter) { + const sigRef = Ref.get(60, 0); + const fieldRef = Ref.get(61, 0); + const sigDict = makeSigDict({ + byteRange: [0, 10, 20, 30], + subFilter, + }); + const xref = new XRefMock([ + { ref: sigRef, data: sigDict }, + { + ref: fieldRef, + data: makeSigField({ T: "sig", sigRef }), + }, + ]); + acroForm.set("Fields", [fieldRef]); + const pdfDocument = getDocument(acroForm, xref); + const [sig] = await pdfDocument.signatures; + return sig.signatureType; + } + + expect(await signatureType("adbe.pkcs7.detached")).toEqual(0); + expect(await signatureType("adbe.pkcs7.sha1")).toEqual(1); + expect(await signatureType("ETSI.CAdES.detached")).toEqual(null); + }); + }); + it("should get calculation order array or null", function () { const acroForm = new Dict(); diff --git a/web/app.js b/web/app.js index e630ac5d52042..95dae5f07e711 100644 --- a/web/app.js +++ b/web/app.js @@ -93,6 +93,7 @@ import { Preferences } from "web-preferences"; import { RenderingStates } from "./renderable_view.js"; import { SecondaryToolbar } from "web-secondary_toolbar"; import { SignatureManager } from "web-signature_manager"; +import { SignaturePropertiesManager } from "web-signature_properties_manager"; import { Toolbar } from "web-toolbar"; import { ViewHistory } from "./view_history.js"; import { ViewsManager } from "web-views_manager"; @@ -163,6 +164,8 @@ const PDFViewerApplication = { l10n: null, /** @type {AnnotationEditorParams} */ annotationEditorParams: null, + /** @type {SignaturePropertiesManager|null} */ + signaturePropertiesManager: null, /** @type {ImageAltTextSettings} */ imageAltTextSettings: null, isInitialViewSet: false, @@ -1171,6 +1174,7 @@ const PDFViewerApplication = { this.pdfViewer.setDocument(null); this.pdfLinkService.setDocument(null); this.pdfDocumentProperties?.setDocument(null); + this.signaturePropertiesManager?.reset(); } this.pdfLinkService.externalLinkEnabled = true; this.store = null; @@ -1808,12 +1812,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 */ @@ -2935,6 +2969,12 @@ function closeEditorUndoBar(evt) { } } +function closeSignatureProperties({ target }) { + if (this.signaturePropertiesManager?.shouldCloseOnClick(target)) { + this.signaturePropertiesManager.close(); + } +} + function onBeforeUnload(evt) { if (this._hasChanges()) { evt.preventDefault(); @@ -2947,6 +2987,7 @@ function onBeforeUnload(evt) { function onClick(evt) { closeSecondaryToolbar.call(this, evt); closeEditorUndoBar.call(this, evt); + closeSignatureProperties.call(this, evt); } function onKeyUp(evt) { @@ -3162,6 +3203,10 @@ function onKeyDown(evt) { this.secondaryToolbar.close(); handled = true; } + if (this.signaturePropertiesManager?.isOpen) { + this.signaturePropertiesManager.close(); + handled = true; + } if (!this.supportsIntegratedFind && this.findBar?.opened) { this.findBar.close(); handled = true; diff --git a/web/app_options.js b/web/app_options.js index 06b9b3dc58543..1a3182820dc31 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -272,6 +272,15 @@ const defaultOptions = { value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + enableSignatureVerification: { + // On in MOZCENTRAL (NSS provides the verification path) and in + // local dev builds; off in shipping GENERIC builds, where no + // verifier is wired up by default. + /** @type {boolean} */ + value: + typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING || MOZCENTRAL"), + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, enableSplitMerge: { /** @type {boolean} */ value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), diff --git a/web/external_services.js b/web/external_services.js index 74642af48d850..3b3b260f56500 100644 --- a/web/external_services.js +++ b/web/external_services.js @@ -48,6 +48,28 @@ class BaseExternalServices { throw new Error("Not implemented: createSignatureStorage"); } + /** + * Build a signature verifier for the Signature Properties panel. + * + * The MOZCENTRAL build returns a verifier that calls into NSS via + * the chrome bridge. The default GENERIC implementation returns + * `null` — there is no portable cryptographic verification path + * outside Firefox, so the toolbar button stays hidden and the + * worker is never asked for `getSignatures()`. + * + * Downstream consumers of `pdfjs-dist` that want the Signature + * Properties UI should subclass `BaseExternalServices` and return + * an object exposing `verify(signature)` (and optionally + * `viewCertificate(certificate)`) that resolves to a + * `VerificationResult` — see `web/firefoxcom.js` for the exact + * shape. + * + * @returns {Object|null} + */ + createSignatureVerifier() { + return null; + } + updateEditorStates(data) { throw new Error("Not implemented: updateEditorStates"); } diff --git a/web/firefoxcom.js b/web/firefoxcom.js index b12645574933d..14c9d6a398a85 100644 --- a/web/firefoxcom.js +++ b/web/firefoxcom.js @@ -529,6 +529,140 @@ class SignatureStorage { } } +// `nsresult` codes from PSM/NSS that we recognize. Names mirror +// Firefox's nsINSSErrorsService and security/nss codes; values aren't +// stable, so we compare strings instead of integers. +const NSS_ERR_CODES = { + EXPIRED: new Set([ + "SEC_ERROR_EXPIRED_CERTIFICATE", + "SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE", + "MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE", + "MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE", + ]), + // 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, 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); @@ -620,6 +754,10 @@ class ExternalServices extends BaseExternalServices { return new SignatureStorage(eventBus, signal); } + createSignatureVerifier() { + return new SignatureVerifier(); + } + dispatchGlobalEvent(event) { FirefoxCom.request("dispatchGlobalEvent", event); } diff --git a/web/images/signature-properties-row-check.svg b/web/images/signature-properties-row-check.svg new file mode 100644 index 0000000000000..f40d8301f5894 --- /dev/null +++ b/web/images/signature-properties-row-check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/images/toolbarButton-signaturePropertiesError.svg b/web/images/toolbarButton-signaturePropertiesError.svg new file mode 100644 index 0000000000000..43a8b413e2d87 --- /dev/null +++ b/web/images/toolbarButton-signaturePropertiesError.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/images/toolbarButton-signaturePropertiesVerified.svg b/web/images/toolbarButton-signaturePropertiesVerified.svg new file mode 100644 index 0000000000000..e294c6c90af4f --- /dev/null +++ b/web/images/toolbarButton-signaturePropertiesVerified.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/images/toolbarButton-signaturePropertiesWarn.svg b/web/images/toolbarButton-signaturePropertiesWarn.svg new file mode 100644 index 0000000000000..43a8b413e2d87 --- /dev/null +++ b/web/images/toolbarButton-signaturePropertiesWarn.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/signature_properties.css b/web/signature_properties.css new file mode 100644 index 0000000000000..d2b431f966d6e --- /dev/null +++ b/web/signature_properties.css @@ -0,0 +1,403 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* Signature Properties panel. + * + * Floating doorhanger anchored to #signaturePropertiesButton. Lists every + * signature in the open PDF as a card, with a banner summarising the + * overall verification state. + */ + +: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)); + --sig-skeleton-base: light-dark(rgb(238 238 238), rgb(72 72 76)); + --sig-skeleton-mid: light-dark(rgb(248 248 248), rgb(92 92 96)); + + /* 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) { + --sig-card-border: CanvasText; + --sig-card-bg: Canvas; + --sig-card-nested-bg: Canvas; + --sig-row-color: CanvasText; + --sig-detail-color: GrayText; + --sig-divider-color: GrayText; + --sig-summary-hover-color: Highlight; + --sig-link-color: LinkText; + --sig-link-hover-bg: transparent; + --sig-banner-verified-bg: Canvas; + --sig-banner-verified-color: CanvasText; + --sig-banner-warn-bg: Canvas; + --sig-banner-warn-color: CanvasText; + --sig-banner-error-bg: Canvas; + --sig-banner-error-color: CanvasText; + --sig-skeleton-base: Canvas; + --sig-skeleton-mid: Canvas; + /* HCM doesn't allow custom colours; collapse the four icon tints + * to CanvasText. The glyph shape (check / `!` / `✕`) still + * differentiates the icons. */ + --sig-icon-default: CanvasText; + --sig-icon-warn: CanvasText; + --sig-icon-error: CanvasText; + --sig-icon-verified: CanvasText; + } +} + +#signaturePropertiesButton { + /* Default (no state class yet): use the regular signature icon. */ + &::before { + mask-image: var(--toolbarButton-editorSignature-icon); + } + + /* When a verification state is set, switch 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; + border-inline-start: 3px solid currentcolor; + + &.verified { + background: var(--sig-banner-verified-bg); + color: var(--sig-banner-verified-color); + } + &.warn { + background: var(--sig-banner-warn-bg); + color: var(--sig-banner-warn-color); + } + &.error { + background: var(--sig-banner-error-bg); + color: var(--sig-banner-error-color); + } +} + +.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); + } + + /* "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; + } +} + +.sigCardSkeleton { + height: 14px; + background: linear-gradient( + 90deg, + var(--sig-skeleton-base) 0%, + var(--sig-skeleton-mid) 50%, + var(--sig-skeleton-base) 100% + ); + border-radius: 4px; + animation: sigSkeleton 1.2s infinite; +} + +@keyframes sigSkeleton { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} diff --git a/web/signature_properties_manager.js b/web/signature_properties_manager.js new file mode 100644 index 0000000000000..8e80df31fa667 --- /dev/null +++ b/web/signature_properties_manager.js @@ -0,0 +1,686 @@ +/* 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). +const STATUS_INFO = { + verified: { + priority: 0, + severity: "verified", + bannerId: "pdfjs-signature-properties-banner-verified", + statusId: "pdfjs-signature-properties-status-verified", + }, + unknown: { + priority: 1, + severity: "error", + bannerId: "pdfjs-signature-properties-banner-unknown", + statusId: "pdfjs-signature-properties-status-unknown", + }, + untrusted: { + priority: 2, + severity: "warn", + bannerId: "pdfjs-signature-properties-banner-untrusted", + statusId: "pdfjs-signature-properties-status-untrusted", + }, + expired: { + priority: 3, + severity: "warn", + bannerId: "pdfjs-signature-properties-banner-expired", + statusId: "pdfjs-signature-properties-status-expired", + }, + revoked: { + priority: 4, + severity: "error", + bannerId: "pdfjs-signature-properties-banner-revoked", + statusId: "pdfjs-signature-properties-status-revoked", + }, + invalid: { + priority: 5, + severity: "error", + bannerId: "pdfjs-signature-properties-banner-invalid", + statusId: "pdfjs-signature-properties-status-invalid", + }, +}; + +const CERT_L10N_IDS = { + trusted: "pdfjs-signature-properties-certificate-trusted", + unknown: "pdfjs-signature-properties-certificate-unknown", + untrusted: "pdfjs-signature-properties-certificate-untrusted", + expired: "pdfjs-signature-properties-certificate-expired", + revoked: "pdfjs-signature-properties-certificate-revoked", +}; + +const CERT_EXPIRED_WITH_DATE_L10N_ID = + "pdfjs-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: ()" 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-signature-properties-certificate-untrusted-unknown-issuer", + args, + }; + } + if (code.includes("SELF_SIGNED") && args) { + return { + id: "pdfjs-signature-properties-certificate-untrusted-self-signed", + args, + }; + } + if (code.includes("UNTRUSTED_ISSUER") && args) { + return { + id: "pdfjs-signature-properties-certificate-untrusted-untrusted-issuer", + args, + }; + } + return { + id: "pdfjs-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]; + // Walk the chain preferring a past notAfter — that's the cert that + // actually caused the "expired" verdict. NSS sometimes does not + // surface the expired issuer in `signerCertificate.issuer`, in which + // case the chain we receive only has the leaf (with a still-valid + // notAfter). Fall back to the first parseable date so the row at + // least shows *some* meaningful information instead of an empty + // parenthetical. + let fallback = null; + 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)) { + continue; + } + if (ts < now) { + return date; + } + fallback ??= date; + } + return fallback; +} + +class SignaturePropertiesManager { + #appConfig; + + #verifier; + + #eventBus; + + #signatures = []; + + #results = new Map(); // signatureId -> VerificationResult + + #pendingVerify = new Set(); // signatureId set, in-flight + + #isOpen = false; + + #isLoading = false; + + #docOpen = 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; + + 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) { + this.#docOpen = true; + this.#signatures = []; + this.#results.clear(); + this.#pendingVerify.clear(); + this.#isLoading = true; + this.#render(); + + let signatures; + try { + signatures = await pdfDocument.getSignatures(); + } catch (ex) { + console.warn("getSignatures failed:", ex); + signatures = []; + } + if (!this.#docOpen) { + // Document closed during fetch. + return; + } + this.#signatures = signatures || []; + this.#isLoading = false; + + if (this.#signatures.length === 0) { + this.#hideButton(); + return; + } + this.#showButton(); + + // Seed each signature with an "unknown" placeholder result so the + // banner / badge / cards have something to render while the worker + // verifies them in the background. + for (const sig of this.#signatures) { + this.#results.set(sig.id, { + status: "unknown", + errorCode: null, + message: null, + certificate: null, + documentModifiedAfterSigning: !sig.coversWholeDocument, + }); + } + this.#render(); + this.#updateButtonState(); + // Kick off verification automatically — the toolbar button reflects the + // aggregate state and updates as each signature resolves. + for (const sig of this.#signatures) { + this.#verify(sig); + } + } + + reset() { + this.#docOpen = false; + this.#signatures = []; + this.#results.clear(); + this.#pendingVerify.clear(); + this.#needsRender = false; + this.#hideButton(); + this.#close(); + this.#updateButtonState(); + } + + // --- internal --- + + #showButton() { + const root = this.#appConfig.signaturePropertiesButton.parentElement; + if (root) { + root.hidden = false; + } + } + + #hideButton() { + const root = this.#appConfig.signaturePropertiesButton.parentElement; + if (root) { + root.hidden = true; + } + } + + #toggle() { + if (this.#isOpen) { + this.#close(); + } else { + this.#open(); + } + } + + #open() { + this.#isOpen = true; + // Close any other open editor doorhanger (Ink, FreeText, Highlight, …) + // and the find bar / secondary toolbar via global onClick — same pattern + // the Comment doorhanger uses. + this.#eventBus?.dispatch("switchannotationeditormode", { + source: this, + mode: AnnotationEditorType.NONE, + }); + this.#eventBus?.dispatch("findbarclose", { source: this }); + this.#appConfig.signaturePropertiesPanel.classList.remove("hidden"); + this.#appConfig.signaturePropertiesButton.setAttribute( + "aria-expanded", + "true" + ); + 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) { + banner.hidden = true; + for (let i = 0; i < 2; i++) { + const li = document.createElement("li"); + li.className = "sigCard"; + for (let j = 0; j < 3; j++) { + const sk = document.createElement("div"); + sk.className = "sigCardSkeleton"; + li.append(sk); + } + fragment.append(li); + } + list.replaceChildren(fragment); + 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 inFlight = this.#pendingVerify.has(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-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-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-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-signature-properties-sub-signatures" + ); + summary.setAttribute( + "data-l10n-args", + JSON.stringify({ count: this.#countDescendants(sig.id, byParent) }) + ); + details.append(summary); + details.append(subList); + li.append(details); + } else { + li.append(subList); + } + } + + if (inFlight) { + const sk = document.createElement("div"); + sk.className = "sigCardSkeleton"; + li.append(sk); + } + + return li; + } + + #countDescendants(id, byParent) { + const direct = byParent.get(id); + if (!direct) { + return 0; + } + let total = direct.length; + for (const sub of direct) { + total += this.#countDescendants(sub.id, byParent); + } + return total; + } + + async #verify(signature) { + if (!this.#verifier || this.#pendingVerify.has(signature.id)) { + return; + } + this.#pendingVerify.add(signature.id); + this.#render(); + + let result; + try { + result = await this.#verifier.verify(signature); + } catch (ex) { + console.warn("signature verify failed:", ex); + result = { + status: "unknown", + errorCode: "BRIDGE_ERROR", + message: ex?.message ?? null, + certificate: null, + documentModifiedAfterSigning: !signature.coversWholeDocument, + }; + } + this.#pendingVerify.delete(signature.id); + if (!this.#docOpen) { + return; + } + this.#results.set(signature.id, result); + this.#render(); + this.#updateButtonState(); + } + + #updateButtonState() { + const button = this.#appConfig.signaturePropertiesButton; + button.classList.remove( + "state-loading", + "state-verified", + "state-warn", + "state-error" + ); + if (this.#signatures.length === 0) { + return; + } + if (this.#pendingVerify.size > 0) { + button.classList.add("state-loading"); + return; + } + let worst = "verified"; + for (const r of this.#results.values()) { + if (!r) { + continue; + } + if (STATUS_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 }; diff --git a/web/stubs-geckoview.js b/web/stubs-geckoview.js index daf7d94ffbf0b..4aa20d6f4b0b8 100644 --- a/web/stubs-geckoview.js +++ b/web/stubs-geckoview.js @@ -27,6 +27,7 @@ const PDFPresentationMode = null; const PDFThumbnailViewer = null; const SecondaryToolbar = null; const SignatureManager = null; +const SignaturePropertiesManager = null; const ViewsManager = null; export { @@ -44,5 +45,6 @@ export { PDFThumbnailViewer, SecondaryToolbar, SignatureManager, + SignaturePropertiesManager, ViewsManager, }; diff --git a/web/viewer.css b/web/viewer.css index 832439af42344..973c9dd1926ab 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -14,6 +14,7 @@ */ @import url(pdf_viewer.css); +@import url(signature_properties.css); :root { --dir-factor: 1; @@ -83,6 +84,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); diff --git a/web/viewer.html b/web/viewer.html index 0d5d90c7d61ca..bed69239cba22 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -84,6 +84,7 @@ "web-print_service": "./pdf_print_service.js", "web-secondary_toolbar": "./secondary_toolbar.js", "web-signature_manager": "./signature_manager.js", + "web-signature_properties_manager": "./signature_properties_manager.js", "web-toolbar": "./toolbar.js", "web-views_manager": "./views_manager.js" } @@ -432,6 +433,26 @@ +