mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-07-25 08:27:19 +02:00
Add Digital signature properties verification panel
Adds a new "Digital signature properties" doorhanger to the pdf.js toolbar that lists every digital signature found in the opened PDF, verifies each one (via NSS in the Firefox build through a new chrome bridge), and shows per-signature status + certificate state. The viewer side parses /Sig dicts in the worker (`PDFDocument.signatures`), strict-validates the /ByteRange offsets before slicing, and ships only signature metadata across the worker boundary. The PKCS#7 blob and signed-data byte spans live in a worker-side map and are fetched lazily one signature at a time via a new `getSignatureData(id)` RPC, immediately before verification runs, so the bytes never sit in main-thread memory for the document's lifetime. The panel is feature-gated by `pdfjs.enableSignatureVerification` (true in MOZCENTRAL/TESTING, off by default in the GENERIC build). External services expose a `createSignatureVerifier()` factory that the Firefox build wires up to `nsIX509CertDB.asyncVerifyPKCS7Object`; GENERIC builds return null and the toolbar button stays hidden. UI summary: - Toolbar button states: loading dots while in flight, then green check, orange `!`, or red `✕` based on the worst aggregate signature status. - Doorhanger contains a banner summarising the document state, then one card per signature with status row + certificate row (sub- signatures nested under their outer revision via /ByteRange containment). - Icons are mono SVGs themed via `mask-image` + `background-color` so they pick up light/dark/HCM via `--sig-icon-*` vars; flipped under RTL via `scaleX(var(--dir-factor))`. The HCM mapping reuses the alt-text vocabulary (ButtonFace / ButtonText / ButtonBorder / GrayText / AccentColor / LinkText) so this panel reads the same as the rest of the editor toolbar in high-contrast mode. - All visible strings are localized via Fluent (`pdfjs-digital-signature-properties-*`); status row, banner, and certificate row use explicit lookup tables instead of generated ids so a grep finds them. - Esc + outside-click close the panel through the viewer's existing handlers; the manager exposes `isOpen`, `close()`, and `shouldCloseOnClick(target)` for that. This commit also adds a `test/pdfs/sig_corpus/` directory holding a Python generator that produces a corpus of signed PDFs covering every visible state of the doorhanger (verified / untrusted / expired / invalid / unknown / multi-signature variants). The corpus is intentionally NOT part of the automated test suite — it is a manual-test tool. Generated `.pdf` files are gitignored; only the generator, README, and a `user.js.example` snippet are tracked. The generator shells out to mozilla-central's `security/manager/tools/pycms.py` (resolved via `--mozilla-central <path>` or the `MOZILLA_CENTRAL_SRC` env var) and the embedded test trust anchors (`pdf-sign-ca` / `pdf-sign-ca-expired`), gated by `security.pdf_signature_verification.enable_test_trust_anchors` so the test certificates never validate in shipping Firefox.
This commit is contained in:
parent
25eae30e4e
commit
07b1c625e1
@ -225,6 +225,8 @@ function createWebpackAlias(defines) {
|
|||||||
"web-print_service": "",
|
"web-print_service": "",
|
||||||
"web-secondary_toolbar": "web/secondary_toolbar.js",
|
"web-secondary_toolbar": "web/secondary_toolbar.js",
|
||||||
"web-signature_manager": "web/signature_manager.js",
|
"web-signature_manager": "web/signature_manager.js",
|
||||||
|
"web-digital_signature_properties_manager":
|
||||||
|
"web/digital_signature_properties_manager.js",
|
||||||
"web-toolbar": "web/toolbar.js",
|
"web-toolbar": "web/toolbar.js",
|
||||||
"web-views_manager": "web/views_manager.js",
|
"web-views_manager": "web/views_manager.js",
|
||||||
};
|
};
|
||||||
|
|||||||
@ -785,3 +785,96 @@ pdfjs-views-manager-paste-button-after =
|
|||||||
pdfjs-new-badge-content = NEW
|
pdfjs-new-badge-content = NEW
|
||||||
|
|
||||||
pdfjs-views-manager-waiting-for-file = Uploading file…
|
pdfjs-views-manager-waiting-for-file = Uploading file…
|
||||||
|
|
||||||
|
## Digital signature properties (signature verification panel)
|
||||||
|
|
||||||
|
pdfjs-digital-signature-properties-button =
|
||||||
|
.title = Digital signature properties
|
||||||
|
.aria-label = Digital signature properties
|
||||||
|
pdfjs-digital-signature-properties-button-label = Digital signature properties
|
||||||
|
|
||||||
|
## Banner shown above the signature list summarising the overall
|
||||||
|
## verification state of the document. Each variant is selected by the
|
||||||
|
## viewer based on the worst per-signature status; one signature is
|
||||||
|
## enough to lower the banner.
|
||||||
|
##
|
||||||
|
## Variables:
|
||||||
|
## $count (Number) - number of signatures at the worst level.
|
||||||
|
|
||||||
|
pdfjs-digital-signature-properties-banner-verified = Document was signed with a valid digital signature
|
||||||
|
pdfjs-digital-signature-properties-banner-unknown =
|
||||||
|
{ $count ->
|
||||||
|
[one] Document signed but { $count } digital signature could not be verified
|
||||||
|
*[other] Document signed but { $count } digital signatures could not be verified
|
||||||
|
}
|
||||||
|
pdfjs-digital-signature-properties-banner-untrusted =
|
||||||
|
{ $count ->
|
||||||
|
[one] Document signed with { $count } certificate that is not trusted
|
||||||
|
*[other] Document signed with { $count } certificates that are not trusted
|
||||||
|
}
|
||||||
|
pdfjs-digital-signature-properties-banner-expired =
|
||||||
|
{ $count ->
|
||||||
|
[one] Document signed with { $count } expired certificate
|
||||||
|
*[other] Document signed with { $count } expired certificates
|
||||||
|
}
|
||||||
|
pdfjs-digital-signature-properties-banner-invalid =
|
||||||
|
{ $count ->
|
||||||
|
[one] Document has { $count } invalid digital signature
|
||||||
|
*[other] Document has { $count } invalid digital signatures
|
||||||
|
}
|
||||||
|
pdfjs-digital-signature-properties-banner-revoked =
|
||||||
|
{ $count ->
|
||||||
|
[one] Document signed with { $count } revoked certificate
|
||||||
|
*[other] Document signed with { $count } revoked certificates
|
||||||
|
}
|
||||||
|
|
||||||
|
## Per-signature status row. Only three distinct strings are needed:
|
||||||
|
## the signature crypto either verified (the cert chain may still be
|
||||||
|
## untrusted/expired/revoked, but that's surfaced on the cert row
|
||||||
|
## below), or it failed, or its sub-format isn't supported.
|
||||||
|
|
||||||
|
pdfjs-digital-signature-properties-status-verified = Status: Signature verified
|
||||||
|
pdfjs-digital-signature-properties-status-invalid = Status: Signature invalid
|
||||||
|
pdfjs-digital-signature-properties-status-unknown = Status: Unable to verify (unsupported)
|
||||||
|
|
||||||
|
## Per-signature certificate row. The variants with an issuer / date in
|
||||||
|
## parentheses embed fully-localized context — no English fall-through.
|
||||||
|
##
|
||||||
|
## Variables:
|
||||||
|
## $issuer (String) - issuer or subject common name from the cert.
|
||||||
|
## $dateObj (Date) - notAfter date for the expired-with-date form.
|
||||||
|
|
||||||
|
pdfjs-digital-signature-properties-certificate-trusted = Certificate: Trusted ({ $issuer })
|
||||||
|
pdfjs-digital-signature-properties-certificate-unknown = Certificate: Unavailable
|
||||||
|
pdfjs-digital-signature-properties-certificate-untrusted = Certificate: Untrusted
|
||||||
|
pdfjs-digital-signature-properties-certificate-untrusted-unknown-issuer = Certificate: Unknown issuer ({ $issuer })
|
||||||
|
pdfjs-digital-signature-properties-certificate-untrusted-self-signed = Certificate: Self-signed ({ $issuer })
|
||||||
|
pdfjs-digital-signature-properties-certificate-untrusted-untrusted-issuer = Certificate: Untrusted issuer ({ $issuer })
|
||||||
|
pdfjs-digital-signature-properties-certificate-expired = Certificate: Expired
|
||||||
|
pdfjs-digital-signature-properties-certificate-expired-with-date = Certificate: Expired ({ DATETIME($dateObj, dateStyle: "medium") })
|
||||||
|
pdfjs-digital-signature-properties-certificate-revoked = Certificate: Revoked
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
pdfjs-digital-signature-properties-view-certificate = View certificate
|
||||||
|
|
||||||
|
# Shown beneath an invalid signature card to explain why verification
|
||||||
|
# failed. The text comes from NSS (e.g. "Signature integrity has been
|
||||||
|
# compromised", "PKCS#7 signature could not be parsed") and is not
|
||||||
|
# itself localized — it is the underlying error message produced by
|
||||||
|
# the verification backend.
|
||||||
|
# Variables:
|
||||||
|
# $reason (String) - error message describing why the signature
|
||||||
|
# could not be verified.
|
||||||
|
pdfjs-digital-signature-properties-reason = Reason: { $reason }
|
||||||
|
# Variables:
|
||||||
|
# $dateObj (Date) - the signing time from the /Sig dict's /M entry.
|
||||||
|
pdfjs-digital-signature-properties-timestamp = Timestamp: { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") }
|
||||||
|
# Variables:
|
||||||
|
# $count (Number) - number of nested sub-signatures (one per earlier
|
||||||
|
# incremental revision of the document).
|
||||||
|
pdfjs-digital-signature-properties-sub-signatures =
|
||||||
|
{ $count ->
|
||||||
|
[one] Sub-signature ({ $count })
|
||||||
|
*[other] Sub-signatures ({ $count })
|
||||||
|
}
|
||||||
|
|||||||
@ -1004,6 +1004,13 @@ function find(stream, signature, limit = 1024, backwards = false) {
|
|||||||
class PDFDocument {
|
class PDFDocument {
|
||||||
#pagePromises = new Map();
|
#pagePromises = new Map();
|
||||||
|
|
||||||
|
// Map<id, {data: Uint8Array[2], pkcs7: Uint8Array}> — populated by the
|
||||||
|
// `signatures` getter, consumed by `getSignatureData`. We deliberately
|
||||||
|
// keep the byte payload out of the metadata array so it doesn't ride
|
||||||
|
// the worker→main `postMessage` boundary unless the viewer actually
|
||||||
|
// asks to verify (one shot per signature).
|
||||||
|
#signatureData = null;
|
||||||
|
|
||||||
#version = null;
|
#version = null;
|
||||||
|
|
||||||
constructor(pdfManager, stream) {
|
constructor(pdfManager, stream) {
|
||||||
@ -1987,6 +1994,203 @@ class PDFDocument {
|
|||||||
return shadow(this, "fieldObjects", promise);
|
return shadow(this, "fieldObjects", promise);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#collectSignatureFields(fields, out, visitedRefs) {
|
||||||
|
if (!Array.isArray(fields)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const fieldRef of fields) {
|
||||||
|
if (fieldRef instanceof Ref) {
|
||||||
|
if (visitedRefs.has(fieldRef)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visitedRefs.put(fieldRef);
|
||||||
|
}
|
||||||
|
const field = this.xref.fetchIfRef(fieldRef);
|
||||||
|
if (!(field instanceof Dict)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (field.has("Kids")) {
|
||||||
|
this.#collectSignatureFields(field.get("Kids"), out, visitedRefs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isName(field.get("FT"), "Sig")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sigDict = this.xref.fetchIfRef(field.get("V"));
|
||||||
|
if (!(sigDict instanceof Dict)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = this.#parseSignatureDict(field, sigDict, fieldRef);
|
||||||
|
if (parsed) {
|
||||||
|
out.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The /ByteRange of a signature that covers the whole document usually
|
||||||
|
// ends a few bytes before the file's actual end — the trailer,
|
||||||
|
// `startxref` offset and `%%EOF` marker are conventionally left outside
|
||||||
|
// the signed range. 100 bytes covers the worst case (large
|
||||||
|
// `startxref` offsets, optional whitespace) without false positives.
|
||||||
|
static #WHOLE_DOCUMENT_TAIL_FUZZ = 100;
|
||||||
|
|
||||||
|
#parseSignatureDict(field, sigDict, fieldRef) {
|
||||||
|
const byteRange = sigDict.get("ByteRange");
|
||||||
|
if (
|
||||||
|
!Array.isArray(byteRange) ||
|
||||||
|
byteRange.length !== 4 ||
|
||||||
|
byteRange.some(n => !Number.isInteger(n) || n < 0)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const contents = sigDict.get("Contents");
|
||||||
|
if (typeof contents !== "string" || contents.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterName = sigDict.get("Filter");
|
||||||
|
const filter = filterName instanceof Name ? filterName.name : null;
|
||||||
|
const subFilterName = sigDict.get("SubFilter");
|
||||||
|
const subFilter = subFilterName instanceof Name ? subFilterName.name : null;
|
||||||
|
|
||||||
|
let signatureType = null;
|
||||||
|
if (subFilter === "adbe.pkcs7.detached") {
|
||||||
|
signatureType = 0;
|
||||||
|
} else if (subFilter === "adbe.pkcs7.sha1") {
|
||||||
|
signatureType = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slice the two ByteRange byte spans out of the underlying PDF stream.
|
||||||
|
// ByteRange = [a, b, c, d] means signed bytes are [a..a+b] and [c..c+d];
|
||||||
|
// the gap covers the /Contents hex blob itself.
|
||||||
|
const [a, b, c, d] = byteRange;
|
||||||
|
const stream = this.stream;
|
||||||
|
// `/ByteRange` offsets are absolute, so compare against `stream.end`
|
||||||
|
// (raw buffer end), not `stream.length` (post-`moveStart` payload).
|
||||||
|
const fileLength = stream.end || 0;
|
||||||
|
// Reject signatures whose /ByteRange is structurally implausible: it
|
||||||
|
// must start at the file head, define a non-empty first span, leave
|
||||||
|
// room for the /Contents blob between the two spans, and fit within
|
||||||
|
// the file. Without this a crafted PDF can claim to cover the whole
|
||||||
|
// document while only signing a small prologue.
|
||||||
|
if (
|
||||||
|
a !== 0 ||
|
||||||
|
b <= 0 ||
|
||||||
|
d < 0 ||
|
||||||
|
a + b > c ||
|
||||||
|
c + d > fileLength ||
|
||||||
|
fileLength === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = [stream.getByteRange(a, a + b), stream.getByteRange(c, c + d)];
|
||||||
|
|
||||||
|
const pkcs7 = stringToBytes(contents);
|
||||||
|
|
||||||
|
const t = field.get("T");
|
||||||
|
const fieldName = typeof t === "string" ? stringToPDFString(t) : "";
|
||||||
|
const name = sigDict.get("Name");
|
||||||
|
const reason = sigDict.get("Reason");
|
||||||
|
const location = sigDict.get("Location");
|
||||||
|
const contactInfo = sigDict.get("ContactInfo");
|
||||||
|
const m = sigDict.get("M");
|
||||||
|
|
||||||
|
const refKey = fieldRef instanceof Ref ? fieldRef.toString() : "inline";
|
||||||
|
const id = `${refKey}:${a}-${b}-${c}-${d}`;
|
||||||
|
|
||||||
|
// A signature "covers the whole document" iff the gap between the
|
||||||
|
// last signed byte and EOF is no more than the conventional
|
||||||
|
// trailer-slack window. Compare on the gap (not on the absolute
|
||||||
|
// lastSignedByte) so a tiny file with a tiny ByteRange isn't
|
||||||
|
// mis-flagged as full-coverage.
|
||||||
|
const tailGap = fileLength - (c + d);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
fieldName,
|
||||||
|
signerName: typeof name === "string" ? stringToPDFString(name) : null,
|
||||||
|
reason: typeof reason === "string" ? stringToPDFString(reason) : null,
|
||||||
|
location:
|
||||||
|
typeof location === "string" ? stringToPDFString(location) : null,
|
||||||
|
contactInfo:
|
||||||
|
typeof contactInfo === "string" ? stringToPDFString(contactInfo) : null,
|
||||||
|
signingTime: typeof m === "string" ? m : null,
|
||||||
|
filter,
|
||||||
|
subFilter,
|
||||||
|
signatureType,
|
||||||
|
byteRange,
|
||||||
|
pkcs7,
|
||||||
|
data,
|
||||||
|
revisionIndex: 0,
|
||||||
|
parentId: null,
|
||||||
|
coversWholeDocument:
|
||||||
|
tailGap >= 0 && tailGap <= PDFDocument.#WHOLE_DOCUMENT_TAIL_FUZZ,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get signatures() {
|
||||||
|
const promise = this.pdfManager
|
||||||
|
.ensureDoc("formInfo")
|
||||||
|
.then(async formInfo => {
|
||||||
|
if (!formInfo.hasSignatures || !formInfo.hasFields) {
|
||||||
|
this.#signatureData = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const annotationGlobals = await this.annotationGlobals;
|
||||||
|
if (!annotationGlobals) {
|
||||||
|
this.#signatureData = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const fields = annotationGlobals.acroForm.get("Fields");
|
||||||
|
|
||||||
|
const collected = [];
|
||||||
|
this.#collectSignatureFields(fields, collected, new RefSet());
|
||||||
|
|
||||||
|
// Group sub-signatures by ByteRange containment: outer revision is
|
||||||
|
// the largest covering signature (largest c + d). Sort descending,
|
||||||
|
// then point each later signature at the smallest enclosing parent
|
||||||
|
// that came before it.
|
||||||
|
collected.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.byteRange[2] + b.byteRange[3] - (a.byteRange[2] + a.byteRange[3])
|
||||||
|
);
|
||||||
|
for (let i = 0, ii = collected.length; i < ii; i++) {
|
||||||
|
const sig = collected[i];
|
||||||
|
sig.revisionIndex = i;
|
||||||
|
for (let j = i - 1; j >= 0; j--) {
|
||||||
|
const candidate = collected[j];
|
||||||
|
if (
|
||||||
|
candidate.byteRange[2] + candidate.byteRange[3] >
|
||||||
|
sig.byteRange[2] + sig.byteRange[3]
|
||||||
|
) {
|
||||||
|
sig.parentId = candidate.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Split bytes (`data`, `pkcs7`) out of the metadata so the array
|
||||||
|
// we ship to the main thread stays small. The viewer fetches the
|
||||||
|
// bytes on demand via `getSignatureData(id)`, one signature at a
|
||||||
|
// time, only when verification is about to run.
|
||||||
|
const signatureData = new Map();
|
||||||
|
const metadata = collected.map(sig => {
|
||||||
|
const { data, pkcs7, ...rest } = sig;
|
||||||
|
signatureData.set(sig.id, { data, pkcs7 });
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
this.#signatureData = signatureData;
|
||||||
|
return metadata.length ? metadata : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return shadow(this, "signatures", promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSignatureData(id) {
|
||||||
|
// Ensure parsing is finished and `#signatureData` is populated.
|
||||||
|
await this.signatures;
|
||||||
|
return this.#signatureData?.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
get hasJSActions() {
|
get hasJSActions() {
|
||||||
const promise = this.pdfManager.ensureDoc("_parseHasJSActions");
|
const promise = this.pdfManager.ensureDoc("_parseHasJSActions");
|
||||||
return shadow(this, "hasJSActions", promise);
|
return shadow(this, "hasJSActions", promise);
|
||||||
|
|||||||
@ -579,6 +579,14 @@ class WorkerMessageHandler {
|
|||||||
.then(fieldObjects => fieldObjects?.allFields || null);
|
.then(fieldObjects => fieldObjects?.allFields || null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
handler.on("GetSignatures", function (data) {
|
||||||
|
return pdfManager.ensureDoc("signatures");
|
||||||
|
});
|
||||||
|
|
||||||
|
handler.on("GetSignatureData", function (id) {
|
||||||
|
return pdfManager.ensureDoc("getSignatureData", [id]);
|
||||||
|
});
|
||||||
|
|
||||||
handler.on("HasJSActions", function (data) {
|
handler.on("HasJSActions", function (data) {
|
||||||
return pdfManager.ensureDoc("hasJSActions");
|
return pdfManager.ensureDoc("hasJSActions");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1077,6 +1077,29 @@ class PDFDocumentProxy {
|
|||||||
return this._transport.getFieldObjects();
|
return this._transport.getFieldObjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<Array<Object> | null>} A promise that is resolved
|
||||||
|
* with an {Array} of digital signature metadata (signerName, reason,
|
||||||
|
* signingTime, byteRange, subFilter, …), or `null` when the document
|
||||||
|
* has no signatures. The PKCS#7 blob and signed-data byte spans
|
||||||
|
* needed for verification are fetched separately via
|
||||||
|
* {@link PDFDocumentProxy.getSignatureData} so they don't ride the
|
||||||
|
* worker boundary unless verification is actually requested.
|
||||||
|
*/
|
||||||
|
getSignatures() {
|
||||||
|
return this._transport.getSignatures();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id Signature `id` from a {@link getSignatures} entry.
|
||||||
|
* @returns {Promise<{ data: Uint8Array[], pkcs7: Uint8Array } | null>}
|
||||||
|
* The byte payload needed to verify the signature, or `null` if the
|
||||||
|
* id is unknown.
|
||||||
|
*/
|
||||||
|
getSignatureData(id) {
|
||||||
|
return this._transport.getSignatureData(id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {Promise<boolean>} A promise that is resolved with `true`
|
* @returns {Promise<boolean>} A promise that is resolved with `true`
|
||||||
* if some /AcroForm fields have JavaScript actions.
|
* if some /AcroForm fields have JavaScript actions.
|
||||||
@ -3035,6 +3058,17 @@ class WorkerTransport {
|
|||||||
return this.#cacheSimpleMethod("GetFieldObjects");
|
return this.#cacheSimpleMethod("GetFieldObjects");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSignatures() {
|
||||||
|
return this.#cacheSimpleMethod("GetSignatures");
|
||||||
|
}
|
||||||
|
|
||||||
|
getSignatureData(id) {
|
||||||
|
// Not cached: bytes should be one-shot. Holding them in the
|
||||||
|
// `#methodPromises` map would keep them alive for the document's
|
||||||
|
// lifetime, which defeats the metadata/data split.
|
||||||
|
return this.messageHandler.sendWithPromise("GetSignatureData", id);
|
||||||
|
}
|
||||||
|
|
||||||
hasJSActions() {
|
hasJSActions() {
|
||||||
return this.#cacheSimpleMethod("HasJSActions");
|
return this.#cacheSimpleMethod("HasJSActions");
|
||||||
}
|
}
|
||||||
|
|||||||
3
test/pdfs/sig_corpus/.gitignore
vendored
Normal file
3
test/pdfs/sig_corpus/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
*.pdf
|
||||||
|
*.p7s
|
||||||
|
*.pkcs7spec
|
||||||
122
test/pdfs/sig_corpus/README.md
Normal file
122
test/pdfs/sig_corpus/README.md
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
# Digital signature properties — manual-test PDF corpus
|
||||||
|
|
||||||
|
This directory ships a Python generator that produces a small corpus
|
||||||
|
of digitally signed PDFs covering every visible state of the
|
||||||
|
**Digital signature properties** doorhanger. The intent is manual visual
|
||||||
|
testing: open each PDF in a Firefox build that has the signature
|
||||||
|
verification UI enabled, and compare what the toolbar / banner /
|
||||||
|
cards render against what the PDF's own page content says they
|
||||||
|
should render.
|
||||||
|
|
||||||
|
The PDFs themselves are **not committed** (`*.pdf` is ignored). Only
|
||||||
|
`generate.py` and this README are tracked, so you regenerate the
|
||||||
|
corpus when you need it.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. A built mozilla-central checkout. The generator shells out to its
|
||||||
|
`security/manager/tools/pycms.py` and reuses its vendored Python
|
||||||
|
modules under
|
||||||
|
`third_party/python/{ecdsa,rsa,pyasn1,pyasn1_modules,six}`.
|
||||||
|
2. A Firefox build (Nightly or a local artefact / full build) that
|
||||||
|
includes the pdf.js viewer + chrome bridge for the Signature
|
||||||
|
Properties UI. Any Nightly built after the Bug 1943059 landing
|
||||||
|
contains both pieces.
|
||||||
|
|
||||||
|
The generator finds your mozilla-central checkout in this order:
|
||||||
|
|
||||||
|
1. `--mozilla-central </path/to/mozilla-central>` CLI flag.
|
||||||
|
2. `MOZILLA_CENTRAL_SRC` environment variable.
|
||||||
|
3. `/opt/mozilla/firefox` (fallback default; prints a warning).
|
||||||
|
|
||||||
|
## Generate
|
||||||
|
|
||||||
|
From the pdf.js root, with the path resolved via any of the methods
|
||||||
|
above:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 test/pdfs/sig_corpus/generate.py \
|
||||||
|
--mozilla-central ~/src/mozilla-central
|
||||||
|
# …or…
|
||||||
|
MOZILLA_CENTRAL_SRC=~/src/mozilla-central \
|
||||||
|
python3 test/pdfs/sig_corpus/generate.py
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see eight `.pdf` files appear in this directory.
|
||||||
|
|
||||||
|
## Enable the test trust anchors pref
|
||||||
|
|
||||||
|
Three of the cases (`signed_verified`, both verified multi-sig PDFs,
|
||||||
|
and the outer half of `signed_multi_outer_verified_inner_expired`)
|
||||||
|
need Firefox to trust the bundled `pdf-sign-ca` test root. That root
|
||||||
|
is gated behind one pref:
|
||||||
|
|
||||||
|
```
|
||||||
|
security.pdf_signature_verification.enable_test_trust_anchors = true
|
||||||
|
```
|
||||||
|
|
||||||
|
The pref defaults to `false` in every Firefox build (Release, Beta,
|
||||||
|
Nightly, local), so by default a Firefox cannot validate PDFs
|
||||||
|
signed with these test certs. To enable it for manual testing:
|
||||||
|
|
||||||
|
- Easiest: append the contents of `user.js.example` (next to this
|
||||||
|
README) to your dev profile's `user.js` and (re)launch Firefox.
|
||||||
|
- Or via `about:config` → search for the pref name → toggle to
|
||||||
|
`true`.
|
||||||
|
|
||||||
|
⚠️ **Do not enable this in your normal browsing profile.** With the
|
||||||
|
pref on, any PDF signed with the publicly known mozilla-central test
|
||||||
|
private key validates as "trusted" until those certs expire
|
||||||
|
(`pdf-sign-ca` notAfter = 2027-01-01).
|
||||||
|
|
||||||
|
## Open the PDFs
|
||||||
|
|
||||||
|
Launch any Firefox build that bundles the Digital signature properties UI
|
||||||
|
and open the PDFs via `file:///` URLs, e.g.:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
firefox file:///$(pwd)/test/pdfs/sig_corpus/signed_verified.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
Or `./mach run -- file:///…/signed_verified.pdf` from your
|
||||||
|
mozilla-central checkout.
|
||||||
|
|
||||||
|
The page content of every PDF describes the expected toolbar icon,
|
||||||
|
banner, status row, and certificate row. Compare it against the
|
||||||
|
doorhanger.
|
||||||
|
|
||||||
|
## Cases
|
||||||
|
|
||||||
|
| File | Toolbar icon | Banner | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `signed_verified.pdf` | green ✓ | green | leaf ← `pdf-sign-ca` |
|
||||||
|
| `signed_untrusted.pdf` | orange ! | orange | self-signed root |
|
||||||
|
| `signed_expired.pdf` | orange ! | orange | leaf ← `pdf-sign-ca-expired` |
|
||||||
|
| `signed_invalid.pdf` | red × | red | one byte tampered post-sign |
|
||||||
|
| `signed_unknown.pdf` | red × | red | `/SubFilter /ETSI.CAdES.detached` (unsupported by pdf.js) |
|
||||||
|
| `signed_multi_verified.pdf` | green ✓ | green | both sigs valid, "Sub-signatures (1)" |
|
||||||
|
| `signed_multi_mixed.pdf` | orange ! | orange | outer verified, inner self-signed/untrusted |
|
||||||
|
| `signed_multi_outer_verified_inner_expired.pdf` | orange ! | orange | outer verified, inner expired — exercises worst-status-wins logic |
|
||||||
|
|
||||||
|
The last entry is the most informative for verifying that the
|
||||||
|
banner aggregation isn't accidentally clamped to the outermost
|
||||||
|
signature.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **`revoked` status.** Producing a revoked-certificate state
|
||||||
|
end-to-end against NSS requires either an OCSP responder, a CRL
|
||||||
|
file in the right path, or a OneCRL fixture — none of which are
|
||||||
|
feasible to ship as a static PDF corpus. The UI path for
|
||||||
|
`revoked` (red banner / red cert row / red toolbar icon) is
|
||||||
|
exercised only via the existing xpcshell tests.
|
||||||
|
- **CAdES validation.** `signed_unknown.pdf` only proves that pdf.js
|
||||||
|
short-circuits to `unknown` for `ETSI.CAdES.detached`; real CAdES
|
||||||
|
signature validation is follow-up Firefox work.
|
||||||
|
|
||||||
|
## Sanity check the safeguard
|
||||||
|
|
||||||
|
Open `signed_verified.pdf` with the pref **off** (default). Every
|
||||||
|
single PDF should now report `untrusted (unknown issuer)`. That's
|
||||||
|
the expected behavior in shipping Firefox and confirms the pref
|
||||||
|
guard is doing its job.
|
||||||
775
test/pdfs/sig_corpus/generate.py
Normal file
775
test/pdfs/sig_corpus/generate.py
Normal file
@ -0,0 +1,775 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright 2026 Mozilla Foundation
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
"""Generate the Digital signature properties test-PDF corpus.
|
||||||
|
|
||||||
|
Produces one PDF per verification UI state. Each PDF embeds a PKCS#7
|
||||||
|
detached signature minted by ``pycms.py`` from mozilla-central, with the
|
||||||
|
``messageDigest`` matching the actual ``/ByteRange`` bytes of the PDF.
|
||||||
|
|
||||||
|
The visible page content of every PDF is the description of the expected
|
||||||
|
UI state for that case — open the file in Firefox and compare what the
|
||||||
|
page says against what the Digital signature properties doorhanger renders.
|
||||||
|
|
||||||
|
Run from the pdf.js root:
|
||||||
|
|
||||||
|
python3 test/pdfs/sig_corpus/generate.py
|
||||||
|
|
||||||
|
Requires a built mozilla-central checkout so we can call its bundled
|
||||||
|
pycms.py / pycert.py / pykey.py and reuse the vendored Python deps under
|
||||||
|
third_party/python. The checkout location is resolved in this order:
|
||||||
|
|
||||||
|
1. The --mozilla-central CLI flag (highest priority).
|
||||||
|
2. The MOZILLA_CENTRAL_SRC environment variable.
|
||||||
|
3. /opt/mozilla/firefox (fallback default; will print a warning).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CORPUS_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
# Default location, used only when no CLI flag or env var overrides it.
|
||||||
|
# Patched at runtime in main() once the path is resolved.
|
||||||
|
DEFAULT_MOZILLA_CENTRAL_DIR = Path("/opt/mozilla/firefox")
|
||||||
|
FIREFOX_DIR = DEFAULT_MOZILLA_CENTRAL_DIR
|
||||||
|
TOOLS_DIR = FIREFOX_DIR / "security/manager/tools"
|
||||||
|
PYCMS = TOOLS_DIR / "pycms.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_mozilla_central_dir(cli_value):
|
||||||
|
"""CLI flag → MOZILLA_CENTRAL_SRC env var → default."""
|
||||||
|
if cli_value:
|
||||||
|
return Path(cli_value).expanduser().resolve()
|
||||||
|
env = os.environ.get("MOZILLA_CENTRAL_SRC")
|
||||||
|
if env:
|
||||||
|
return Path(env).expanduser().resolve()
|
||||||
|
sys.stderr.write(
|
||||||
|
f"warning: no --mozilla-central / MOZILLA_CENTRAL_SRC set, "
|
||||||
|
f"falling back to {DEFAULT_MOZILLA_CENTRAL_DIR}\n"
|
||||||
|
)
|
||||||
|
return DEFAULT_MOZILLA_CENTRAL_DIR
|
||||||
|
|
||||||
|
# Vendored Python modules pycms transitively imports.
|
||||||
|
VENDORED_DEPS = ["ecdsa", "rsa", "pyasn1", "pyasn1_modules", "six"]
|
||||||
|
|
||||||
|
# /Contents placeholder is sized so any single PKCS#7 we generate fits.
|
||||||
|
# The blobs we mint are ~1.2 KB each, so 4 KB of payload = 8192 hex chars.
|
||||||
|
PLACEHOLDER_PKCS7_LEN = 4096
|
||||||
|
|
||||||
|
|
||||||
|
def python_path_for_pycms():
|
||||||
|
parts = [str(FIREFOX_DIR / "third_party/python" / dep) for dep in VENDORED_DEPS]
|
||||||
|
parts.append(str(TOOLS_DIR))
|
||||||
|
return os.pathsep.join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def run_pycms(spec_text):
|
||||||
|
"""Invoke pycms.py with the given spec, return raw DER bytes of the PKCS#7."""
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PYTHONPATH"] = python_path_for_pycms()
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, str(PYCMS)],
|
||||||
|
input=spec_text.encode("ascii"),
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
sys.stderr.write(proc.stderr.decode("utf-8", "replace"))
|
||||||
|
raise SystemExit(f"pycms.py failed for spec:\n{spec_text}")
|
||||||
|
pem = proc.stdout.decode("ascii")
|
||||||
|
body = re.sub(r"(-----.*?-----|\s)", "", pem)
|
||||||
|
return base64.b64decode(body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Tiny PDF builder
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_lines(text, max_width=72):
|
||||||
|
out = []
|
||||||
|
for paragraph in text.split("\n"):
|
||||||
|
if not paragraph:
|
||||||
|
out.append("")
|
||||||
|
continue
|
||||||
|
words = paragraph.split(" ")
|
||||||
|
line = ""
|
||||||
|
for w in words:
|
||||||
|
if not line:
|
||||||
|
line = w
|
||||||
|
elif len(line) + 1 + len(w) <= max_width:
|
||||||
|
line += " " + w
|
||||||
|
else:
|
||||||
|
out.append(line)
|
||||||
|
line = w
|
||||||
|
if line:
|
||||||
|
out.append(line)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_ASCII_REPLACEMENTS = {
|
||||||
|
"—": "--", # em dash
|
||||||
|
"–": "-", # en dash
|
||||||
|
"‘": "'",
|
||||||
|
"’": "'",
|
||||||
|
"“": '"',
|
||||||
|
"”": '"',
|
||||||
|
"…": "...",
|
||||||
|
"→": "->",
|
||||||
|
"←": "<-",
|
||||||
|
"×": "x",
|
||||||
|
"✓": "v",
|
||||||
|
"✗": "x",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_pdf_string(s):
|
||||||
|
for src, dst in _ASCII_REPLACEMENTS.items():
|
||||||
|
s = s.replace(src, dst)
|
||||||
|
# Strip any remaining non-ASCII characters as a safety net.
|
||||||
|
s = s.encode("ascii", "replace").decode("ascii")
|
||||||
|
return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
||||||
|
|
||||||
|
|
||||||
|
def _content_stream_for(text):
|
||||||
|
"""Build a content stream that paints the text top-down with Helvetica."""
|
||||||
|
lines = _wrap_lines(text)
|
||||||
|
cmds = ["BT", "/F1 11 Tf", "12 TL", "50 780 Td"]
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if i == 0:
|
||||||
|
cmds.append(f"({_escape_pdf_string(line)}) Tj")
|
||||||
|
else:
|
||||||
|
cmds.append(f"T*")
|
||||||
|
cmds.append(f"({_escape_pdf_string(line)}) Tj")
|
||||||
|
cmds.append("ET")
|
||||||
|
return "\n".join(cmds).encode("latin-1")
|
||||||
|
|
||||||
|
|
||||||
|
class PdfBuilder:
|
||||||
|
"""Minimal one-page PDF builder with a single /Sig field.
|
||||||
|
|
||||||
|
Returned bytes have placeholder ByteRange [0 0 0 0] and a /Contents hex
|
||||||
|
string of zero bytes. The caller patches ByteRange and /Contents in
|
||||||
|
place after computing the offsets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, page_text, sub_filter="/adbe.pkcs7.detached"):
|
||||||
|
self.page_text = page_text
|
||||||
|
self.sub_filter = sub_filter
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
contents_stream = _content_stream_for(self.page_text)
|
||||||
|
|
||||||
|
objs = {}
|
||||||
|
objs[1] = (
|
||||||
|
b"<< /Type /Catalog /Pages 2 0 R "
|
||||||
|
b"/AcroForm << /Fields [4 0 R] /SigFlags 3 >> >>"
|
||||||
|
)
|
||||||
|
objs[2] = b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>"
|
||||||
|
objs[3] = (
|
||||||
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] "
|
||||||
|
b"/Contents 7 0 R /Resources << /Font << /F1 8 0 R >> >> >>"
|
||||||
|
)
|
||||||
|
objs[4] = (
|
||||||
|
b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) "
|
||||||
|
b"/V 5 0 R /Rect [0 0 0 0] /F 4 /P 3 0 R >>"
|
||||||
|
)
|
||||||
|
# Object 5 (the /Sig dict) is built later because it contains the
|
||||||
|
# placeholders we need to patch.
|
||||||
|
objs[7] = (
|
||||||
|
b"<< /Length " + str(len(contents_stream)).encode("ascii") + b" >>\n"
|
||||||
|
b"stream\n" + contents_stream + b"\nendstream"
|
||||||
|
)
|
||||||
|
objs[8] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
|
||||||
|
|
||||||
|
# Emit header + objects 1-4, then build the /Sig dict knowing
|
||||||
|
# /Contents will sit at a precise offset.
|
||||||
|
chunks = [b"%PDF-1.7\n%\xc2\xa5\xc2\xb1\xc3\xab\n"]
|
||||||
|
offsets = {}
|
||||||
|
|
||||||
|
def emit_obj(num, body):
|
||||||
|
header = f"{num} 0 obj\n".encode("ascii")
|
||||||
|
offsets[num] = sum(len(c) for c in chunks)
|
||||||
|
chunks.append(header + body + b"\nendobj\n")
|
||||||
|
|
||||||
|
emit_obj(1, objs[1])
|
||||||
|
emit_obj(2, objs[2])
|
||||||
|
emit_obj(3, objs[3])
|
||||||
|
emit_obj(4, objs[4])
|
||||||
|
|
||||||
|
# Object 5 — the /Sig dict. We build the prefix, capture the
|
||||||
|
# offset of the '<' that starts /Contents, append the placeholder
|
||||||
|
# zeros, then continue with the rest.
|
||||||
|
sig_prefix = (
|
||||||
|
b"<< /Type /Sig "
|
||||||
|
b"/Filter /Adobe.PPKLite "
|
||||||
|
b"/SubFilter " + self.sub_filter.encode("ascii") + b" "
|
||||||
|
b"/M (D:20260509000000Z) "
|
||||||
|
b"/Reason (Test signature for pdf.js Digital signature properties UI) "
|
||||||
|
b"/ByteRange [0000000000 0000000000 0000000000 0000000000] "
|
||||||
|
b"/Contents <"
|
||||||
|
)
|
||||||
|
sig_suffix_zeros = b"0" * (PLACEHOLDER_PKCS7_LEN * 2)
|
||||||
|
sig_suffix_close = b"> >>"
|
||||||
|
|
||||||
|
obj5_header = b"5 0 obj\n"
|
||||||
|
offsets[5] = sum(len(c) for c in chunks)
|
||||||
|
chunks.append(obj5_header + sig_prefix)
|
||||||
|
contents_start_in_obj = sum(len(c) for c in chunks) - 1 # the '<' byte
|
||||||
|
chunks.append(sig_suffix_zeros)
|
||||||
|
chunks.append(sig_suffix_close)
|
||||||
|
chunks.append(b"\nendobj\n")
|
||||||
|
|
||||||
|
emit_obj(7, objs[7])
|
||||||
|
emit_obj(8, objs[8])
|
||||||
|
|
||||||
|
# xref + trailer.
|
||||||
|
xref_offset = sum(len(c) for c in chunks)
|
||||||
|
xref_lines = [b"xref\n0 9\n", b"0000000000 65535 f \n"]
|
||||||
|
for num in range(1, 9):
|
||||||
|
if num == 6:
|
||||||
|
xref_lines.append(b"0000000000 65535 f \n")
|
||||||
|
continue
|
||||||
|
off = offsets[num]
|
||||||
|
xref_lines.append(f"{off:010d} 00000 n \n".encode("ascii"))
|
||||||
|
chunks.extend(xref_lines)
|
||||||
|
chunks.append(
|
||||||
|
b"trailer\n<< /Size 9 /Root 1 0 R >>\nstartxref\n"
|
||||||
|
+ str(xref_offset).encode("ascii")
|
||||||
|
+ b"\n%%EOF\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf = b"".join(chunks)
|
||||||
|
# Locate placeholders in the final byte string.
|
||||||
|
contents_open = pdf.index(b"/Contents <") + len(b"/Contents ")
|
||||||
|
# contents_open points at '<'; the hex blob starts right after.
|
||||||
|
hex_start = contents_open + 1
|
||||||
|
hex_end = hex_start + PLACEHOLDER_PKCS7_LEN * 2
|
||||||
|
assert pdf[hex_start:hex_end] == sig_suffix_zeros
|
||||||
|
assert pdf[hex_end:hex_end + 1] == b">"
|
||||||
|
|
||||||
|
return bytearray(pdf), contents_open, hex_start, hex_end
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_byte_range(pdf, byte_range):
|
||||||
|
placeholder = b"/ByteRange [0000000000 0000000000 0000000000 0000000000]"
|
||||||
|
a, b_, c, d = byte_range
|
||||||
|
replacement = (
|
||||||
|
f"/ByteRange [{a:010d} {b_:010d} {c:010d} {d:010d}]".encode("ascii")
|
||||||
|
)
|
||||||
|
assert len(replacement) == len(placeholder)
|
||||||
|
idx = pdf.index(placeholder)
|
||||||
|
pdf[idx:idx + len(placeholder)] = replacement
|
||||||
|
|
||||||
|
|
||||||
|
def _splice_pkcs7(pdf, hex_start, hex_end, pkcs7_der):
|
||||||
|
pkcs7_hex = pkcs7_der.hex().upper().encode("ascii")
|
||||||
|
if len(pkcs7_hex) > (hex_end - hex_start):
|
||||||
|
raise SystemExit(
|
||||||
|
f"PKCS#7 ({len(pkcs7_hex) // 2} bytes) larger than placeholder "
|
||||||
|
f"({(hex_end - hex_start) // 2})"
|
||||||
|
)
|
||||||
|
padded = pkcs7_hex.ljust(hex_end - hex_start, b"0")
|
||||||
|
pdf[hex_start:hex_end] = padded
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Cases
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
CASES = []
|
||||||
|
|
||||||
|
|
||||||
|
def _register(name, page_text, spec_template, *, sub_filter="/adbe.pkcs7.detached", post_process=None):
|
||||||
|
"""Register a single-signature case.
|
||||||
|
|
||||||
|
``spec_template`` is a string with ``{sha256}`` replaced by the
|
||||||
|
computed digest at generation time.
|
||||||
|
``post_process(pdf)`` runs after splicing the PKCS#7, allowing the
|
||||||
|
"invalid" case to flip a byte inside the ByteRange.
|
||||||
|
"""
|
||||||
|
CASES.append({
|
||||||
|
"name": name,
|
||||||
|
"page_text": page_text,
|
||||||
|
"spec_template": spec_template,
|
||||||
|
"sub_filter": sub_filter,
|
||||||
|
"post_process": post_process,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
PAGE_HEADER = """\
|
||||||
|
Digital signature properties — pdf.js test corpus
|
||||||
|
=========================================
|
||||||
|
|
||||||
|
This PDF is part of the manual-test corpus for the Signature
|
||||||
|
Properties UI. The text below describes what the toolbar button
|
||||||
|
and the doorhanger should look like when this file is opened in
|
||||||
|
a Firefox build with
|
||||||
|
security.pdf_signature_verification.enable_test_trust_anchors
|
||||||
|
set to true.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
_register(
|
||||||
|
"signed_verified",
|
||||||
|
PAGE_HEADER + """\
|
||||||
|
Expected verification state: VERIFIED
|
||||||
|
|
||||||
|
Toolbar icon: GREEN circle with white check.
|
||||||
|
Banner: GREEN. "Document signed and verified".
|
||||||
|
Status row: GREEN check, "Status: Signature verified".
|
||||||
|
Certificate row: GREEN check, "Certificate: Trusted (pdf-sign-ca)".
|
||||||
|
(Green only because this is the top-level card AND
|
||||||
|
every signature in the document is verified.)
|
||||||
|
"View certificate" link below the timestamp opens about:certificate.
|
||||||
|
No sub-signatures.
|
||||||
|
""",
|
||||||
|
"""\
|
||||||
|
sha256:{sha256}
|
||||||
|
signer:
|
||||||
|
issuer:pdf-sign-ca
|
||||||
|
subject:test-pdf-signer
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
_register(
|
||||||
|
"signed_untrusted",
|
||||||
|
PAGE_HEADER + """\
|
||||||
|
Expected verification state: UNTRUSTED
|
||||||
|
|
||||||
|
The signing certificate is self-signed and does not chain to any
|
||||||
|
trusted root, so even with the test trust anchors pref enabled the
|
||||||
|
chain validation reports SEC_ERROR_UNKNOWN_ISSUER.
|
||||||
|
|
||||||
|
Toolbar icon: ORANGE circle with white exclamation.
|
||||||
|
Banner: ORANGE. "Document signed with a certificate that
|
||||||
|
is not trusted".
|
||||||
|
Status row: grey check, "Status: Signature verified".
|
||||||
|
Certificate row: orange exclamation, "Certificate: Unknown issuer
|
||||||
|
(Untrusted Self-Signed Test Root)" -- the
|
||||||
|
parenthetical is the issuer CN from the cert.
|
||||||
|
""",
|
||||||
|
"""\
|
||||||
|
sha256:{sha256}
|
||||||
|
signer:
|
||||||
|
issuer:Untrusted Self-Signed Test Root
|
||||||
|
subject:Untrusted Self-Signed Test Root
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
_register(
|
||||||
|
"signed_expired",
|
||||||
|
PAGE_HEADER + """\
|
||||||
|
Expected verification state: EXPIRED
|
||||||
|
|
||||||
|
The signer is issued by pdf-sign-ca-expired, whose validity ended
|
||||||
|
in 2020. The CMS signature itself is fine (NS_OK) but chain
|
||||||
|
validation reports SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE.
|
||||||
|
|
||||||
|
Toolbar icon: ORANGE circle with white exclamation.
|
||||||
|
Banner: ORANGE. "Document signed with an expired
|
||||||
|
certificate".
|
||||||
|
Status row: grey check, "Status: Signature verified" -- the
|
||||||
|
cryptographic signature is fine, only the
|
||||||
|
certificate has expired.
|
||||||
|
Certificate row: orange exclamation, "Certificate: Expired
|
||||||
|
(Dec 31, 2020)" -- the parenthetical is the
|
||||||
|
leaf cert's notAfter date.
|
||||||
|
""",
|
||||||
|
"""\
|
||||||
|
sha256:{sha256}
|
||||||
|
signer:
|
||||||
|
issuer:pdf-sign-ca-expired
|
||||||
|
subject:test-pdf-signer-expired
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tamper_byterange(pdf):
|
||||||
|
# Flip one byte inside the page content stream so the CMS hash
|
||||||
|
# check fails.
|
||||||
|
marker = b"signed_invalid"
|
||||||
|
# The page text contains the file's name, so flipping a letter inside
|
||||||
|
# the marker is guaranteed to fall within the ByteRange.
|
||||||
|
idx = pdf.index(marker)
|
||||||
|
pdf[idx] = ord("S") if pdf[idx] == ord("s") else ord("s")
|
||||||
|
|
||||||
|
|
||||||
|
_register(
|
||||||
|
"signed_invalid",
|
||||||
|
PAGE_HEADER + """\
|
||||||
|
Expected verification state: INVALID — signed_invalid
|
||||||
|
|
||||||
|
After signing, one byte inside the document was flipped, so the
|
||||||
|
PKCS#7 messageDigest no longer matches the actual ByteRange data.
|
||||||
|
NSS returns SEC_ERROR_PKCS7_BAD_SIGNATURE — the signature is no
|
||||||
|
longer valid evidence that the document is intact.
|
||||||
|
|
||||||
|
Toolbar icon: RED circle with white cross.
|
||||||
|
Banner: RED. "Document has an invalid signature".
|
||||||
|
Status row: red cross, "Status: Signature invalid".
|
||||||
|
Certificate row: green check (cert was fine, only the signature
|
||||||
|
broke), "Certificate: Trusted (pdf-sign-ca)".
|
||||||
|
""",
|
||||||
|
"""\
|
||||||
|
sha256:{sha256}
|
||||||
|
signer:
|
||||||
|
issuer:pdf-sign-ca
|
||||||
|
subject:test-pdf-signer
|
||||||
|
""",
|
||||||
|
post_process=_tamper_byterange,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_register(
|
||||||
|
"signed_unknown",
|
||||||
|
PAGE_HEADER + """\
|
||||||
|
Expected verification state: UNKNOWN (unsupported)
|
||||||
|
|
||||||
|
The /Sig dict uses /SubFilter /ETSI.CAdES.detached, which pdf.js
|
||||||
|
maps to signatureType: null and never sends to NSS. The result
|
||||||
|
short-circuits to status: unknown without any cryptographic check.
|
||||||
|
|
||||||
|
Toolbar icon: RED circle with white cross (verification failed).
|
||||||
|
Banner: RED. "Document signed but the signature could
|
||||||
|
not be verified".
|
||||||
|
Status row: red cross, "Status: Unable to verify (unsupported)".
|
||||||
|
Certificate row: red cross, "Certificate: Unavailable".
|
||||||
|
No "View certificate" button (no certificate to show).
|
||||||
|
""",
|
||||||
|
# Spec is irrelevant (we do not actually call pycms for this case);
|
||||||
|
# the placeholder zeros stay in /Contents.
|
||||||
|
None,
|
||||||
|
sub_filter="/ETSI.CAdES.detached",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Driver
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_single(case):
|
||||||
|
builder = PdfBuilder(case["page_text"], sub_filter=case["sub_filter"])
|
||||||
|
pdf, contents_open, hex_start, hex_end = builder.build()
|
||||||
|
file_len = len(pdf)
|
||||||
|
a = 0
|
||||||
|
b = contents_open # bytes up to and including '<'? We want bytes up to '<' EXclusive.
|
||||||
|
# /Contents <abcdef...> — the bytes covered by ByteRange are everything
|
||||||
|
# except the hex string between '<' and '>'. So b = contents_open + 1
|
||||||
|
# (include the '<') and c = hex_end (the '>'), d = file_len - hex_end.
|
||||||
|
b = contents_open + 1
|
||||||
|
c = hex_end
|
||||||
|
d = file_len - hex_end
|
||||||
|
_patch_byte_range(pdf, (a, b, c, d))
|
||||||
|
|
||||||
|
if case["spec_template"] is None:
|
||||||
|
# "unknown" case — leave /Contents zeros.
|
||||||
|
return pdf
|
||||||
|
|
||||||
|
digest = hashlib.sha256(bytes(pdf[a:a + b]) + bytes(pdf[c:c + d])).hexdigest()
|
||||||
|
spec_text = case["spec_template"].format(sha256=digest)
|
||||||
|
pkcs7_der = run_pycms(spec_text)
|
||||||
|
_splice_pkcs7(pdf, hex_start, hex_end, pkcs7_der)
|
||||||
|
|
||||||
|
if case.get("post_process"):
|
||||||
|
case["post_process"](pdf)
|
||||||
|
|
||||||
|
return pdf
|
||||||
|
|
||||||
|
|
||||||
|
# Multi-signature cases use incremental updates: build an inner-signed
|
||||||
|
# PDF first, then append additional objects + a second /Sig field whose
|
||||||
|
# /ByteRange covers the entire updated file.
|
||||||
|
|
||||||
|
def _build_multi(name, page_text, inner_spec_template, outer_spec_template):
|
||||||
|
"""Two-signature PDF.
|
||||||
|
|
||||||
|
The inner signature is created normally over a single-page PDF.
|
||||||
|
Then we incrementally append a new /AcroForm referencing both Sig
|
||||||
|
fields and a second /Sig dict whose /ByteRange covers the full
|
||||||
|
file. Both signatures are independent CMS messages over their own
|
||||||
|
/ByteRange spans, exactly the pattern issue17169.pdf uses.
|
||||||
|
"""
|
||||||
|
inner = PdfBuilder(page_text, sub_filter="/adbe.pkcs7.detached")
|
||||||
|
pdf, c_open, h_start, h_end = inner.build()
|
||||||
|
a, b = 0, c_open + 1
|
||||||
|
c = h_end
|
||||||
|
d = len(pdf) - h_end
|
||||||
|
_patch_byte_range(pdf, (a, b, c, d))
|
||||||
|
digest = hashlib.sha256(bytes(pdf[a:a + b]) + bytes(pdf[c:c + d])).hexdigest()
|
||||||
|
pkcs7_der = run_pycms(inner_spec_template.format(sha256=digest))
|
||||||
|
_splice_pkcs7(pdf, h_start, h_end, pkcs7_der)
|
||||||
|
|
||||||
|
inner_pdf = bytes(pdf)
|
||||||
|
|
||||||
|
# Incremental update: append updated catalog with a second /Sig field
|
||||||
|
# plus the new /Sig dict + xref + trailer. Each new object gets a new
|
||||||
|
# number; we reuse 9, 10, 11, 12 for second sig field, second sig
|
||||||
|
# dict, updated catalog, and updated AcroForm.
|
||||||
|
|
||||||
|
# Build the appended chunk with placeholders we can patch in place.
|
||||||
|
update_offsets = {}
|
||||||
|
update_chunks = []
|
||||||
|
|
||||||
|
# Object 9: second Sig field annot.
|
||||||
|
update_chunks.append(b"\n9 0 obj\n")
|
||||||
|
update_offsets[9] = len(inner_pdf) + sum(len(c) for c in update_chunks) - len(b"\n9 0 obj\n")
|
||||||
|
update_chunks.append(
|
||||||
|
b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature2) "
|
||||||
|
b"/V 10 0 R /Rect [0 0 0 0] /F 4 /P 3 0 R >>\nendobj\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Object 10: second Sig dict (placeholders).
|
||||||
|
sig_prefix = (
|
||||||
|
b"<< /Type /Sig "
|
||||||
|
b"/Filter /Adobe.PPKLite "
|
||||||
|
b"/SubFilter /adbe.pkcs7.detached "
|
||||||
|
b"/M (D:20260509000001Z) "
|
||||||
|
b"/Reason (Outer signature for sub-signature test) "
|
||||||
|
b"/ByteRange [0000000000 0000000000 0000000000 0000000000] "
|
||||||
|
b"/Contents <"
|
||||||
|
)
|
||||||
|
sig_zeros = b"0" * (PLACEHOLDER_PKCS7_LEN * 2)
|
||||||
|
update_chunks.append(b"10 0 obj\n")
|
||||||
|
update_offsets[10] = len(inner_pdf) + sum(len(c) for c in update_chunks) - len(b"10 0 obj\n")
|
||||||
|
update_chunks.append(sig_prefix)
|
||||||
|
contents_open_2 = len(inner_pdf) + sum(len(c) for c in update_chunks) - 1 # '<'
|
||||||
|
update_chunks.append(sig_zeros)
|
||||||
|
update_chunks.append(b">")
|
||||||
|
update_chunks.append(b" >>\nendobj\n")
|
||||||
|
|
||||||
|
# Object 1 (catalog) updated to point at a new AcroForm with both
|
||||||
|
# fields. We rewrite the same number; the xref will mark it as
|
||||||
|
# generation 0 again with a new offset.
|
||||||
|
update_chunks.append(b"1 0 obj\n")
|
||||||
|
update_offsets[1] = len(inner_pdf) + sum(len(c) for c in update_chunks) - len(b"1 0 obj\n")
|
||||||
|
update_chunks.append(
|
||||||
|
b"<< /Type /Catalog /Pages 2 0 R "
|
||||||
|
b"/AcroForm << /Fields [4 0 R 9 0 R] /SigFlags 3 >> >>\nendobj\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
appended = b"".join(update_chunks)
|
||||||
|
pdf = bytearray(inner_pdf + appended)
|
||||||
|
|
||||||
|
# Compute final positions of the new placeholder.
|
||||||
|
hex_start_2 = contents_open_2 + 1
|
||||||
|
hex_end_2 = hex_start_2 + PLACEHOLDER_PKCS7_LEN * 2
|
||||||
|
assert pdf[contents_open_2:contents_open_2 + 1] == b"<"
|
||||||
|
assert pdf[hex_end_2:hex_end_2 + 1] == b">"
|
||||||
|
|
||||||
|
# Build incremental xref table.
|
||||||
|
# Pre-xref offset for startxref.
|
||||||
|
pre_xref = bytearray(pdf)
|
||||||
|
|
||||||
|
# Patch ByteRange of outer Sig: covers everything except the hex
|
||||||
|
# blob inside the second /Contents.
|
||||||
|
outer_a = 0
|
||||||
|
outer_b = contents_open_2 + 1
|
||||||
|
outer_c = hex_end_2
|
||||||
|
|
||||||
|
# We don't know `d` until we know where the xref/trailer is, which
|
||||||
|
# depends on `d` itself if the offset/`d` lengths change... but
|
||||||
|
# ByteRange numbers are fixed-width 010d so they don't change size.
|
||||||
|
# So we can compute `d` once we know where the file ends.
|
||||||
|
|
||||||
|
# We'll construct the rest assuming `d` is the bytes from hex_end_2
|
||||||
|
# to EOF. Build the xref + trailer relative to current position.
|
||||||
|
xref_offset = len(pre_xref)
|
||||||
|
xref = (
|
||||||
|
b"xref\n"
|
||||||
|
+ b"1 1\n" + f"{update_offsets[1]:010d} 00000 n \n".encode("ascii")
|
||||||
|
+ b"9 2\n"
|
||||||
|
+ f"{update_offsets[9]:010d} 00000 n \n".encode("ascii")
|
||||||
|
+ f"{update_offsets[10]:010d} 00000 n \n".encode("ascii")
|
||||||
|
)
|
||||||
|
trailer = (
|
||||||
|
b"trailer\n"
|
||||||
|
+ b"<< /Size 11 /Root 1 0 R /Prev "
|
||||||
|
+ str(_find_startxref(inner_pdf)).encode("ascii")
|
||||||
|
+ b" >>\nstartxref\n"
|
||||||
|
+ str(xref_offset).encode("ascii")
|
||||||
|
+ b"\n%%EOF\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf = bytearray(pre_xref + xref + trailer)
|
||||||
|
outer_d = len(pdf) - outer_c
|
||||||
|
_patch_byte_range_from(
|
||||||
|
pdf, outer_a, outer_b, outer_c, outer_d,
|
||||||
|
# The placeholder belongs to obj 10 only.
|
||||||
|
from_offset=update_offsets[10],
|
||||||
|
)
|
||||||
|
|
||||||
|
digest_outer = hashlib.sha256(
|
||||||
|
bytes(pdf[outer_a:outer_a + outer_b]) + bytes(pdf[outer_c:outer_c + outer_d])
|
||||||
|
).hexdigest()
|
||||||
|
pkcs7_outer = run_pycms(outer_spec_template.format(sha256=digest_outer))
|
||||||
|
_splice_pkcs7(pdf, hex_start_2, hex_end_2, pkcs7_outer)
|
||||||
|
|
||||||
|
return pdf
|
||||||
|
|
||||||
|
|
||||||
|
def _find_startxref(pdf_bytes):
|
||||||
|
idx = pdf_bytes.rindex(b"startxref")
|
||||||
|
after = pdf_bytes[idx + len(b"startxref"):]
|
||||||
|
m = re.search(rb"\d+", after)
|
||||||
|
return int(m.group(0))
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_byte_range_from(pdf, a, b, c, d, *, from_offset):
|
||||||
|
placeholder = b"/ByteRange [0000000000 0000000000 0000000000 0000000000]"
|
||||||
|
replacement = (
|
||||||
|
f"/ByteRange [{a:010d} {b:010d} {c:010d} {d:010d}]".encode("ascii")
|
||||||
|
)
|
||||||
|
assert len(replacement) == len(placeholder)
|
||||||
|
idx = pdf.index(placeholder, from_offset)
|
||||||
|
pdf[idx:idx + len(placeholder)] = replacement
|
||||||
|
|
||||||
|
|
||||||
|
SPEC_VERIFIED = """\
|
||||||
|
sha256:{sha256}
|
||||||
|
signer:
|
||||||
|
issuer:pdf-sign-ca
|
||||||
|
subject:test-pdf-signer
|
||||||
|
"""
|
||||||
|
SPEC_EXPIRED = """\
|
||||||
|
sha256:{sha256}
|
||||||
|
signer:
|
||||||
|
issuer:pdf-sign-ca-expired
|
||||||
|
subject:test-pdf-signer-expired
|
||||||
|
"""
|
||||||
|
SPEC_UNTRUSTED = """\
|
||||||
|
sha256:{sha256}
|
||||||
|
signer:
|
||||||
|
issuer:Untrusted Self-Signed Test Root
|
||||||
|
subject:Untrusted Self-Signed Test Root
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
MULTI_CASES = [
|
||||||
|
(
|
||||||
|
"signed_multi_verified",
|
||||||
|
PAGE_HEADER + """\
|
||||||
|
Expected verification state: VERIFIED (multi-signature)
|
||||||
|
|
||||||
|
Outer signature: leaf <- pdf-sign-ca -> verified.
|
||||||
|
Inner signature: leaf <- pdf-sign-ca -> verified.
|
||||||
|
|
||||||
|
Toolbar icon: GREEN check. Banner: GREEN, "Document signed and
|
||||||
|
verified" (count = 2). Outer card has GREEN status check + GREEN
|
||||||
|
"Certificate: Trusted (pdf-sign-ca)" (everything is fine, top-
|
||||||
|
level). The expanded inner card uses the muted GREY check on
|
||||||
|
both rows -- green is reserved for the top-level card.
|
||||||
|
""",
|
||||||
|
# inner_spec, outer_spec
|
||||||
|
SPEC_VERIFIED, SPEC_VERIFIED,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"signed_multi_mixed",
|
||||||
|
PAGE_HEADER + """\
|
||||||
|
Expected verification state: UNTRUSTED (multi-signature)
|
||||||
|
|
||||||
|
Outer signature: leaf <- pdf-sign-ca -> verified.
|
||||||
|
Inner signature: self-signed -> untrusted.
|
||||||
|
|
||||||
|
Toolbar icon: ORANGE exclamation. Banner: ORANGE, "Document signed
|
||||||
|
with a certificate that is not trusted" (count = 1, the inner sig).
|
||||||
|
Outer card shows GREY check, "Certificate: Trusted (pdf-sign-ca)"
|
||||||
|
(green is suppressed because the inner sig is untrusted). Expanded
|
||||||
|
inner card shows orange "Unknown issuer (Untrusted Self-Signed
|
||||||
|
Test Root)".
|
||||||
|
""",
|
||||||
|
# inner = untrusted, outer = verified
|
||||||
|
SPEC_UNTRUSTED, SPEC_VERIFIED,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"signed_multi_outer_verified_inner_expired",
|
||||||
|
PAGE_HEADER + """\
|
||||||
|
Expected verification state: EXPIRED (multi-signature)
|
||||||
|
|
||||||
|
Outer signature: leaf <- pdf-sign-ca -> verified.
|
||||||
|
Inner signature: leaf <- pdf-sign-ca-expired -> expired.
|
||||||
|
|
||||||
|
This case is the most informative test of the worst-status banner
|
||||||
|
aggregation: the top-level signature is fully valid, but the worst
|
||||||
|
status across the whole tree is "expired", so the banner must show
|
||||||
|
the orange "...with an expired certificate" message and the toolbar
|
||||||
|
button must use the orange warn icon.
|
||||||
|
|
||||||
|
Outer card: status grey check, certificate GREY "Trusted
|
||||||
|
(pdf-sign-ca)" (green suppressed because the inner sig
|
||||||
|
is expired).
|
||||||
|
Inner card: status grey check (signature itself is verified),
|
||||||
|
certificate orange "Expired (Dec 31, 2020)".
|
||||||
|
""",
|
||||||
|
# inner = expired, outer = verified
|
||||||
|
SPEC_EXPIRED, SPEC_VERIFIED,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--out",
|
||||||
|
type=Path,
|
||||||
|
default=CORPUS_DIR,
|
||||||
|
help="Output directory (default: this script's directory).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mozilla-central",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Path to a built mozilla-central checkout. Defaults to the "
|
||||||
|
"MOZILLA_CENTRAL_SRC env var, then /opt/mozilla/firefox."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
global FIREFOX_DIR, TOOLS_DIR, PYCMS
|
||||||
|
FIREFOX_DIR = _resolve_mozilla_central_dir(args.mozilla_central)
|
||||||
|
TOOLS_DIR = FIREFOX_DIR / "security/manager/tools"
|
||||||
|
PYCMS = TOOLS_DIR / "pycms.py"
|
||||||
|
|
||||||
|
if not PYCMS.exists():
|
||||||
|
raise SystemExit(
|
||||||
|
f"pycms.py not found at {PYCMS}\n"
|
||||||
|
f"Pass --mozilla-central </path/to/mozilla-central> or set the "
|
||||||
|
f"MOZILLA_CENTRAL_SRC environment variable."
|
||||||
|
)
|
||||||
|
|
||||||
|
for case in CASES:
|
||||||
|
pdf = _build_single(case)
|
||||||
|
path = args.out / f"{case['name']}.pdf"
|
||||||
|
path.write_bytes(bytes(pdf))
|
||||||
|
print(f" wrote {path.name} ({len(pdf)} bytes)")
|
||||||
|
|
||||||
|
for name, page_text, inner_spec, outer_spec in MULTI_CASES:
|
||||||
|
# NB: Inner signature template has a unique tag so the post-process
|
||||||
|
# tampering of "signed_invalid" can target it; multi cases don't
|
||||||
|
# need post_process.
|
||||||
|
pdf = _build_multi(name, page_text, inner_spec, outer_spec)
|
||||||
|
path = args.out / f"{name}.pdf"
|
||||||
|
path.write_bytes(bytes(pdf))
|
||||||
|
print(f" wrote {path.name} ({len(pdf)} bytes)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
9
test/pdfs/sig_corpus/user.js.example
Normal file
9
test/pdfs/sig_corpus/user.js.example
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// Append this line to your dev Firefox profile's user.js to make
|
||||||
|
// the embedded test PDF-signing trust anchors participate in chain
|
||||||
|
// validation.
|
||||||
|
//
|
||||||
|
// DO NOT add this to a profile you use for normal browsing — it
|
||||||
|
// allows any PDF signed with the publicly known mozilla-central
|
||||||
|
// test CA private key to verify as "trusted" until those certs
|
||||||
|
// expire (2027-01-01 for pdf-sign-ca).
|
||||||
|
user_pref("security.pdf_signature_verification.enable_test_trust_anchors", true);
|
||||||
@ -45,7 +45,11 @@ describe("document", function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("PDFDocument", function () {
|
describe("PDFDocument", function () {
|
||||||
const stream = new StringStream("Dummy_PDF_data");
|
// Padded to 1024 bytes so signature ByteRange tests using offsets
|
||||||
|
// like `[0, 100, 200, 800]` stay within `stream.end` (the new
|
||||||
|
// `#parseSignatureDict` validation rejects ByteRanges that exceed
|
||||||
|
// the file length).
|
||||||
|
const stream = new StringStream("Dummy_PDF_data".padEnd(1024, " "));
|
||||||
|
|
||||||
function getDocument(acroForm, xref = new XRefMock()) {
|
function getDocument(acroForm, xref = new XRefMock()) {
|
||||||
const catalog = { acroForm };
|
const catalog = { acroForm };
|
||||||
@ -188,6 +192,221 @@ describe("document", function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getSignatures", function () {
|
||||||
|
function makeSigDict({
|
||||||
|
byteRange,
|
||||||
|
contents = "00".repeat(8),
|
||||||
|
subFilter = "adbe.pkcs7.detached",
|
||||||
|
name = null,
|
||||||
|
reason = null,
|
||||||
|
location = null,
|
||||||
|
m = null,
|
||||||
|
}) {
|
||||||
|
const dict = new Dict();
|
||||||
|
dict.set("Type", Name.get("Sig"));
|
||||||
|
dict.set("Filter", Name.get("Adobe.PPKLite"));
|
||||||
|
dict.set("SubFilter", Name.get(subFilter));
|
||||||
|
dict.set("ByteRange", byteRange);
|
||||||
|
dict.set("Contents", contents);
|
||||||
|
if (name !== null) {
|
||||||
|
dict.set("Name", name);
|
||||||
|
}
|
||||||
|
if (reason !== null) {
|
||||||
|
dict.set("Reason", reason);
|
||||||
|
}
|
||||||
|
if (location !== null) {
|
||||||
|
dict.set("Location", location);
|
||||||
|
}
|
||||||
|
if (m !== null) {
|
||||||
|
dict.set("M", m);
|
||||||
|
}
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSigField({ T, sigRef }) {
|
||||||
|
const dict = new Dict();
|
||||||
|
dict.set("FT", Name.get("Sig"));
|
||||||
|
dict.set("T", T);
|
||||||
|
dict.set("V", sigRef);
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns null when no signatures are present", async function () {
|
||||||
|
const acroForm = new Dict();
|
||||||
|
const pdfDocument = getDocument(acroForm);
|
||||||
|
const signatures = await pdfDocument.signatures;
|
||||||
|
expect(signatures).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts metadata for a single signature", async function () {
|
||||||
|
const acroForm = new Dict();
|
||||||
|
acroForm.set("SigFlags", 3);
|
||||||
|
|
||||||
|
const sigRef = Ref.get(20, 0);
|
||||||
|
const fieldRef = Ref.get(21, 0);
|
||||||
|
const sigDict = makeSigDict({
|
||||||
|
byteRange: [0, 100, 200, 300],
|
||||||
|
name: "Alice Becker",
|
||||||
|
reason: "Approved for release",
|
||||||
|
m: "D:20251014103200+00'00'",
|
||||||
|
});
|
||||||
|
const fieldDict = makeSigField({ T: "sig_alice", sigRef });
|
||||||
|
|
||||||
|
const xref = new XRefMock([
|
||||||
|
{ ref: sigRef, data: sigDict },
|
||||||
|
{ ref: fieldRef, data: fieldDict },
|
||||||
|
]);
|
||||||
|
acroForm.set("Fields", [fieldRef]);
|
||||||
|
|
||||||
|
const pdfDocument = getDocument(acroForm, xref);
|
||||||
|
const signatures = await pdfDocument.signatures;
|
||||||
|
expect(signatures.length).toEqual(1);
|
||||||
|
const [sig] = signatures;
|
||||||
|
expect(sig.signerName).toEqual("Alice Becker");
|
||||||
|
expect(sig.reason).toEqual("Approved for release");
|
||||||
|
expect(sig.signingTime).toEqual("D:20251014103200+00'00'");
|
||||||
|
expect(sig.fieldName).toEqual("sig_alice");
|
||||||
|
expect(sig.subFilter).toEqual("adbe.pkcs7.detached");
|
||||||
|
expect(sig.signatureType).toEqual(0);
|
||||||
|
expect(sig.byteRange).toEqual([0, 100, 200, 300]);
|
||||||
|
expect(sig.parentId).toEqual(null);
|
||||||
|
expect(sig.revisionIndex).toEqual(0);
|
||||||
|
// The bytes (pkcs7 + signed-data spans) are no longer attached
|
||||||
|
// to the metadata array — they're fetched on demand via
|
||||||
|
// `getSignatureData(id)` so the worker→main message stays
|
||||||
|
// small.
|
||||||
|
expect(sig.pkcs7).toBeUndefined();
|
||||||
|
expect(sig.data).toBeUndefined();
|
||||||
|
const bytes = await pdfDocument.getSignatureData(sig.id);
|
||||||
|
expect(bytes.pkcs7).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(Array.isArray(bytes.data)).toBeTrue();
|
||||||
|
expect(bytes.data.length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks Kids recursively to find nested signature fields", async function () {
|
||||||
|
const acroForm = new Dict();
|
||||||
|
acroForm.set("SigFlags", 3);
|
||||||
|
|
||||||
|
const sigRef = Ref.get(30, 0);
|
||||||
|
const sigFieldRef = Ref.get(31, 0);
|
||||||
|
const containerRef = Ref.get(32, 0);
|
||||||
|
|
||||||
|
const sigDict = makeSigDict({
|
||||||
|
byteRange: [0, 50, 100, 150],
|
||||||
|
name: "John Smith",
|
||||||
|
});
|
||||||
|
const sigField = makeSigField({ T: "sig_john", sigRef });
|
||||||
|
const container = new Dict();
|
||||||
|
container.set("Kids", [sigFieldRef]);
|
||||||
|
|
||||||
|
const xref = new XRefMock([
|
||||||
|
{ ref: sigRef, data: sigDict },
|
||||||
|
{ ref: sigFieldRef, data: sigField },
|
||||||
|
{ ref: containerRef, data: container },
|
||||||
|
]);
|
||||||
|
acroForm.set("Fields", [containerRef]);
|
||||||
|
|
||||||
|
const pdfDocument = getDocument(acroForm, xref);
|
||||||
|
const signatures = await pdfDocument.signatures;
|
||||||
|
expect(signatures.length).toEqual(1);
|
||||||
|
expect(signatures[0].signerName).toEqual("John Smith");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips signatures with malformed ByteRange", async function () {
|
||||||
|
const acroForm = new Dict();
|
||||||
|
acroForm.set("SigFlags", 3);
|
||||||
|
|
||||||
|
const sigRef = Ref.get(40, 0);
|
||||||
|
const fieldRef = Ref.get(41, 0);
|
||||||
|
const sigDict = makeSigDict({ byteRange: [0, 100] }); // wrong length
|
||||||
|
const fieldDict = makeSigField({ T: "bad", sigRef });
|
||||||
|
|
||||||
|
const xref = new XRefMock([
|
||||||
|
{ ref: sigRef, data: sigDict },
|
||||||
|
{ ref: fieldRef, data: fieldDict },
|
||||||
|
]);
|
||||||
|
acroForm.set("Fields", [fieldRef]);
|
||||||
|
|
||||||
|
const pdfDocument = getDocument(acroForm, xref);
|
||||||
|
expect(await pdfDocument.signatures).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups sub-signatures under the outer revision", async function () {
|
||||||
|
const acroForm = new Dict();
|
||||||
|
acroForm.set("SigFlags", 3);
|
||||||
|
|
||||||
|
// Outer covers more bytes (c+d larger) → parent.
|
||||||
|
// Inner covers fewer bytes → sub-signature of outer.
|
||||||
|
const outerSigRef = Ref.get(50, 0);
|
||||||
|
const outerFieldRef = Ref.get(51, 0);
|
||||||
|
const innerSigRef = Ref.get(52, 0);
|
||||||
|
const innerFieldRef = Ref.get(53, 0);
|
||||||
|
|
||||||
|
const outerSig = makeSigDict({
|
||||||
|
byteRange: [0, 100, 200, 800],
|
||||||
|
name: "Outer",
|
||||||
|
});
|
||||||
|
const innerSig = makeSigDict({
|
||||||
|
byteRange: [0, 50, 100, 200],
|
||||||
|
name: "Inner",
|
||||||
|
});
|
||||||
|
|
||||||
|
const xref = new XRefMock([
|
||||||
|
{ ref: outerSigRef, data: outerSig },
|
||||||
|
{
|
||||||
|
ref: outerFieldRef,
|
||||||
|
data: makeSigField({ T: "outer", sigRef: outerSigRef }),
|
||||||
|
},
|
||||||
|
{ ref: innerSigRef, data: innerSig },
|
||||||
|
{
|
||||||
|
ref: innerFieldRef,
|
||||||
|
data: makeSigField({ T: "inner", sigRef: innerSigRef }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
acroForm.set("Fields", [outerFieldRef, innerFieldRef]);
|
||||||
|
|
||||||
|
const pdfDocument = getDocument(acroForm, xref);
|
||||||
|
const signatures = await pdfDocument.signatures;
|
||||||
|
expect(signatures.length).toEqual(2);
|
||||||
|
// Sorted descending by c+d, so outer comes first.
|
||||||
|
expect(signatures[0].signerName).toEqual("Outer");
|
||||||
|
expect(signatures[0].parentId).toEqual(null);
|
||||||
|
expect(signatures[0].revisionIndex).toEqual(0);
|
||||||
|
expect(signatures[1].signerName).toEqual("Inner");
|
||||||
|
expect(signatures[1].parentId).toEqual(signatures[0].id);
|
||||||
|
expect(signatures[1].revisionIndex).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps SubFilter to the PDFSignatureAlgorithm enum", async function () {
|
||||||
|
const acroForm = new Dict();
|
||||||
|
acroForm.set("SigFlags", 3);
|
||||||
|
|
||||||
|
async function signatureType(subFilter) {
|
||||||
|
const sigRef = Ref.get(60, 0);
|
||||||
|
const fieldRef = Ref.get(61, 0);
|
||||||
|
const sigDict = makeSigDict({
|
||||||
|
byteRange: [0, 10, 20, 30],
|
||||||
|
subFilter,
|
||||||
|
});
|
||||||
|
const xref = new XRefMock([
|
||||||
|
{ ref: sigRef, data: sigDict },
|
||||||
|
{
|
||||||
|
ref: fieldRef,
|
||||||
|
data: makeSigField({ T: "sig", sigRef }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
acroForm.set("Fields", [fieldRef]);
|
||||||
|
const pdfDocument = getDocument(acroForm, xref);
|
||||||
|
const [sig] = await pdfDocument.signatures;
|
||||||
|
return sig.signatureType;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await signatureType("adbe.pkcs7.detached")).toEqual(0);
|
||||||
|
expect(await signatureType("adbe.pkcs7.sha1")).toEqual(1);
|
||||||
|
expect(await signatureType("ETSI.CAdES.detached")).toEqual(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should get calculation order array or null", function () {
|
it("should get calculation order array or null", function () {
|
||||||
const acroForm = new Dict();
|
const acroForm = new Dict();
|
||||||
|
|
||||||
|
|||||||
47
web/app.js
47
web/app.js
@ -94,6 +94,7 @@ import { Preferences } from "web-preferences";
|
|||||||
import { RenderingStates } from "./renderable_view.js";
|
import { RenderingStates } from "./renderable_view.js";
|
||||||
import { SecondaryToolbar } from "web-secondary_toolbar";
|
import { SecondaryToolbar } from "web-secondary_toolbar";
|
||||||
import { SignatureManager } from "web-signature_manager";
|
import { SignatureManager } from "web-signature_manager";
|
||||||
|
import { SignaturePropertiesManager } from "web-digital_signature_properties_manager";
|
||||||
import { Toolbar } from "web-toolbar";
|
import { Toolbar } from "web-toolbar";
|
||||||
import { ViewHistory } from "./view_history.js";
|
import { ViewHistory } from "./view_history.js";
|
||||||
import { ViewsManager } from "web-views_manager";
|
import { ViewsManager } from "web-views_manager";
|
||||||
@ -164,6 +165,8 @@ const PDFViewerApplication = {
|
|||||||
l10n: null,
|
l10n: null,
|
||||||
/** @type {AnnotationEditorParams} */
|
/** @type {AnnotationEditorParams} */
|
||||||
annotationEditorParams: null,
|
annotationEditorParams: null,
|
||||||
|
/** @type {SignaturePropertiesManager|null} */
|
||||||
|
signaturePropertiesManager: null,
|
||||||
/** @type {ImageAltTextSettings} */
|
/** @type {ImageAltTextSettings} */
|
||||||
imageAltTextSettings: null,
|
imageAltTextSettings: null,
|
||||||
isInitialViewSet: false,
|
isInitialViewSet: false,
|
||||||
@ -1204,6 +1207,7 @@ const PDFViewerApplication = {
|
|||||||
this.pdfViewer.setDocument(null);
|
this.pdfViewer.setDocument(null);
|
||||||
this.pdfLinkService.setDocument(null);
|
this.pdfLinkService.setDocument(null);
|
||||||
this.pdfDocumentProperties?.setDocument(null);
|
this.pdfDocumentProperties?.setDocument(null);
|
||||||
|
this.signaturePropertiesManager?.reset();
|
||||||
}
|
}
|
||||||
this.pdfLinkService.externalLinkEnabled = true;
|
this.pdfLinkService.externalLinkEnabled = true;
|
||||||
this.store = null;
|
this.store = null;
|
||||||
@ -1869,12 +1873,42 @@ const PDFViewerApplication = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (info.IsSignaturesPresent) {
|
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 });
|
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
|
* @private
|
||||||
*/
|
*/
|
||||||
@ -2999,6 +3033,12 @@ function closeEditorUndoBar(evt) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeSignatureProperties({ target }) {
|
||||||
|
if (this.signaturePropertiesManager?.shouldCloseOnClick(target)) {
|
||||||
|
this.signaturePropertiesManager.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onBeforeUnload(evt) {
|
function onBeforeUnload(evt) {
|
||||||
if (this._hasChanges()) {
|
if (this._hasChanges()) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
@ -3011,6 +3051,7 @@ function onBeforeUnload(evt) {
|
|||||||
function onClick(evt) {
|
function onClick(evt) {
|
||||||
closeSecondaryToolbar.call(this, evt);
|
closeSecondaryToolbar.call(this, evt);
|
||||||
closeEditorUndoBar.call(this, evt);
|
closeEditorUndoBar.call(this, evt);
|
||||||
|
closeSignatureProperties.call(this, evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyUp(evt) {
|
function onKeyUp(evt) {
|
||||||
@ -3226,6 +3267,10 @@ function onKeyDown(evt) {
|
|||||||
this.secondaryToolbar.close();
|
this.secondaryToolbar.close();
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
|
if (this.signaturePropertiesManager?.isOpen) {
|
||||||
|
this.signaturePropertiesManager.close();
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
if (!this.supportsIntegratedFind && this.findBar?.opened) {
|
if (!this.supportsIntegratedFind && this.findBar?.opened) {
|
||||||
this.findBar.close();
|
this.findBar.close();
|
||||||
handled = true;
|
handled = true;
|
||||||
|
|||||||
@ -277,6 +277,15 @@ const defaultOptions = {
|
|||||||
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
|
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
|
||||||
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
|
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: {
|
enableSplitMerge: {
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
|
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
|
||||||
|
|||||||
412
web/digital_signature_properties.css
Normal file
412
web/digital_signature_properties.css
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Digital signature properties panel.
|
||||||
|
*
|
||||||
|
* Floating doorhanger anchored to #signaturePropertiesButton. Lists every
|
||||||
|
* signature in the open PDF as a card, with a banner summarising the
|
||||||
|
* overall verification state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--sig-card-border: light-dark(rgb(228 228 232), rgb(82 82 86));
|
||||||
|
--sig-card-bg: var(--field-bg-color, light-dark(white, rgb(64 64 68)));
|
||||||
|
--sig-card-nested-bg: light-dark(rgb(252 252 253), rgb(72 72 76));
|
||||||
|
--sig-row-color: light-dark(rgb(58 58 60), rgb(228 228 232));
|
||||||
|
--sig-detail-color: light-dark(rgb(96 96 96), rgb(180 180 184));
|
||||||
|
--sig-divider-color: light-dark(rgb(228 228 232), rgb(82 82 86));
|
||||||
|
--sig-summary-hover-color: light-dark(rgb(28 67 138), rgb(126 169 255));
|
||||||
|
--sig-link-color: light-dark(rgb(28 113 216), rgb(126 169 255));
|
||||||
|
--sig-link-hover-bg: light-dark(
|
||||||
|
rgb(28 113 216 / 0.1),
|
||||||
|
rgb(126 169 255 / 0.15)
|
||||||
|
);
|
||||||
|
--sig-banner-verified-bg: light-dark(rgb(228 247 235), rgb(28 84 49));
|
||||||
|
--sig-banner-verified-color: light-dark(rgb(16 92 47), rgb(176 232 196));
|
||||||
|
--sig-banner-warn-bg: light-dark(rgb(255 247 217), rgb(95 67 9));
|
||||||
|
--sig-banner-warn-color: light-dark(rgb(124 84 9), rgb(255 222 153));
|
||||||
|
--sig-banner-error-bg: light-dark(rgb(254 226 235), rgb(122 21 51));
|
||||||
|
--sig-banner-error-color: light-dark(rgb(167 26 70), rgb(255 188 207));
|
||||||
|
|
||||||
|
/* Tint colours for the row / toolbar icons. These are paired with
|
||||||
|
* `mask-image` so the icons recolour for light/dark/HCM. The four
|
||||||
|
* semantics map to: default = grey (signature crypto verified but
|
||||||
|
* not endorsed), warn = orange (cert trust/validity issue),
|
||||||
|
* error = red (signature itself failed or could not be checked),
|
||||||
|
* verified = green (only used for the top-level "everything fine"
|
||||||
|
* row and the toolbar's verified badge). */
|
||||||
|
--sig-icon-default: light-dark(rgb(150 150 150), rgb(180 180 184));
|
||||||
|
--sig-icon-warn: light-dark(rgb(217 142 27), rgb(255 178 77));
|
||||||
|
--sig-icon-error: light-dark(rgb(196 31 71), rgb(255 117 145));
|
||||||
|
--sig-icon-verified: light-dark(rgb(29 142 61), rgb(106 210 126));
|
||||||
|
|
||||||
|
@media screen and (forced-colors: active) {
|
||||||
|
/* HCM keywords are picked by *semantic role*, not by hue — the
|
||||||
|
* user's high-contrast theme resolves them to whatever palette it
|
||||||
|
* ships. The same role-vocabulary as alt-text
|
||||||
|
* (annotation_editor_layer_builder.css:261-275) is used here so
|
||||||
|
* the two panels read consistently:
|
||||||
|
* - ButtonFace / ButtonText: "control surface + text"
|
||||||
|
* (banner background + body text inside it).
|
||||||
|
* - AccentColor: the user's accent — used as a saturated
|
||||||
|
* emphasis foreground for severity icons and the banner's
|
||||||
|
* left stripe (same role alt-text uses for its hover
|
||||||
|
* foreground).
|
||||||
|
* - ButtonBorder: dedicated control-border keyword for the
|
||||||
|
* outer card frame.
|
||||||
|
* - GrayText: muted text (detail rows, divider, default
|
||||||
|
* "everything OK" row icon).
|
||||||
|
* - LinkText: link colour.
|
||||||
|
* Background-typed keywords are never used as foregrounds. */
|
||||||
|
--sig-card-border: ButtonBorder;
|
||||||
|
--sig-card-bg: Canvas;
|
||||||
|
--sig-card-nested-bg: ButtonFace;
|
||||||
|
--sig-row-color: ButtonText;
|
||||||
|
--sig-detail-color: GrayText;
|
||||||
|
--sig-divider-color: GrayText;
|
||||||
|
--sig-summary-hover-color: AccentColor;
|
||||||
|
--sig-link-color: LinkText;
|
||||||
|
--sig-link-hover-bg: transparent;
|
||||||
|
--sig-banner-verified-bg: ButtonFace;
|
||||||
|
--sig-banner-verified-color: ButtonText;
|
||||||
|
--sig-banner-warn-bg: ButtonFace;
|
||||||
|
--sig-banner-warn-color: ButtonText;
|
||||||
|
--sig-banner-error-bg: ButtonFace;
|
||||||
|
--sig-banner-error-color: ButtonText;
|
||||||
|
/* Severities collapse to the same emphasis keyword (AccentColor)
|
||||||
|
* in HCM — same trick as alt-text where `done` and `warning`
|
||||||
|
* share their hover colour. The glyph shape (check vs `!` vs `✕`)
|
||||||
|
* carries the remaining distinction. The neutral "row crypto
|
||||||
|
* verified" check stays muted (GrayText). */
|
||||||
|
--sig-icon-default: GrayText;
|
||||||
|
--sig-icon-warn: AccentColor;
|
||||||
|
--sig-icon-error: AccentColor;
|
||||||
|
--sig-icon-verified: AccentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#signaturePropertiesButton {
|
||||||
|
/* Default (no state class yet): use the regular signature icon.
|
||||||
|
* Mirror under RTL via the shared --dir-factor so the icon stays
|
||||||
|
* visually aligned with the rest of the toolbar (same pattern as
|
||||||
|
* the comment button). The state-* rules below override
|
||||||
|
* `mask-image` only — the transform is inherited. */
|
||||||
|
&::before {
|
||||||
|
mask-image: var(--toolbarButton-editorSignature-icon);
|
||||||
|
transform: scaleX(var(--dir-factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When a verification state is set, switch the mask to the matching
|
||||||
|
* state badge and tint via background-color so the badge inherits
|
||||||
|
* light/dark/HCM via the `--sig-icon-*` vars. */
|
||||||
|
&.state-verified::before,
|
||||||
|
&.state-warn::before,
|
||||||
|
&.state-error::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
&.state-verified::before {
|
||||||
|
mask-image: var(--toolbarButton-signaturePropertiesVerified-icon);
|
||||||
|
background-color: var(--sig-icon-verified);
|
||||||
|
}
|
||||||
|
&.state-warn::before {
|
||||||
|
mask-image: var(--toolbarButton-signaturePropertiesWarn-icon);
|
||||||
|
background-color: var(--sig-icon-warn);
|
||||||
|
}
|
||||||
|
&.state-error::before {
|
||||||
|
mask-image: var(--toolbarButton-signaturePropertiesError-icon);
|
||||||
|
background-color: var(--sig-icon-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state: three .loadingDot spans pulse in sequence via
|
||||||
|
* `animation-delay`. The spans are injected once at construction (see
|
||||||
|
* SignaturePropertiesManager) and are width/height 0 by default thanks
|
||||||
|
* to the `.toolbarButton > span` rule — they only become visible when
|
||||||
|
* this `state-loading` modifier is set. */
|
||||||
|
&.state-loading {
|
||||||
|
&::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.loadingDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
margin: 0 1px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--toolbar-icon-bg-color);
|
||||||
|
animation: signaturePropertiesDot 1.2s ease-in-out infinite both;
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
&:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes signaturePropertiesDot {
|
||||||
|
0%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#signaturePropertiesPanel {
|
||||||
|
width: 320px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signaturePropertiesContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigBanner {
|
||||||
|
margin: 12px 12px 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.35;
|
||||||
|
/* The left stripe reuses the per-severity icon tint so the banner
|
||||||
|
* keeps a meaningful accent in HCM (where the bg/fg both flatten to
|
||||||
|
* Canvas/CanvasText). The icon tint vars are themed semantically
|
||||||
|
* ("danger" / "accent" / "muted"), so the stripe colour follows the
|
||||||
|
* user's high-contrast palette without us hard-coding any hue. */
|
||||||
|
border-inline-start: 3px solid currentcolor;
|
||||||
|
|
||||||
|
&.verified {
|
||||||
|
background: var(--sig-banner-verified-bg);
|
||||||
|
color: var(--sig-banner-verified-color);
|
||||||
|
border-inline-start-color: var(--sig-icon-verified);
|
||||||
|
}
|
||||||
|
&.warn {
|
||||||
|
background: var(--sig-banner-warn-bg);
|
||||||
|
color: var(--sig-banner-warn-color);
|
||||||
|
border-inline-start-color: var(--sig-icon-warn);
|
||||||
|
}
|
||||||
|
&.error {
|
||||||
|
background: var(--sig-banner-error-bg);
|
||||||
|
color: var(--sig-banner-error-color);
|
||||||
|
border-inline-start-color: var(--sig-icon-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signaturePropertiesList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sigCard {
|
||||||
|
border: 1px solid var(--sig-card-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
background: var(--sig-card-bg);
|
||||||
|
|
||||||
|
.signer {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 2px 6px;
|
||||||
|
/* Icon aligns to the first line of multi-line text, not the centre
|
||||||
|
* of the wrapped block. */
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--sig-row-color);
|
||||||
|
min-height: 18px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
/* Allow the span to shrink below its intrinsic min-content width
|
||||||
|
* so long text wraps inside the row instead of pushing the whole
|
||||||
|
* label below the icon. */
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* Keep the icon at the same vertical rhythm as a single line of
|
||||||
|
* text so it visually pairs with the first row of the wrapped
|
||||||
|
* label. */
|
||||||
|
margin-top: 1px;
|
||||||
|
/* The icon shape is a mask-image; the tint comes from
|
||||||
|
* `background-color`, which lets every row icon adapt to
|
||||||
|
* light/dark/HCM via the `--sig-icon-*` vars in `:root`. */
|
||||||
|
mask-position: center;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-image: var(--signatureProperties-rowCheck-icon);
|
||||||
|
background-color: var(--sig-icon-default);
|
||||||
|
/* Mirror under RTL — the per-status modifiers below override
|
||||||
|
* `mask-image` only, so the transform applies uniformly. */
|
||||||
|
transform: scaleX(var(--dir-factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Everything-OK" rows (signature crypto verified, even if the
|
||||||
|
* cert chain is untrusted/expired/etc.) and the trusted-cert row
|
||||||
|
* keep the muted grey check. */
|
||||||
|
&.status--verified::before,
|
||||||
|
&.status--untrusted::before,
|
||||||
|
&.status--expired::before,
|
||||||
|
&.status--revoked::before,
|
||||||
|
&.cert--trusted::before {
|
||||||
|
mask-image: var(--signatureProperties-rowCheck-icon);
|
||||||
|
background-color: var(--sig-icon-default);
|
||||||
|
}
|
||||||
|
&.cert--untrusted::before,
|
||||||
|
&.cert--expired::before {
|
||||||
|
mask-image: var(--toolbarButton-signaturePropertiesWarn-icon);
|
||||||
|
background-color: var(--sig-icon-warn);
|
||||||
|
}
|
||||||
|
&.cert--revoked::before,
|
||||||
|
&.status--invalid::before,
|
||||||
|
&.status--unknown::before,
|
||||||
|
&.cert--unknown::before {
|
||||||
|
mask-image: var(--toolbarButton-signaturePropertiesError-icon);
|
||||||
|
background-color: var(--sig-icon-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Promote to a real green tick only on the top-level card AND only
|
||||||
|
* when every signature in the document is verified. The
|
||||||
|
* `.sigCard--top-allfine` modifier is set in #render. */
|
||||||
|
&.sigCard--top-allfine > .row.status--verified::before,
|
||||||
|
&.sigCard--top-allfine > .row.cert--trusted::before {
|
||||||
|
mask-image: var(--toolbarButton-signaturePropertiesVerified-icon);
|
||||||
|
background-color: var(--sig-icon-verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--sig-detail-color);
|
||||||
|
margin-inline-start: 20px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewCert {
|
||||||
|
align-self: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--sig-link-color);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--sig-link-hover-bg);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--sig-link-color);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subSignatures {
|
||||||
|
margin-top: 4px;
|
||||||
|
border-top: 1px dashed var(--sig-divider-color);
|
||||||
|
padding-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
> summary {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
color: var(--sig-detail-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--sig-summary-hover-color);
|
||||||
|
}
|
||||||
|
&::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
border-inline-start: 5px solid currentcolor;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&[open] > summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nested,
|
||||||
|
.subSignatures > .signaturePropertiesList {
|
||||||
|
padding: 4px 0 2px;
|
||||||
|
margin-inline-start: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nested {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cosmetics for cards nested inside another (sub-)signature. */
|
||||||
|
.subSignatures .sigCard,
|
||||||
|
.nested .sigCard {
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--sig-card-nested-bg);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subSignatures .signer,
|
||||||
|
.nested .signer {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
725
web/digital_signature_properties_manager.js
Normal file
725
web/digital_signature_properties_manager.js
Normal file
@ -0,0 +1,725 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AnnotationEditorType, makeArr, PDFDateString } from "pdfjs-lib";
|
||||||
|
|
||||||
|
// Per-status descriptor keyed by the status code returned by the verifier.
|
||||||
|
// `priority` drives worst-status aggregation, `severity` drives the banner
|
||||||
|
// colour bucket, and the two Fluent IDs are the explicit strings used by
|
||||||
|
// the banner / status rows (no `${status}` template construction so the
|
||||||
|
// IDs are greppable).
|
||||||
|
// The status row only ever shows one of three distinct strings: the
|
||||||
|
// signature crypto verified, the signature is invalid, or the format is
|
||||||
|
// unsupported. Cert chain issues (untrusted / expired / revoked) live
|
||||||
|
// on the cert row, so those statuses still report "Signature verified"
|
||||||
|
// on the status row.
|
||||||
|
const STATUS_ROW_VERIFIED =
|
||||||
|
"pdfjs-digital-signature-properties-status-verified";
|
||||||
|
const STATUS_ROW_INVALID = "pdfjs-digital-signature-properties-status-invalid";
|
||||||
|
const STATUS_ROW_UNKNOWN = "pdfjs-digital-signature-properties-status-unknown";
|
||||||
|
|
||||||
|
const STATUS_INFO = {
|
||||||
|
verified: {
|
||||||
|
priority: 0,
|
||||||
|
severity: "verified",
|
||||||
|
bannerId: "pdfjs-digital-signature-properties-banner-verified",
|
||||||
|
statusId: STATUS_ROW_VERIFIED,
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
priority: 1,
|
||||||
|
severity: "error",
|
||||||
|
bannerId: "pdfjs-digital-signature-properties-banner-unknown",
|
||||||
|
statusId: STATUS_ROW_UNKNOWN,
|
||||||
|
},
|
||||||
|
untrusted: {
|
||||||
|
priority: 2,
|
||||||
|
severity: "warn",
|
||||||
|
bannerId: "pdfjs-digital-signature-properties-banner-untrusted",
|
||||||
|
statusId: STATUS_ROW_VERIFIED,
|
||||||
|
},
|
||||||
|
expired: {
|
||||||
|
priority: 3,
|
||||||
|
severity: "warn",
|
||||||
|
bannerId: "pdfjs-digital-signature-properties-banner-expired",
|
||||||
|
statusId: STATUS_ROW_VERIFIED,
|
||||||
|
},
|
||||||
|
revoked: {
|
||||||
|
priority: 4,
|
||||||
|
severity: "error",
|
||||||
|
bannerId: "pdfjs-digital-signature-properties-banner-revoked",
|
||||||
|
statusId: STATUS_ROW_VERIFIED,
|
||||||
|
},
|
||||||
|
invalid: {
|
||||||
|
priority: 5,
|
||||||
|
severity: "error",
|
||||||
|
bannerId: "pdfjs-digital-signature-properties-banner-invalid",
|
||||||
|
statusId: STATUS_ROW_INVALID,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CERT_L10N_IDS = {
|
||||||
|
trusted: "pdfjs-digital-signature-properties-certificate-trusted",
|
||||||
|
unknown: "pdfjs-digital-signature-properties-certificate-unknown",
|
||||||
|
untrusted: "pdfjs-digital-signature-properties-certificate-untrusted",
|
||||||
|
expired: "pdfjs-digital-signature-properties-certificate-expired",
|
||||||
|
revoked: "pdfjs-digital-signature-properties-certificate-revoked",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CERT_EXPIRED_WITH_DATE_L10N_ID =
|
||||||
|
"pdfjs-digital-signature-properties-certificate-expired-with-date";
|
||||||
|
|
||||||
|
function bannerStateForResults(results) {
|
||||||
|
if (results.length === 0) {
|
||||||
|
return { worst: "unknown", severity: "error", count: 0 };
|
||||||
|
}
|
||||||
|
let worst = "verified";
|
||||||
|
for (const r of results) {
|
||||||
|
if (
|
||||||
|
r &&
|
||||||
|
r.status &&
|
||||||
|
STATUS_INFO[r.status].priority > STATUS_INFO[worst].priority
|
||||||
|
) {
|
||||||
|
worst = r.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Count how many signatures are at the worst level — this drives the
|
||||||
|
// singular/plural variant of the banner message.
|
||||||
|
let count = 0;
|
||||||
|
for (const r of results) {
|
||||||
|
if (r?.status === worst) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { worst, severity: STATUS_INFO[worst].severity, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For an `untrusted` certificate, pick the most specific Fluent label.
|
||||||
|
// When the error code matches one of the recognised cases we have a
|
||||||
|
// structured "Certificate: <reason> (<issuer>)" string; otherwise we
|
||||||
|
// fall back to the bare "Certificate: Untrusted".
|
||||||
|
function untrustedCertLabel(errorCode, issuerCN) {
|
||||||
|
const code = (errorCode || "").toUpperCase();
|
||||||
|
const args = issuerCN ? { issuer: issuerCN } : null;
|
||||||
|
if (code.includes("UNKNOWN_ISSUER") && args) {
|
||||||
|
return {
|
||||||
|
id: "pdfjs-digital-signature-properties-certificate-untrusted-unknown-issuer",
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (code.includes("SELF_SIGNED") && args) {
|
||||||
|
return {
|
||||||
|
id: "pdfjs-digital-signature-properties-certificate-untrusted-self-signed",
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (code.includes("UNTRUSTED_ISSUER") && args) {
|
||||||
|
return {
|
||||||
|
id: "pdfjs-digital-signature-properties-certificate-untrusted-untrusted-issuer",
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: "pdfjs-digital-signature-properties-certificate-untrusted",
|
||||||
|
args: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For an `expired` certificate: NSS may have flagged either the leaf
|
||||||
|
// (SEC_ERROR_EXPIRED_CERTIFICATE) or any issuer up the chain
|
||||||
|
// (SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE). We want the parenthetical
|
||||||
|
// to show the date that actually expired, so walk leaf + chain and
|
||||||
|
// return the first notAfter that is already in the past as a Date.
|
||||||
|
// If nothing is in the past we return null and the caller renders the
|
||||||
|
// generic "Certificate: Expired" label without a date.
|
||||||
|
function expirationDateForCert(cert) {
|
||||||
|
if (!cert) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const entries =
|
||||||
|
Array.isArray(cert.chain) && cert.chain.length ? cert.chain : [cert];
|
||||||
|
// Return the first past notAfter in the chain — that's the cert that
|
||||||
|
// actually caused the "expired" verdict. We deliberately do NOT fall
|
||||||
|
// back to a future date if no past date is found: NSS sometimes does
|
||||||
|
// not surface the expired issuer in `signerCertificate.issuer`, in
|
||||||
|
// which case the chain only has the leaf with a still-valid notAfter.
|
||||||
|
// Showing that as "Expired (Jan 1, 2027)" would be misleading, so the
|
||||||
|
// caller renders the dateless label instead.
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (typeof entry?.notAfter !== "string" || !entry.notAfter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const date = new Date(entry.notAfter);
|
||||||
|
const ts = date.getTime();
|
||||||
|
if (Number.isFinite(ts) && ts < now) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SignaturePropertiesManager {
|
||||||
|
#appConfig;
|
||||||
|
|
||||||
|
#verifier;
|
||||||
|
|
||||||
|
#eventBus;
|
||||||
|
|
||||||
|
#signatures = [];
|
||||||
|
|
||||||
|
#results = new Map(); // signatureId -> VerificationResult
|
||||||
|
|
||||||
|
#pendingVerify = new Set(); // signatureId set, in-flight
|
||||||
|
|
||||||
|
#isOpen = false;
|
||||||
|
|
||||||
|
#isLoading = false;
|
||||||
|
|
||||||
|
// Set whenever state changes while the panel is closed, so that opening it
|
||||||
|
// forces a fresh render. While the panel is hidden, building the list /
|
||||||
|
// banner DOM is pure churn — only the toolbar button is visible, and that
|
||||||
|
// is updated via #updateButtonState().
|
||||||
|
#needsRender = false;
|
||||||
|
|
||||||
|
// Identifies the current `loadFromDocument` call. Each load rotates the
|
||||||
|
// token; in-flight `#verify()` promises capture the value at start and
|
||||||
|
// bail on resolve if the manager has since switched to another document.
|
||||||
|
// Prevents a stale verification for doc A from writing into doc B's
|
||||||
|
// results map after a fast doc-switch.
|
||||||
|
#loadToken = null;
|
||||||
|
|
||||||
|
// Held only to ferry per-signature byte payloads from the worker into
|
||||||
|
// `#verify`. Cleared in `reset()` so we don't pin a closed document.
|
||||||
|
#pdfDocument = null;
|
||||||
|
|
||||||
|
constructor({ appConfig, verifier, eventBus }) {
|
||||||
|
this.#appConfig = appConfig;
|
||||||
|
this.#verifier = verifier;
|
||||||
|
this.#eventBus = eventBus;
|
||||||
|
|
||||||
|
const button = appConfig.signaturePropertiesButton;
|
||||||
|
// Loading dots: three real spans (hidden by `.toolbarButton > span`)
|
||||||
|
// that the `state-loading` CSS modifier turns into pulsing circles
|
||||||
|
// with staggered `animation-delay`. Real elements (not gradient
|
||||||
|
// keyframes) let each dot animate independently.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const dot = document.createElement("span");
|
||||||
|
dot.className = "loadingDot";
|
||||||
|
button.append(dot);
|
||||||
|
}
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
this.#toggle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean} `true` while the doorhanger is visible.
|
||||||
|
*/
|
||||||
|
get isOpen() {
|
||||||
|
return this.#isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the doorhanger if it is open. The viewer's existing Escape
|
||||||
|
* handler and outside-click logic call this — the manager doesn't
|
||||||
|
* register its own document-level listeners.
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (this.#isOpen) {
|
||||||
|
this.#close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Element} target Click target. The viewer's outside-click
|
||||||
|
* handler uses this to decide whether to close the panel.
|
||||||
|
* @returns {boolean} `true` if the click is outside both the toolbar
|
||||||
|
* button and the doorhanger and the panel should be closed.
|
||||||
|
*/
|
||||||
|
shouldCloseOnClick(target) {
|
||||||
|
if (!this.#isOpen) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !(
|
||||||
|
this.#appConfig.signaturePropertiesButton.contains(target) ||
|
||||||
|
this.#appConfig.signaturePropertiesPanel.contains(target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFromDocument(pdfDocument) {
|
||||||
|
const token = Symbol("sig-load");
|
||||||
|
this.#loadToken = token;
|
||||||
|
this.#pdfDocument = pdfDocument;
|
||||||
|
this.#signatures = [];
|
||||||
|
this.#results.clear();
|
||||||
|
this.#pendingVerify.clear();
|
||||||
|
this.#isLoading = true;
|
||||||
|
this.#render();
|
||||||
|
|
||||||
|
let signatures;
|
||||||
|
try {
|
||||||
|
signatures = await pdfDocument.getSignatures();
|
||||||
|
} catch (ex) {
|
||||||
|
console.warn("getSignatures failed:", ex);
|
||||||
|
signatures = [];
|
||||||
|
}
|
||||||
|
if (this.#loadToken !== token) {
|
||||||
|
// A newer document load (or reset) raced ahead — drop this result.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#signatures = signatures || [];
|
||||||
|
this.#isLoading = false;
|
||||||
|
|
||||||
|
if (this.#signatures.length === 0) {
|
||||||
|
this.#hideButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#showButton();
|
||||||
|
|
||||||
|
// Seed each signature with an "unknown" placeholder result so the
|
||||||
|
// banner / badge / cards have something to render while the worker
|
||||||
|
// verifies them in the background.
|
||||||
|
for (const sig of this.#signatures) {
|
||||||
|
this.#results.set(sig.id, {
|
||||||
|
status: "unknown",
|
||||||
|
errorCode: null,
|
||||||
|
message: null,
|
||||||
|
certificate: null,
|
||||||
|
documentModifiedAfterSigning: !sig.coversWholeDocument,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.#render();
|
||||||
|
this.#updateButtonState();
|
||||||
|
// Kick off verification automatically — the toolbar button reflects the
|
||||||
|
// aggregate state and updates as each signature resolves.
|
||||||
|
for (const sig of this.#signatures) {
|
||||||
|
this.#verify(sig, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
// Rotate the token so any in-flight verify from the previous document
|
||||||
|
// sees a mismatch and bails before mutating state.
|
||||||
|
this.#loadToken = null;
|
||||||
|
this.#pdfDocument = null;
|
||||||
|
this.#signatures = [];
|
||||||
|
this.#results.clear();
|
||||||
|
this.#pendingVerify.clear();
|
||||||
|
this.#needsRender = false;
|
||||||
|
this.#hideButton();
|
||||||
|
this.#close();
|
||||||
|
this.#updateButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internal ---
|
||||||
|
|
||||||
|
#showButton() {
|
||||||
|
const root = this.#appConfig.signaturePropertiesButton.parentElement;
|
||||||
|
if (root) {
|
||||||
|
root.hidden = false;
|
||||||
|
}
|
||||||
|
// The separator only makes sense when our toolbar group is visible.
|
||||||
|
const sep = this.#appConfig.signaturePropertiesSeparator;
|
||||||
|
if (sep) {
|
||||||
|
sep.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#hideButton() {
|
||||||
|
const root = this.#appConfig.signaturePropertiesButton.parentElement;
|
||||||
|
if (root) {
|
||||||
|
root.hidden = true;
|
||||||
|
}
|
||||||
|
const sep = this.#appConfig.signaturePropertiesSeparator;
|
||||||
|
if (sep) {
|
||||||
|
sep.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#toggle() {
|
||||||
|
if (this.#isOpen) {
|
||||||
|
this.#close();
|
||||||
|
} else {
|
||||||
|
this.#open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#open() {
|
||||||
|
this.#isOpen = true;
|
||||||
|
// Close any other open editor doorhanger (Ink, FreeText, Highlight, …)
|
||||||
|
// and the find bar / secondary toolbar via global onClick — same pattern
|
||||||
|
// the Comment doorhanger uses.
|
||||||
|
this.#eventBus?.dispatch("switchannotationeditormode", {
|
||||||
|
source: this,
|
||||||
|
mode: AnnotationEditorType.NONE,
|
||||||
|
});
|
||||||
|
this.#eventBus?.dispatch("findbarclose", { source: this });
|
||||||
|
this.#appConfig.signaturePropertiesPanel.classList.remove("hidden");
|
||||||
|
this.#appConfig.signaturePropertiesButton.setAttribute(
|
||||||
|
"aria-expanded",
|
||||||
|
"true"
|
||||||
|
);
|
||||||
|
if (this.#needsRender) {
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#close() {
|
||||||
|
this.#isOpen = false;
|
||||||
|
this.#appConfig.signaturePropertiesPanel.classList.add("hidden");
|
||||||
|
this.#appConfig.signaturePropertiesButton.setAttribute(
|
||||||
|
"aria-expanded",
|
||||||
|
"false"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
if (!this.#isOpen) {
|
||||||
|
// Defer DOM work until the user actually opens the panel.
|
||||||
|
this.#needsRender = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#needsRender = false;
|
||||||
|
const list = this.#appConfig.signaturePropertiesList;
|
||||||
|
const banner = this.#appConfig.signaturePropertiesBanner;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
if (this.#isLoading) {
|
||||||
|
// The worker call that populates `#signatures` resolves in tens of
|
||||||
|
// milliseconds — fast enough that we'd rather show nothing than
|
||||||
|
// flash a placeholder. The toolbar button's loading-dot animation
|
||||||
|
// already conveys "work in progress".
|
||||||
|
banner.hidden = true;
|
||||||
|
list.replaceChildren();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banner.
|
||||||
|
const { worst, severity, count } = bannerStateForResults([
|
||||||
|
...this.#results.values(),
|
||||||
|
]);
|
||||||
|
banner.replaceChildren();
|
||||||
|
banner.hidden = false;
|
||||||
|
banner.className = `sigBanner ${severity}`;
|
||||||
|
banner.setAttribute("data-l10n-id", STATUS_INFO[worst].bannerId);
|
||||||
|
banner.setAttribute("data-l10n-args", JSON.stringify({ count }));
|
||||||
|
|
||||||
|
// Group sub-signatures under their parent.
|
||||||
|
const byParent = new Map();
|
||||||
|
const topLevel = [];
|
||||||
|
for (const sig of this.#signatures) {
|
||||||
|
if (sig.parentId) {
|
||||||
|
byParent.getOrInsertComputed(sig.parentId, makeArr).push(sig);
|
||||||
|
} else {
|
||||||
|
topLevel.push(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Green icons are reserved for the top-level card when *every*
|
||||||
|
// signature in the document is verified. Anywhere else (any
|
||||||
|
// sub-signature, or a top-level when something further down is
|
||||||
|
// expired/untrusted/etc.) keeps the muted grey check.
|
||||||
|
const everythingFine = severity === "verified";
|
||||||
|
|
||||||
|
for (const sig of topLevel) {
|
||||||
|
fragment.append(
|
||||||
|
this.#renderCard(sig, byParent, /* depth = */ 0, everythingFine)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
list.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderCard(sig, byParent, depth, everythingFine) {
|
||||||
|
const subs = byParent.get(sig.id) || [];
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.classList.add("sigCard");
|
||||||
|
if (depth === 0 && everythingFine) {
|
||||||
|
li.classList.add("sigCard--top-allfine");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.#results.get(sig.id);
|
||||||
|
|
||||||
|
const subjectCN = result?.certificate?.subjectCN;
|
||||||
|
if (subjectCN) {
|
||||||
|
const signer = document.createElement("div");
|
||||||
|
signer.className = "signer";
|
||||||
|
signer.textContent = subjectCN;
|
||||||
|
li.append(signer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status row.
|
||||||
|
const statusRow = document.createElement("div");
|
||||||
|
statusRow.classList.add("row", `status--${result.status}`);
|
||||||
|
const statusLabel = document.createElement("span");
|
||||||
|
statusLabel.setAttribute(
|
||||||
|
"data-l10n-id",
|
||||||
|
STATUS_INFO[result.status].statusId
|
||||||
|
);
|
||||||
|
statusRow.append(statusLabel);
|
||||||
|
li.append(statusRow);
|
||||||
|
|
||||||
|
if (result.status === "invalid" && result.message) {
|
||||||
|
const reason = document.createElement("div");
|
||||||
|
reason.className = "detail";
|
||||||
|
reason.setAttribute(
|
||||||
|
"data-l10n-id",
|
||||||
|
"pdfjs-digital-signature-properties-reason"
|
||||||
|
);
|
||||||
|
reason.setAttribute(
|
||||||
|
"data-l10n-args",
|
||||||
|
JSON.stringify({ reason: result.message })
|
||||||
|
);
|
||||||
|
li.append(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certificate row — skipped when the signature itself is invalid:
|
||||||
|
// NSS doesn't return a signerCertificate in that case, so the row
|
||||||
|
// would be empty noise next to the more informative "Reason: …"
|
||||||
|
// line.
|
||||||
|
const cert = result.certificate;
|
||||||
|
let certKind = "unknown";
|
||||||
|
if (cert) {
|
||||||
|
switch (result.status) {
|
||||||
|
case "verified":
|
||||||
|
certKind = "trusted";
|
||||||
|
break;
|
||||||
|
case "expired":
|
||||||
|
certKind = "expired";
|
||||||
|
break;
|
||||||
|
case "revoked":
|
||||||
|
certKind = "revoked";
|
||||||
|
break;
|
||||||
|
case "untrusted":
|
||||||
|
certKind = "untrusted";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
certKind = "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.status !== "invalid") {
|
||||||
|
const certRow = document.createElement("div");
|
||||||
|
certRow.classList.add("row", `cert--${certKind}`);
|
||||||
|
const certLabel = document.createElement("span");
|
||||||
|
let l10nId = CERT_L10N_IDS[certKind];
|
||||||
|
let l10nArgs = null;
|
||||||
|
if (cert?.issuerCN && certKind === "trusted") {
|
||||||
|
l10nArgs = { issuer: cert.issuerCN };
|
||||||
|
} else if (certKind === "expired") {
|
||||||
|
// For expired, the parenthetical is the expiration date itself
|
||||||
|
// (could be the leaf or any issuer up the chain). Pass a Date
|
||||||
|
// through Fluent so the viewer locale formats it, not the
|
||||||
|
// browser locale.
|
||||||
|
const date = expirationDateForCert(cert);
|
||||||
|
if (date) {
|
||||||
|
l10nId = CERT_EXPIRED_WITH_DATE_L10N_ID;
|
||||||
|
l10nArgs = { dateObj: date.valueOf() };
|
||||||
|
}
|
||||||
|
} else if (certKind === "untrusted") {
|
||||||
|
const label = untrustedCertLabel(result.errorCode, cert?.issuerCN);
|
||||||
|
l10nId = label.id;
|
||||||
|
l10nArgs = label.args;
|
||||||
|
}
|
||||||
|
certLabel.setAttribute("data-l10n-id", l10nId);
|
||||||
|
if (l10nArgs) {
|
||||||
|
certLabel.setAttribute("data-l10n-args", JSON.stringify(l10nArgs));
|
||||||
|
}
|
||||||
|
certRow.append(certLabel);
|
||||||
|
li.append(certRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === "untrusted" && result.message) {
|
||||||
|
const detail = document.createElement("div");
|
||||||
|
detail.className = "detail";
|
||||||
|
detail.textContent = result.message;
|
||||||
|
li.append(detail);
|
||||||
|
}
|
||||||
|
if (result.status === "expired" && result.message) {
|
||||||
|
const detail = document.createElement("div");
|
||||||
|
detail.className = "detail";
|
||||||
|
detail.textContent = result.message;
|
||||||
|
li.append(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signingDate = PDFDateString.toDateObject(sig.signingTime);
|
||||||
|
if (signingDate) {
|
||||||
|
const ts = document.createElement("div");
|
||||||
|
ts.className = "detail";
|
||||||
|
ts.setAttribute(
|
||||||
|
"data-l10n-id",
|
||||||
|
"pdfjs-digital-signature-properties-timestamp"
|
||||||
|
);
|
||||||
|
ts.setAttribute(
|
||||||
|
"data-l10n-args",
|
||||||
|
JSON.stringify({ dateObj: signingDate.valueOf() })
|
||||||
|
);
|
||||||
|
li.append(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cert && typeof this.#verifier?.viewCertificate === "function") {
|
||||||
|
const viewCert = document.createElement("button");
|
||||||
|
viewCert.className = "viewCert";
|
||||||
|
viewCert.type = "button";
|
||||||
|
viewCert.setAttribute(
|
||||||
|
"data-l10n-id",
|
||||||
|
"pdfjs-digital-signature-properties-view-certificate"
|
||||||
|
);
|
||||||
|
viewCert.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.#verifier.viewCertificate(cert);
|
||||||
|
});
|
||||||
|
li.append(viewCert);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subs.length > 0) {
|
||||||
|
const subList = document.createElement("ul");
|
||||||
|
subList.classList.add("signaturePropertiesList", "nested");
|
||||||
|
for (const sub of subs) {
|
||||||
|
subList.append(
|
||||||
|
this.#renderCard(sub, byParent, depth + 1, everythingFine)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth === 0) {
|
||||||
|
// Only the top-level card gets the collapsible header. Deeper
|
||||||
|
// signatures are always rendered inline; the nested border + indent
|
||||||
|
// already shows the parent→child relationship.
|
||||||
|
const details = document.createElement("details");
|
||||||
|
details.className = "subSignatures";
|
||||||
|
details.open = true;
|
||||||
|
const summary = document.createElement("summary");
|
||||||
|
summary.setAttribute(
|
||||||
|
"data-l10n-id",
|
||||||
|
"pdfjs-digital-signature-properties-sub-signatures"
|
||||||
|
);
|
||||||
|
summary.setAttribute(
|
||||||
|
"data-l10n-args",
|
||||||
|
JSON.stringify({ count: this.#countDescendants(sig.id, byParent) })
|
||||||
|
);
|
||||||
|
details.append(summary);
|
||||||
|
details.append(subList);
|
||||||
|
li.append(details);
|
||||||
|
} else {
|
||||||
|
li.append(subList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: per-card progress is already conveyed via the toolbar
|
||||||
|
// button's loading-dot animation (state-loading class) — we used to
|
||||||
|
// drop an additional pulsing bar at the bottom of each in-flight
|
||||||
|
// card but it was visual noise without clear meaning.
|
||||||
|
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
#countDescendants(id, byParent) {
|
||||||
|
const direct = byParent.get(id);
|
||||||
|
if (!direct) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let total = direct.length;
|
||||||
|
for (const sub of direct) {
|
||||||
|
total += this.#countDescendants(sub.id, byParent);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #verify(signature, loadToken) {
|
||||||
|
if (!this.#verifier || this.#pendingVerify.has(signature.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#pendingVerify.add(signature.id);
|
||||||
|
this.#render();
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
// Fetch the signature's byte payload (pkcs7 + signed-data spans)
|
||||||
|
// lazily, one signature at a time, so the bytes never sit in main
|
||||||
|
// thread memory for the document's lifetime. `bytes` goes out of
|
||||||
|
// scope as soon as the verifier returns.
|
||||||
|
const bytes = await this.#pdfDocument?.getSignatureData(signature.id);
|
||||||
|
if (this.#loadToken !== loadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bytes) {
|
||||||
|
throw new Error("missing signature data");
|
||||||
|
}
|
||||||
|
result = await this.#verifier.verify({ ...signature, ...bytes });
|
||||||
|
} catch (ex) {
|
||||||
|
console.warn("signature verify failed:", ex);
|
||||||
|
result = {
|
||||||
|
status: "unknown",
|
||||||
|
errorCode: "BRIDGE_ERROR",
|
||||||
|
message: ex?.message ?? null,
|
||||||
|
certificate: null,
|
||||||
|
documentModifiedAfterSigning: !signature.coversWholeDocument,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.#pendingVerify.delete(signature.id);
|
||||||
|
if (this.#loadToken !== loadToken) {
|
||||||
|
// The user switched documents while this verify was in flight; the
|
||||||
|
// result belongs to a defunct load and would corrupt the new doc.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#results.set(signature.id, result);
|
||||||
|
this.#render();
|
||||||
|
this.#updateButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateButtonState() {
|
||||||
|
const button = this.#appConfig.signaturePropertiesButton;
|
||||||
|
button.classList.remove(
|
||||||
|
"state-loading",
|
||||||
|
"state-verified",
|
||||||
|
"state-warn",
|
||||||
|
"state-error"
|
||||||
|
);
|
||||||
|
if (this.#signatures.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.#pendingVerify.size > 0) {
|
||||||
|
button.classList.add("state-loading");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let worst = "verified";
|
||||||
|
for (const r of this.#results.values()) {
|
||||||
|
if (!r) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (STATUS_INFO[r.status].priority > STATUS_INFO[worst].priority) {
|
||||||
|
worst = r.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (worst) {
|
||||||
|
case "invalid":
|
||||||
|
case "revoked":
|
||||||
|
case "unknown":
|
||||||
|
// `unknown` means the verifier completed but could not give a
|
||||||
|
// definitive answer (unsupported subfilter, bridge error,
|
||||||
|
// CMS NOT_YET_ATTEMPTED). Treat that as a verification failure
|
||||||
|
// — the loading dots are reserved for the in-flight case
|
||||||
|
// handled above.
|
||||||
|
button.classList.add("state-error");
|
||||||
|
break;
|
||||||
|
case "expired":
|
||||||
|
case "untrusted":
|
||||||
|
button.classList.add("state-warn");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
button.classList.add("state-verified");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SignaturePropertiesManager };
|
||||||
@ -48,6 +48,28 @@ class BaseExternalServices {
|
|||||||
throw new Error("Not implemented: createSignatureStorage");
|
throw new Error("Not implemented: createSignatureStorage");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a signature verifier for the Digital signature properties panel.
|
||||||
|
*
|
||||||
|
* The MOZCENTRAL build returns a verifier that calls into NSS via
|
||||||
|
* the chrome bridge. The default GENERIC implementation returns
|
||||||
|
* `null` — there is no portable cryptographic verification path
|
||||||
|
* outside Firefox, so the toolbar button stays hidden and the
|
||||||
|
* worker is never asked for `getSignatures()`.
|
||||||
|
*
|
||||||
|
* Downstream consumers of `pdfjs-dist` that want the Signature
|
||||||
|
* Properties UI should subclass `BaseExternalServices` and return
|
||||||
|
* an object exposing `verify(signature)` (and optionally
|
||||||
|
* `viewCertificate(certificate)`) that resolves to a
|
||||||
|
* `VerificationResult` — see `web/firefoxcom.js` for the exact
|
||||||
|
* shape.
|
||||||
|
*
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
createSignatureVerifier() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
updateEditorStates(data) {
|
updateEditorStates(data) {
|
||||||
throw new Error("Not implemented: updateEditorStates");
|
throw new Error("Not implemented: updateEditorStates");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -532,6 +532,140 @@ class SignatureStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `nsresult` codes from PSM/NSS that we recognize. Names mirror
|
||||||
|
// Firefox's nsINSSErrorsService and security/nss codes; values aren't
|
||||||
|
// stable, so we compare strings instead of integers.
|
||||||
|
const NSS_ERR_CODES = {
|
||||||
|
EXPIRED: new Set([
|
||||||
|
"SEC_ERROR_EXPIRED_CERTIFICATE",
|
||||||
|
"SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE",
|
||||||
|
"MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE",
|
||||||
|
"MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE",
|
||||||
|
]),
|
||||||
|
// Only definite-revoked codes belong here. We intentionally do NOT
|
||||||
|
// include OCSP-response-missing-style codes (e.g.
|
||||||
|
// MOZILLA_PKIX_ERROR_OCSP_RESPONSE_FOR_CERT_MISSING), since those
|
||||||
|
// mean "we couldn't reach the responder" — they fall through to the
|
||||||
|
// generic untrusted bucket.
|
||||||
|
REVOKED: new Set([
|
||||||
|
"SEC_ERROR_REVOKED_CERTIFICATE",
|
||||||
|
"SEC_ERROR_REVOKED_KEY",
|
||||||
|
"MOZILLA_PKIX_ERROR_REVOKED_CERTIFICATE",
|
||||||
|
]),
|
||||||
|
UNTRUSTED: new Set([
|
||||||
|
"SEC_ERROR_UNKNOWN_ISSUER",
|
||||||
|
"SEC_ERROR_UNTRUSTED_CERT",
|
||||||
|
"SEC_ERROR_UNTRUSTED_ISSUER",
|
||||||
|
"MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT",
|
||||||
|
"MOZILLA_PKIX_ERROR_ADDITIONAL_POLICY_CONSTRAINT_FAILED",
|
||||||
|
]),
|
||||||
|
CMS_NOT_YET_ATTEMPTED: "NS_ERROR_CMS_VERIFY_NOT_YET_ATTEMPTED",
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapVerificationStatus(signatureCode, certificateCode) {
|
||||||
|
if (signatureCode === NSS_ERR_CODES.CMS_NOT_YET_ATTEMPTED) {
|
||||||
|
return { status: "unknown", errorCode: signatureCode };
|
||||||
|
}
|
||||||
|
if (signatureCode && signatureCode !== "NS_OK") {
|
||||||
|
return { status: "invalid", errorCode: signatureCode };
|
||||||
|
}
|
||||||
|
if (!certificateCode || certificateCode === "NS_OK") {
|
||||||
|
return { status: "verified", errorCode: null };
|
||||||
|
}
|
||||||
|
if (NSS_ERR_CODES.REVOKED.has(certificateCode)) {
|
||||||
|
return { status: "revoked", errorCode: certificateCode };
|
||||||
|
}
|
||||||
|
if (NSS_ERR_CODES.EXPIRED.has(certificateCode)) {
|
||||||
|
return { status: "expired", errorCode: certificateCode };
|
||||||
|
}
|
||||||
|
if (NSS_ERR_CODES.UNTRUSTED.has(certificateCode)) {
|
||||||
|
return { status: "untrusted", errorCode: certificateCode };
|
||||||
|
}
|
||||||
|
return { status: "untrusted", errorCode: certificateCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
class SignatureVerifier {
|
||||||
|
async verify(signature) {
|
||||||
|
if (signature.signatureType === null) {
|
||||||
|
return {
|
||||||
|
status: "unknown",
|
||||||
|
errorCode: "SUBFILTER_NOT_SUPPORTED",
|
||||||
|
message: signature.subFilter,
|
||||||
|
certificate: null,
|
||||||
|
documentModifiedAfterSigning: !signature.coversWholeDocument,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await FirefoxCom.requestAsync("verifyPdfSignature", {
|
||||||
|
pkcs7: signature.pkcs7,
|
||||||
|
data: signature.data,
|
||||||
|
signatureType: signature.signatureType,
|
||||||
|
});
|
||||||
|
} catch (ex) {
|
||||||
|
return {
|
||||||
|
status: "unknown",
|
||||||
|
errorCode: "BRIDGE_ERROR",
|
||||||
|
message: ex?.message ?? null,
|
||||||
|
certificate: null,
|
||||||
|
documentModifiedAfterSigning: !signature.coversWholeDocument,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!response || response.error) {
|
||||||
|
return {
|
||||||
|
status: "unknown",
|
||||||
|
errorCode: response?.error ?? "EMPTY_RESPONSE",
|
||||||
|
message: null,
|
||||||
|
certificate: null,
|
||||||
|
documentModifiedAfterSigning: !signature.coversWholeDocument,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chrome side returns an Array<nsIPDFVerificationResult>, but for
|
||||||
|
// a single PKCS#7 input it has exactly one entry.
|
||||||
|
const entry = Array.isArray(response) ? response[0] : response;
|
||||||
|
if (!entry) {
|
||||||
|
return {
|
||||||
|
status: "unknown",
|
||||||
|
errorCode: "EMPTY_RESPONSE",
|
||||||
|
message: null,
|
||||||
|
certificate: null,
|
||||||
|
documentModifiedAfterSigning: !signature.coversWholeDocument,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { status, errorCode } = mapVerificationStatus(
|
||||||
|
entry.signatureResult,
|
||||||
|
entry.certificateResult
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
errorCode,
|
||||||
|
message: entry.message ?? null,
|
||||||
|
certificate: entry.certificate ?? null,
|
||||||
|
documentModifiedAfterSigning: !signature.coversWholeDocument,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async viewCertificate(certificate) {
|
||||||
|
if (!certificate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const certs =
|
||||||
|
Array.isArray(certificate.chain) && certificate.chain.length
|
||||||
|
? certificate.chain.map(c => c.derBase64).filter(Boolean)
|
||||||
|
: [certificate.derBase64].filter(Boolean);
|
||||||
|
if (certs.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await FirefoxCom.requestAsync("viewPdfCertificate", { certs });
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ExternalServices extends BaseExternalServices {
|
class ExternalServices extends BaseExternalServices {
|
||||||
updateFindControlState(data) {
|
updateFindControlState(data) {
|
||||||
FirefoxCom.request("updateFindControlState", data);
|
FirefoxCom.request("updateFindControlState", data);
|
||||||
@ -623,6 +757,10 @@ class ExternalServices extends BaseExternalServices {
|
|||||||
return new SignatureStorage(eventBus, signal);
|
return new SignatureStorage(eventBus, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createSignatureVerifier() {
|
||||||
|
return new SignatureVerifier();
|
||||||
|
}
|
||||||
|
|
||||||
dispatchGlobalEvent(event) {
|
dispatchGlobalEvent(event) {
|
||||||
FirefoxCom.request("dispatchGlobalEvent", event);
|
FirefoxCom.request("dispatchGlobalEvent", event);
|
||||||
}
|
}
|
||||||
|
|||||||
4
web/images/signature-properties-row-check.svg
Normal file
4
web/images/signature-properties-row-check.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8" cy="8" r="7" stroke="black" stroke-width="1" fill="none"/>
|
||||||
|
<path d="M5 8.4l2.4 2.4L11.6 6" stroke="black" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 296 B |
7
web/images/toolbarButton-signaturePropertiesError.svg
Normal file
7
web/images/toolbarButton-signaturePropertiesError.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="m">
|
||||||
|
<rect width="16" height="16" fill="white"/>
|
||||||
|
<path d="M5.2 5.2l5.6 5.6M10.8 5.2l-5.6 5.6" stroke="black" stroke-width="1.8" fill="none" stroke-linecap="round"/>
|
||||||
|
</mask>
|
||||||
|
<circle cx="8" cy="8" r="7" fill="black" mask="url(#m)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 345 B |
7
web/images/toolbarButton-signaturePropertiesVerified.svg
Normal file
7
web/images/toolbarButton-signaturePropertiesVerified.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="m">
|
||||||
|
<rect width="16" height="16" fill="white"/>
|
||||||
|
<path d="M4.5 8.4l2.4 2.4L11.6 6" stroke="black" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</mask>
|
||||||
|
<circle cx="8" cy="8" r="7" fill="black" mask="url(#m)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 356 B |
7
web/images/toolbarButton-signaturePropertiesWarn.svg
Normal file
7
web/images/toolbarButton-signaturePropertiesWarn.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="m">
|
||||||
|
<rect width="16" height="16" fill="white"/>
|
||||||
|
<path d="M5.2 5.2l5.6 5.6M10.8 5.2l-5.6 5.6" stroke="black" stroke-width="1.8" fill="none" stroke-linecap="round"/>
|
||||||
|
</mask>
|
||||||
|
<circle cx="8" cy="8" r="7" fill="black" mask="url(#m)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 345 B |
@ -27,6 +27,7 @@ const PDFPresentationMode = null;
|
|||||||
const PDFThumbnailViewer = null;
|
const PDFThumbnailViewer = null;
|
||||||
const SecondaryToolbar = null;
|
const SecondaryToolbar = null;
|
||||||
const SignatureManager = null;
|
const SignatureManager = null;
|
||||||
|
const SignaturePropertiesManager = null;
|
||||||
const ViewsManager = null;
|
const ViewsManager = null;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -44,5 +45,6 @@ export {
|
|||||||
PDFThumbnailViewer,
|
PDFThumbnailViewer,
|
||||||
SecondaryToolbar,
|
SecondaryToolbar,
|
||||||
SignatureManager,
|
SignatureManager,
|
||||||
|
SignaturePropertiesManager,
|
||||||
ViewsManager,
|
ViewsManager,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@import url(pdf_viewer.css);
|
@import url(pdf_viewer.css);
|
||||||
|
@import url(digital_signature_properties.css);
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--dir-factor: 1;
|
--dir-factor: 1;
|
||||||
@ -83,6 +84,10 @@
|
|||||||
--toolbarButton-editorInk-icon: url(images/toolbarButton-editorInk.svg);
|
--toolbarButton-editorInk-icon: url(images/toolbarButton-editorInk.svg);
|
||||||
--toolbarButton-editorStamp-icon: url(images/toolbarButton-editorStamp.svg);
|
--toolbarButton-editorStamp-icon: url(images/toolbarButton-editorStamp.svg);
|
||||||
--toolbarButton-editorSignature-icon: url(images/toolbarButton-editorSignature.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-menuArrow-icon: url(images/toolbarButton-menuArrow.svg);
|
||||||
--toolbarButton-viewsManagerToggle-icon: url(images/toolbarButton-viewsManagerToggle.svg);
|
--toolbarButton-viewsManagerToggle-icon: url(images/toolbarButton-viewsManagerToggle.svg);
|
||||||
--toolbarButton-secondaryToolbarToggle-icon: url(images/toolbarButton-secondaryToolbarToggle.svg);
|
--toolbarButton-secondaryToolbarToggle-icon: url(images/toolbarButton-secondaryToolbarToggle.svg);
|
||||||
|
|||||||
@ -107,6 +107,7 @@ See https://github.com/adobe-type-tools/cmap-resources
|
|||||||
"web-print_service": "./pdf_print_service.js",
|
"web-print_service": "./pdf_print_service.js",
|
||||||
"web-secondary_toolbar": "./secondary_toolbar.js",
|
"web-secondary_toolbar": "./secondary_toolbar.js",
|
||||||
"web-signature_manager": "./signature_manager.js",
|
"web-signature_manager": "./signature_manager.js",
|
||||||
|
"web-digital_signature_properties_manager": "./digital_signature_properties_manager.js",
|
||||||
"web-toolbar": "./toolbar.js",
|
"web-toolbar": "./toolbar.js",
|
||||||
"web-views_manager": "./views_manager.js"
|
"web-views_manager": "./views_manager.js"
|
||||||
}
|
}
|
||||||
@ -423,6 +424,27 @@ See https://github.com/adobe-type-tools/cmap-resources
|
|||||||
</div>
|
</div>
|
||||||
<div id="toolbarViewerRight" class="toolbarHorizontalGroup">
|
<div id="toolbarViewerRight" class="toolbarHorizontalGroup">
|
||||||
<div id="editorModeButtons" class="toolbarHorizontalGroup">
|
<div id="editorModeButtons" class="toolbarHorizontalGroup">
|
||||||
|
<div id="signatureProperties" class="toolbarButtonWithContainer" hidden="true">
|
||||||
|
<button
|
||||||
|
id="signaturePropertiesButton"
|
||||||
|
class="toolbarButton"
|
||||||
|
type="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-controls="signaturePropertiesPanel"
|
||||||
|
data-l10n-id="pdfjs-digital-signature-properties-button"
|
||||||
|
>
|
||||||
|
<span data-l10n-id="pdfjs-digital-signature-properties-button-label"></span>
|
||||||
|
</button>
|
||||||
|
<div class="editorParamsToolbar hidden doorHangerRight" id="signaturePropertiesPanel" role="region">
|
||||||
|
<div id="signaturePropertiesContainer" class="signaturePropertiesContainer">
|
||||||
|
<div id="signaturePropertiesBanner" class="sigBanner" hidden="true" role="status"></div>
|
||||||
|
<ul id="signaturePropertiesList" class="signaturePropertiesList" tabindex="-1"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="signaturePropertiesSeparator" class="verticalToolbarSeparator" hidden="true"></div>
|
||||||
<div id="editorComment" class="toolbarButtonWithContainer" hidden="true">
|
<div id="editorComment" class="toolbarButtonWithContainer" hidden="true">
|
||||||
<button
|
<button
|
||||||
id="editorCommentButton"
|
id="editorCommentButton"
|
||||||
|
|||||||
@ -71,6 +71,24 @@ function getViewerConfiguration() {
|
|||||||
editorSignatureParamsToolbar: document.getElementById(
|
editorSignatureParamsToolbar: document.getElementById(
|
||||||
"editorSignatureParamsToolbar"
|
"editorSignatureParamsToolbar"
|
||||||
),
|
),
|
||||||
|
signaturePropertiesButton: document.getElementById(
|
||||||
|
"signaturePropertiesButton"
|
||||||
|
),
|
||||||
|
signaturePropertiesPanel: document.getElementById(
|
||||||
|
"signaturePropertiesPanel"
|
||||||
|
),
|
||||||
|
signaturePropertiesContainer: document.getElementById(
|
||||||
|
"signaturePropertiesContainer"
|
||||||
|
),
|
||||||
|
signaturePropertiesBanner: document.getElementById(
|
||||||
|
"signaturePropertiesBanner"
|
||||||
|
),
|
||||||
|
signaturePropertiesList: document.getElementById(
|
||||||
|
"signaturePropertiesList"
|
||||||
|
),
|
||||||
|
signaturePropertiesSeparator: document.getElementById(
|
||||||
|
"signaturePropertiesSeparator"
|
||||||
|
),
|
||||||
download: document.getElementById("downloadButton"),
|
download: document.getElementById("downloadButton"),
|
||||||
},
|
},
|
||||||
secondaryToolbar: {
|
secondaryToolbar: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user