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

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

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

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

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

542 lines
19 KiB
JavaScript

/* Copyright 2017 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 { createIdFactory, XRefMock } from "./test_utils.js";
import { Dict, Name, Ref } from "../../src/core/primitives.js";
import { PDFDocument } from "../../src/core/document.js";
import { StringStream } from "../../src/core/stream.js";
describe("document", function () {
describe("Page", function () {
it("should create correct objId/fontId using the idFactory", function () {
const idFactory1 = createIdFactory(/* pageIndex = */ 0);
const idFactory2 = createIdFactory(/* pageIndex = */ 1);
expect(idFactory1.createObjId()).toEqual("p0_1");
expect(idFactory1.createObjId()).toEqual("p0_2");
expect(idFactory1.createFontId()).toEqual("f1");
expect(idFactory1.createFontId()).toEqual("f2");
expect(idFactory1.getDocId()).toEqual("g_d0");
expect(idFactory2.createObjId()).toEqual("p1_1");
expect(idFactory2.createObjId()).toEqual("p1_2");
expect(idFactory2.createFontId()).toEqual("f1");
expect(idFactory2.createFontId()).toEqual("f2");
expect(idFactory2.getDocId()).toEqual("g_d0");
expect(idFactory1.createObjId()).toEqual("p0_3");
expect(idFactory1.createObjId()).toEqual("p0_4");
expect(idFactory1.createFontId()).toEqual("f3");
expect(idFactory1.createFontId()).toEqual("f4");
expect(idFactory1.getDocId()).toEqual("g_d0");
});
});
describe("PDFDocument", function () {
// Padded to 1024 bytes so signature ByteRange tests using offsets
// like `[0, 100, 200, 800]` stay within `stream.end` (the new
// `#parseSignatureDict` validation rejects ByteRanges that exceed
// the file length).
const stream = new StringStream("Dummy_PDF_data".padEnd(1024, " "));
function getDocument(acroForm, xref = new XRefMock()) {
const catalog = { acroForm };
const pdfManager = {
get docId() {
return "d0";
},
ensureDoc(prop, args) {
return pdfManager.ensure(pdfDocument, prop, args);
},
ensureCatalog(prop, args) {
return pdfManager.ensure(catalog, prop, args);
},
async ensure(obj, prop, args) {
const value = obj[prop];
if (typeof value === "function") {
return value.apply(obj, args);
}
return value;
},
get evaluatorOptions() {
return { isOffscreenCanvasSupported: false };
},
};
const pdfDocument = new PDFDocument(pdfManager, stream);
pdfDocument.xref = xref;
pdfDocument.catalog = catalog;
pdfManager.pdfDocument = pdfDocument;
return pdfDocument;
}
it("should get form info when no form data is present", function () {
const pdfDocument = getDocument(null);
expect(pdfDocument.formInfo).toEqual({
hasAcroForm: false,
hasSignatures: false,
hasXfa: false,
hasFields: false,
});
});
it("should get form info when XFA is present", function () {
const acroForm = new Dict();
// The `XFA` entry can only be a non-empty array or stream.
acroForm.set("XFA", []);
let pdfDocument = getDocument(acroForm);
expect(pdfDocument.formInfo).toEqual({
hasAcroForm: false,
hasSignatures: false,
hasXfa: false,
hasFields: false,
});
acroForm.set("XFA", ["foo", "bar"]);
pdfDocument = getDocument(acroForm);
expect(pdfDocument.formInfo).toEqual({
hasAcroForm: false,
hasSignatures: false,
hasXfa: true,
hasFields: false,
});
acroForm.set("XFA", new StringStream(""));
pdfDocument = getDocument(acroForm);
expect(pdfDocument.formInfo).toEqual({
hasAcroForm: false,
hasSignatures: false,
hasXfa: false,
hasFields: false,
});
acroForm.set("XFA", new StringStream("non-empty"));
pdfDocument = getDocument(acroForm);
expect(pdfDocument.formInfo).toEqual({
hasAcroForm: false,
hasSignatures: false,
hasXfa: true,
hasFields: false,
});
});
it("should get form info when AcroForm is present", function () {
const acroForm = new Dict();
// The `Fields` entry can only be a non-empty array.
acroForm.set("Fields", []);
let pdfDocument = getDocument(acroForm);
expect(pdfDocument.formInfo).toEqual({
hasAcroForm: false,
hasSignatures: false,
hasXfa: false,
hasFields: false,
});
acroForm.set("Fields", ["foo", "bar"]);
pdfDocument = getDocument(acroForm);
expect(pdfDocument.formInfo).toEqual({
hasAcroForm: true,
hasSignatures: false,
hasXfa: false,
hasFields: true,
});
// If the first bit of the `SigFlags` entry is set and the `Fields` array
// only contains document signatures, then there is no AcroForm data.
acroForm.set("Fields", ["foo", "bar"]);
acroForm.set("SigFlags", 2);
pdfDocument = getDocument(acroForm);
expect(pdfDocument.formInfo).toEqual({
hasAcroForm: true,
hasSignatures: false,
hasXfa: false,
hasFields: true,
});
const annotationDict = new Dict();
annotationDict.set("FT", Name.get("Sig"));
annotationDict.set("Rect", [0, 0, 0, 0]);
const annotationRef = Ref.get(11, 0);
const kidsDict = new Dict();
kidsDict.set("Kids", [annotationRef]);
const kidsRef = Ref.get(10, 0);
const xref = new XRefMock([
{ ref: annotationRef, data: annotationDict },
{ ref: kidsRef, data: kidsDict },
]);
acroForm.set("Fields", [kidsRef]);
acroForm.set("SigFlags", 3);
pdfDocument = getDocument(acroForm, xref);
expect(pdfDocument.formInfo).toEqual({
hasAcroForm: false,
hasSignatures: true,
hasXfa: false,
hasFields: true,
});
});
describe("getSignatures", function () {
function makeSigDict({
byteRange,
contents = "00".repeat(8),
subFilter = "adbe.pkcs7.detached",
name = null,
reason = null,
location = null,
m = null,
}) {
const dict = new Dict();
dict.set("Type", Name.get("Sig"));
dict.set("Filter", Name.get("Adobe.PPKLite"));
dict.set("SubFilter", Name.get(subFilter));
dict.set("ByteRange", byteRange);
dict.set("Contents", contents);
if (name !== null) {
dict.set("Name", name);
}
if (reason !== null) {
dict.set("Reason", reason);
}
if (location !== null) {
dict.set("Location", location);
}
if (m !== null) {
dict.set("M", m);
}
return dict;
}
function makeSigField({ T, sigRef }) {
const dict = new Dict();
dict.set("FT", Name.get("Sig"));
dict.set("T", T);
dict.set("V", sigRef);
return dict;
}
it("returns null when no signatures are present", async function () {
const acroForm = new Dict();
const pdfDocument = getDocument(acroForm);
const signatures = await pdfDocument.signatures;
expect(signatures).toBeNull();
});
it("extracts metadata for a single signature", async function () {
const acroForm = new Dict();
acroForm.set("SigFlags", 3);
const sigRef = Ref.get(20, 0);
const fieldRef = Ref.get(21, 0);
const sigDict = makeSigDict({
byteRange: [0, 100, 200, 300],
name: "Alice Becker",
reason: "Approved for release",
m: "D:20251014103200+00'00'",
});
const fieldDict = makeSigField({ T: "sig_alice", sigRef });
const xref = new XRefMock([
{ ref: sigRef, data: sigDict },
{ ref: fieldRef, data: fieldDict },
]);
acroForm.set("Fields", [fieldRef]);
const pdfDocument = getDocument(acroForm, xref);
const signatures = await pdfDocument.signatures;
expect(signatures.length).toEqual(1);
const [sig] = signatures;
expect(sig.signerName).toEqual("Alice Becker");
expect(sig.reason).toEqual("Approved for release");
expect(sig.signingTime).toEqual("D:20251014103200+00'00'");
expect(sig.fieldName).toEqual("sig_alice");
expect(sig.subFilter).toEqual("adbe.pkcs7.detached");
expect(sig.signatureType).toEqual(0);
expect(sig.byteRange).toEqual([0, 100, 200, 300]);
expect(sig.parentId).toEqual(null);
expect(sig.revisionIndex).toEqual(0);
// The bytes (pkcs7 + signed-data spans) are no longer attached
// to the metadata array — they're fetched on demand via
// `getSignatureData(id)` so the worker→main message stays
// small.
expect(sig.pkcs7).toBeUndefined();
expect(sig.data).toBeUndefined();
const bytes = await pdfDocument.getSignatureData(sig.id);
expect(bytes.pkcs7).toBeInstanceOf(Uint8Array);
expect(Array.isArray(bytes.data)).toBeTrue();
expect(bytes.data.length).toEqual(2);
});
it("walks Kids recursively to find nested signature fields", async function () {
const acroForm = new Dict();
acroForm.set("SigFlags", 3);
const sigRef = Ref.get(30, 0);
const sigFieldRef = Ref.get(31, 0);
const containerRef = Ref.get(32, 0);
const sigDict = makeSigDict({
byteRange: [0, 50, 100, 150],
name: "John Smith",
});
const sigField = makeSigField({ T: "sig_john", sigRef });
const container = new Dict();
container.set("Kids", [sigFieldRef]);
const xref = new XRefMock([
{ ref: sigRef, data: sigDict },
{ ref: sigFieldRef, data: sigField },
{ ref: containerRef, data: container },
]);
acroForm.set("Fields", [containerRef]);
const pdfDocument = getDocument(acroForm, xref);
const signatures = await pdfDocument.signatures;
expect(signatures.length).toEqual(1);
expect(signatures[0].signerName).toEqual("John Smith");
});
it("skips signatures with malformed ByteRange", async function () {
const acroForm = new Dict();
acroForm.set("SigFlags", 3);
const sigRef = Ref.get(40, 0);
const fieldRef = Ref.get(41, 0);
const sigDict = makeSigDict({ byteRange: [0, 100] }); // wrong length
const fieldDict = makeSigField({ T: "bad", sigRef });
const xref = new XRefMock([
{ ref: sigRef, data: sigDict },
{ ref: fieldRef, data: fieldDict },
]);
acroForm.set("Fields", [fieldRef]);
const pdfDocument = getDocument(acroForm, xref);
expect(await pdfDocument.signatures).toBeNull();
});
it("groups sub-signatures under the outer revision", async function () {
const acroForm = new Dict();
acroForm.set("SigFlags", 3);
// Outer covers more bytes (c+d larger) → parent.
// Inner covers fewer bytes → sub-signature of outer.
const outerSigRef = Ref.get(50, 0);
const outerFieldRef = Ref.get(51, 0);
const innerSigRef = Ref.get(52, 0);
const innerFieldRef = Ref.get(53, 0);
const outerSig = makeSigDict({
byteRange: [0, 100, 200, 800],
name: "Outer",
});
const innerSig = makeSigDict({
byteRange: [0, 50, 100, 200],
name: "Inner",
});
const xref = new XRefMock([
{ ref: outerSigRef, data: outerSig },
{
ref: outerFieldRef,
data: makeSigField({ T: "outer", sigRef: outerSigRef }),
},
{ ref: innerSigRef, data: innerSig },
{
ref: innerFieldRef,
data: makeSigField({ T: "inner", sigRef: innerSigRef }),
},
]);
acroForm.set("Fields", [outerFieldRef, innerFieldRef]);
const pdfDocument = getDocument(acroForm, xref);
const signatures = await pdfDocument.signatures;
expect(signatures.length).toEqual(2);
// Sorted descending by c+d, so outer comes first.
expect(signatures[0].signerName).toEqual("Outer");
expect(signatures[0].parentId).toEqual(null);
expect(signatures[0].revisionIndex).toEqual(0);
expect(signatures[1].signerName).toEqual("Inner");
expect(signatures[1].parentId).toEqual(signatures[0].id);
expect(signatures[1].revisionIndex).toEqual(1);
});
it("maps SubFilter to the PDFSignatureAlgorithm enum", async function () {
const acroForm = new Dict();
acroForm.set("SigFlags", 3);
async function signatureType(subFilter) {
const sigRef = Ref.get(60, 0);
const fieldRef = Ref.get(61, 0);
const sigDict = makeSigDict({
byteRange: [0, 10, 20, 30],
subFilter,
});
const xref = new XRefMock([
{ ref: sigRef, data: sigDict },
{
ref: fieldRef,
data: makeSigField({ T: "sig", sigRef }),
},
]);
acroForm.set("Fields", [fieldRef]);
const pdfDocument = getDocument(acroForm, xref);
const [sig] = await pdfDocument.signatures;
return sig.signatureType;
}
expect(await signatureType("adbe.pkcs7.detached")).toEqual(0);
expect(await signatureType("adbe.pkcs7.sha1")).toEqual(1);
expect(await signatureType("ETSI.CAdES.detached")).toEqual(null);
});
});
it("should get calculation order array or null", function () {
const acroForm = new Dict();
let pdfDocument = getDocument(acroForm);
expect(pdfDocument.calculationOrderIds).toEqual(null);
acroForm.set("CO", [Ref.get(1, 0), Ref.get(2, 0), Ref.get(3, 0)]);
pdfDocument = getDocument(acroForm);
expect(pdfDocument.calculationOrderIds).toEqual(["1R", "2R", "3R"]);
acroForm.set("CO", []);
pdfDocument = getDocument(acroForm);
expect(pdfDocument.calculationOrderIds).toEqual(null);
acroForm.set("CO", ["1", "2"]);
pdfDocument = getDocument(acroForm);
expect(pdfDocument.calculationOrderIds).toEqual(null);
acroForm.set("CO", ["1", Ref.get(1, 0), "2"]);
pdfDocument = getDocument(acroForm);
expect(pdfDocument.calculationOrderIds).toEqual(["1R"]);
});
it("should get field objects array or null", async function () {
const acroForm = new Dict();
let pdfDocument = getDocument(acroForm);
let fields = await pdfDocument.fieldObjects;
expect(fields).toEqual(null);
acroForm.set("Fields", []);
pdfDocument = getDocument(acroForm);
fields = await pdfDocument.fieldObjects;
expect(fields).toEqual(null);
const kid1Ref = Ref.get(314, 0);
const kid11Ref = Ref.get(159, 0);
const kid2Ref = Ref.get(265, 0);
const kid2BisRef = Ref.get(266, 0);
const parentRef = Ref.get(358, 0);
const allFields = Object.create(null);
for (const name of ["parent", "kid1", "kid2", "kid11"]) {
const buttonWidgetDict = new Dict();
buttonWidgetDict.set("Type", Name.get("Annot"));
buttonWidgetDict.set("Subtype", Name.get("Widget"));
buttonWidgetDict.set("FT", Name.get("Btn"));
buttonWidgetDict.set("T", name);
allFields[name] = buttonWidgetDict;
}
allFields.kid1.set("Kids", [kid11Ref]);
allFields.parent.set("Kids", [kid1Ref, kid2Ref, kid2BisRef]);
const xref = new XRefMock([
{ ref: parentRef, data: allFields.parent },
{ ref: kid1Ref, data: allFields.kid1 },
{ ref: kid11Ref, data: allFields.kid11 },
{ ref: kid2Ref, data: allFields.kid2 },
{ ref: kid2BisRef, data: allFields.kid2 },
]);
acroForm.set("Fields", [parentRef]);
pdfDocument = getDocument(acroForm, xref);
fields = (await pdfDocument.fieldObjects).allFields;
for (const [name, objs] of Object.entries(fields)) {
fields[name] = objs.map(obj => obj.id);
}
expect(fields["parent.kid1"]).toEqual(["314R"]);
expect(fields["parent.kid1.kid11"]).toEqual(["159R"]);
expect(fields["parent.kid2"]).toEqual(["265R", "266R"]);
expect(fields.parent).toEqual(["358R"]);
});
it("should check if fields have any actions", async function () {
const acroForm = new Dict();
let pdfDocument = getDocument(acroForm);
let hasJSActions = await pdfDocument.hasJSActions;
expect(hasJSActions).toEqual(false);
acroForm.set("Fields", []);
pdfDocument = getDocument(acroForm);
hasJSActions = await pdfDocument.hasJSActions;
expect(hasJSActions).toEqual(false);
const kid1Ref = Ref.get(314, 0);
const kid11Ref = Ref.get(159, 0);
const kid2Ref = Ref.get(265, 0);
const parentRef = Ref.get(358, 0);
const allFields = Object.create(null);
for (const name of ["parent", "kid1", "kid2", "kid11"]) {
const buttonWidgetDict = new Dict();
buttonWidgetDict.set("Type", Name.get("Annot"));
buttonWidgetDict.set("Subtype", Name.get("Widget"));
buttonWidgetDict.set("FT", Name.get("Btn"));
buttonWidgetDict.set("T", name);
allFields[name] = buttonWidgetDict;
}
allFields.kid1.set("Kids", [kid11Ref]);
allFields.parent.set("Kids", [kid1Ref, kid2Ref]);
const xref = new XRefMock([
{ ref: parentRef, data: allFields.parent },
{ ref: kid1Ref, data: allFields.kid1 },
{ ref: kid11Ref, data: allFields.kid11 },
{ ref: kid2Ref, data: allFields.kid2 },
]);
acroForm.set("Fields", [parentRef]);
pdfDocument = getDocument(acroForm, xref);
hasJSActions = await pdfDocument.hasJSActions;
expect(hasJSActions).toEqual(false);
const JS = Name.get("JavaScript");
const additionalActionsDict = new Dict();
const eDict = new Dict();
eDict.set("JS", "hello()");
eDict.set("S", JS);
additionalActionsDict.set("E", eDict);
allFields.kid2.set("AA", additionalActionsDict);
pdfDocument = getDocument(acroForm, xref);
hasJSActions = await pdfDocument.hasJSActions;
expect(hasJSActions).toEqual(true);
});
});
});