mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-07-01 04:35:47 +02:00
Merge pull request #21247 from beurdouche/master
Digital Signature and Certificate verification
This commit is contained in:
commit
2578f6bff6
@ -225,6 +225,8 @@ function createWebpackAlias(defines) {
|
||||
"web-print_service": "",
|
||||
"web-secondary_toolbar": "web/secondary_toolbar.js",
|
||||
"web-signature_manager": "web/signature_manager.js",
|
||||
"web-digital_signature_properties_manager":
|
||||
"web/digital_signature_properties_manager.js",
|
||||
"web-toolbar": "web/toolbar.js",
|
||||
"web-views_manager": "web/views_manager.js",
|
||||
};
|
||||
|
||||
@ -785,3 +785,96 @@ pdfjs-views-manager-paste-button-after =
|
||||
pdfjs-new-badge-content = NEW
|
||||
|
||||
pdfjs-views-manager-waiting-for-file = Uploading file…
|
||||
|
||||
## Digital signature properties (signature verification panel)
|
||||
|
||||
pdfjs-digital-signature-properties-button =
|
||||
.title = Digital signature properties
|
||||
.aria-label = Digital signature properties
|
||||
pdfjs-digital-signature-properties-button-label = Digital signature properties
|
||||
|
||||
## Banner shown above the signature list summarising the overall
|
||||
## verification state of the document. Each variant is selected by the
|
||||
## viewer based on the worst per-signature status; one signature is
|
||||
## enough to lower the banner.
|
||||
##
|
||||
## Variables:
|
||||
## $count (Number) - number of signatures at the worst level.
|
||||
|
||||
pdfjs-digital-signature-properties-banner-verified = Document was signed with a valid digital signature
|
||||
pdfjs-digital-signature-properties-banner-unknown =
|
||||
{ $count ->
|
||||
[one] Document signed but { $count } digital signature could not be verified
|
||||
*[other] Document signed but { $count } digital signatures could not be verified
|
||||
}
|
||||
pdfjs-digital-signature-properties-banner-untrusted =
|
||||
{ $count ->
|
||||
[one] Document signed with { $count } certificate that is not trusted
|
||||
*[other] Document signed with { $count } certificates that are not trusted
|
||||
}
|
||||
pdfjs-digital-signature-properties-banner-expired =
|
||||
{ $count ->
|
||||
[one] Document signed with { $count } expired certificate
|
||||
*[other] Document signed with { $count } expired certificates
|
||||
}
|
||||
pdfjs-digital-signature-properties-banner-invalid =
|
||||
{ $count ->
|
||||
[one] Document has { $count } invalid digital signature
|
||||
*[other] Document has { $count } invalid digital signatures
|
||||
}
|
||||
pdfjs-digital-signature-properties-banner-revoked =
|
||||
{ $count ->
|
||||
[one] Document signed with { $count } revoked certificate
|
||||
*[other] Document signed with { $count } revoked certificates
|
||||
}
|
||||
|
||||
## Per-signature status row. Only three distinct strings are needed:
|
||||
## the signature crypto either verified (the cert chain may still be
|
||||
## untrusted/expired/revoked, but that's surfaced on the cert row
|
||||
## below), or it failed, or its sub-format isn't supported.
|
||||
|
||||
pdfjs-digital-signature-properties-status-verified = Status: Signature verified
|
||||
pdfjs-digital-signature-properties-status-invalid = Status: Signature invalid
|
||||
pdfjs-digital-signature-properties-status-unknown = Status: Unable to verify (unsupported)
|
||||
|
||||
## Per-signature certificate row. The variants with an issuer / date in
|
||||
## parentheses embed fully-localized context — no English fall-through.
|
||||
##
|
||||
## Variables:
|
||||
## $issuer (String) - issuer or subject common name from the cert.
|
||||
## $dateObj (Date) - notAfter date for the expired-with-date form.
|
||||
|
||||
pdfjs-digital-signature-properties-certificate-trusted = Certificate: Trusted ({ $issuer })
|
||||
pdfjs-digital-signature-properties-certificate-unknown = Certificate: Unavailable
|
||||
pdfjs-digital-signature-properties-certificate-untrusted = Certificate: Untrusted
|
||||
pdfjs-digital-signature-properties-certificate-untrusted-unknown-issuer = Certificate: Unknown issuer ({ $issuer })
|
||||
pdfjs-digital-signature-properties-certificate-untrusted-self-signed = Certificate: Self-signed ({ $issuer })
|
||||
pdfjs-digital-signature-properties-certificate-untrusted-untrusted-issuer = Certificate: Untrusted issuer ({ $issuer })
|
||||
pdfjs-digital-signature-properties-certificate-expired = Certificate: Expired
|
||||
pdfjs-digital-signature-properties-certificate-expired-with-date = Certificate: Expired ({ DATETIME($dateObj, dateStyle: "medium") })
|
||||
pdfjs-digital-signature-properties-certificate-revoked = Certificate: Revoked
|
||||
|
||||
##
|
||||
|
||||
pdfjs-digital-signature-properties-view-certificate = View certificate
|
||||
|
||||
# Shown beneath an invalid signature card to explain why verification
|
||||
# failed. The text comes from NSS (e.g. "Signature integrity has been
|
||||
# compromised", "PKCS#7 signature could not be parsed") and is not
|
||||
# itself localized — it is the underlying error message produced by
|
||||
# the verification backend.
|
||||
# Variables:
|
||||
# $reason (String) - error message describing why the signature
|
||||
# could not be verified.
|
||||
pdfjs-digital-signature-properties-reason = Reason: { $reason }
|
||||
# Variables:
|
||||
# $dateObj (Date) - the signing time from the /Sig dict's /M entry.
|
||||
pdfjs-digital-signature-properties-timestamp = Timestamp: { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") }
|
||||
# Variables:
|
||||
# $count (Number) - number of nested sub-signatures (one per earlier
|
||||
# incremental revision of the document).
|
||||
pdfjs-digital-signature-properties-sub-signatures =
|
||||
{ $count ->
|
||||
[one] Sub-signature ({ $count })
|
||||
*[other] Sub-signatures ({ $count })
|
||||
}
|
||||
|
||||
@ -1004,6 +1004,13 @@ function find(stream, signature, limit = 1024, backwards = false) {
|
||||
class PDFDocument {
|
||||
#pagePromises = new Map();
|
||||
|
||||
// Map<id, {data: Uint8Array[2], pkcs7: Uint8Array}> — populated by the
|
||||
// `signatures` getter, consumed by `getSignatureData`. We deliberately
|
||||
// keep the byte payload out of the metadata array so it doesn't ride
|
||||
// the worker→main `postMessage` boundary unless the viewer actually
|
||||
// asks to verify (one shot per signature).
|
||||
#signatureData = null;
|
||||
|
||||
#version = null;
|
||||
|
||||
constructor(pdfManager, stream) {
|
||||
@ -1987,6 +1994,203 @@ class PDFDocument {
|
||||
return shadow(this, "fieldObjects", promise);
|
||||
}
|
||||
|
||||
#collectSignatureFields(fields, out, visitedRefs) {
|
||||
if (!Array.isArray(fields)) {
|
||||
return;
|
||||
}
|
||||
for (const fieldRef of fields) {
|
||||
if (fieldRef instanceof Ref) {
|
||||
if (visitedRefs.has(fieldRef)) {
|
||||
continue;
|
||||
}
|
||||
visitedRefs.put(fieldRef);
|
||||
}
|
||||
const field = this.xref.fetchIfRef(fieldRef);
|
||||
if (!(field instanceof Dict)) {
|
||||
continue;
|
||||
}
|
||||
if (field.has("Kids")) {
|
||||
this.#collectSignatureFields(field.get("Kids"), out, visitedRefs);
|
||||
continue;
|
||||
}
|
||||
if (!isName(field.get("FT"), "Sig")) {
|
||||
continue;
|
||||
}
|
||||
const sigDict = this.xref.fetchIfRef(field.get("V"));
|
||||
if (!(sigDict instanceof Dict)) {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.#parseSignatureDict(field, sigDict, fieldRef);
|
||||
if (parsed) {
|
||||
out.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The /ByteRange of a signature that covers the whole document usually
|
||||
// ends a few bytes before the file's actual end — the trailer,
|
||||
// `startxref` offset and `%%EOF` marker are conventionally left outside
|
||||
// the signed range. 100 bytes covers the worst case (large
|
||||
// `startxref` offsets, optional whitespace) without false positives.
|
||||
static #WHOLE_DOCUMENT_TAIL_FUZZ = 100;
|
||||
|
||||
#parseSignatureDict(field, sigDict, fieldRef) {
|
||||
const byteRange = sigDict.get("ByteRange");
|
||||
if (
|
||||
!Array.isArray(byteRange) ||
|
||||
byteRange.length !== 4 ||
|
||||
byteRange.some(n => !Number.isInteger(n) || n < 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const contents = sigDict.get("Contents");
|
||||
if (typeof contents !== "string" || contents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filterName = sigDict.get("Filter");
|
||||
const filter = filterName instanceof Name ? filterName.name : null;
|
||||
const subFilterName = sigDict.get("SubFilter");
|
||||
const subFilter = subFilterName instanceof Name ? subFilterName.name : null;
|
||||
|
||||
let signatureType = null;
|
||||
if (subFilter === "adbe.pkcs7.detached") {
|
||||
signatureType = 0;
|
||||
} else if (subFilter === "adbe.pkcs7.sha1") {
|
||||
signatureType = 1;
|
||||
}
|
||||
|
||||
// Slice the two ByteRange byte spans out of the underlying PDF stream.
|
||||
// ByteRange = [a, b, c, d] means signed bytes are [a..a+b] and [c..c+d];
|
||||
// the gap covers the /Contents hex blob itself.
|
||||
const [a, b, c, d] = byteRange;
|
||||
const stream = this.stream;
|
||||
// `/ByteRange` offsets are absolute, so compare against `stream.end`
|
||||
// (raw buffer end), not `stream.length` (post-`moveStart` payload).
|
||||
const fileLength = stream.end || 0;
|
||||
// Reject signatures whose /ByteRange is structurally implausible: it
|
||||
// must start at the file head, define a non-empty first span, leave
|
||||
// room for the /Contents blob between the two spans, and fit within
|
||||
// the file. Without this a crafted PDF can claim to cover the whole
|
||||
// document while only signing a small prologue.
|
||||
if (
|
||||
a !== 0 ||
|
||||
b <= 0 ||
|
||||
d < 0 ||
|
||||
a + b > c ||
|
||||
c + d > fileLength ||
|
||||
fileLength === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const data = [stream.getByteRange(a, a + b), stream.getByteRange(c, c + d)];
|
||||
|
||||
const pkcs7 = stringToBytes(contents);
|
||||
|
||||
const t = field.get("T");
|
||||
const fieldName = typeof t === "string" ? stringToPDFString(t) : "";
|
||||
const name = sigDict.get("Name");
|
||||
const reason = sigDict.get("Reason");
|
||||
const location = sigDict.get("Location");
|
||||
const contactInfo = sigDict.get("ContactInfo");
|
||||
const m = sigDict.get("M");
|
||||
|
||||
const refKey = fieldRef instanceof Ref ? fieldRef.toString() : "inline";
|
||||
const id = `${refKey}:${a}-${b}-${c}-${d}`;
|
||||
|
||||
// A signature "covers the whole document" iff the gap between the
|
||||
// last signed byte and EOF is no more than the conventional
|
||||
// trailer-slack window. Compare on the gap (not on the absolute
|
||||
// lastSignedByte) so a tiny file with a tiny ByteRange isn't
|
||||
// mis-flagged as full-coverage.
|
||||
const tailGap = fileLength - (c + d);
|
||||
|
||||
return {
|
||||
id,
|
||||
fieldName,
|
||||
signerName: typeof name === "string" ? stringToPDFString(name) : null,
|
||||
reason: typeof reason === "string" ? stringToPDFString(reason) : null,
|
||||
location:
|
||||
typeof location === "string" ? stringToPDFString(location) : null,
|
||||
contactInfo:
|
||||
typeof contactInfo === "string" ? stringToPDFString(contactInfo) : null,
|
||||
signingTime: typeof m === "string" ? m : null,
|
||||
filter,
|
||||
subFilter,
|
||||
signatureType,
|
||||
byteRange,
|
||||
pkcs7,
|
||||
data,
|
||||
revisionIndex: 0,
|
||||
parentId: null,
|
||||
coversWholeDocument:
|
||||
tailGap >= 0 && tailGap <= PDFDocument.#WHOLE_DOCUMENT_TAIL_FUZZ,
|
||||
};
|
||||
}
|
||||
|
||||
get signatures() {
|
||||
const promise = this.pdfManager
|
||||
.ensureDoc("formInfo")
|
||||
.then(async formInfo => {
|
||||
if (!formInfo.hasSignatures || !formInfo.hasFields) {
|
||||
this.#signatureData = null;
|
||||
return null;
|
||||
}
|
||||
const annotationGlobals = await this.annotationGlobals;
|
||||
if (!annotationGlobals) {
|
||||
this.#signatureData = null;
|
||||
return null;
|
||||
}
|
||||
const fields = annotationGlobals.acroForm.get("Fields");
|
||||
|
||||
const collected = [];
|
||||
this.#collectSignatureFields(fields, collected, new RefSet());
|
||||
|
||||
// Group sub-signatures by ByteRange containment: outer revision is
|
||||
// the largest covering signature (largest c + d). Sort descending,
|
||||
// then point each later signature at the smallest enclosing parent
|
||||
// that came before it.
|
||||
collected.sort(
|
||||
(a, b) =>
|
||||
b.byteRange[2] + b.byteRange[3] - (a.byteRange[2] + a.byteRange[3])
|
||||
);
|
||||
for (let i = 0, ii = collected.length; i < ii; i++) {
|
||||
const sig = collected[i];
|
||||
sig.revisionIndex = i;
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
const candidate = collected[j];
|
||||
if (
|
||||
candidate.byteRange[2] + candidate.byteRange[3] >
|
||||
sig.byteRange[2] + sig.byteRange[3]
|
||||
) {
|
||||
sig.parentId = candidate.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Split bytes (`data`, `pkcs7`) out of the metadata so the array
|
||||
// we ship to the main thread stays small. The viewer fetches the
|
||||
// bytes on demand via `getSignatureData(id)`, one signature at a
|
||||
// time, only when verification is about to run.
|
||||
const signatureData = new Map();
|
||||
const metadata = collected.map(sig => {
|
||||
const { data, pkcs7, ...rest } = sig;
|
||||
signatureData.set(sig.id, { data, pkcs7 });
|
||||
return rest;
|
||||
});
|
||||
this.#signatureData = signatureData;
|
||||
return metadata.length ? metadata : null;
|
||||
});
|
||||
|
||||
return shadow(this, "signatures", promise);
|
||||
}
|
||||
|
||||
async getSignatureData(id) {
|
||||
// Ensure parsing is finished and `#signatureData` is populated.
|
||||
await this.signatures;
|
||||
return this.#signatureData?.get(id) ?? null;
|
||||
}
|
||||
|
||||
get hasJSActions() {
|
||||
const promise = this.pdfManager.ensureDoc("_parseHasJSActions");
|
||||
return shadow(this, "hasJSActions", promise);
|
||||
|
||||
@ -579,6 +579,14 @@ class WorkerMessageHandler {
|
||||
.then(fieldObjects => fieldObjects?.allFields || null);
|
||||
});
|
||||
|
||||
handler.on("GetSignatures", function (data) {
|
||||
return pdfManager.ensureDoc("signatures");
|
||||
});
|
||||
|
||||
handler.on("GetSignatureData", function (id) {
|
||||
return pdfManager.ensureDoc("getSignatureData", [id]);
|
||||
});
|
||||
|
||||
handler.on("HasJSActions", function (data) {
|
||||
return pdfManager.ensureDoc("hasJSActions");
|
||||
});
|
||||
|
||||
@ -1077,6 +1077,29 @@ class PDFDocumentProxy {
|
||||
return this._transport.getFieldObjects();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Array<Object> | null>} A promise that is resolved
|
||||
* with an {Array} of digital signature metadata (signerName, reason,
|
||||
* signingTime, byteRange, subFilter, …), or `null` when the document
|
||||
* has no signatures. The PKCS#7 blob and signed-data byte spans
|
||||
* needed for verification are fetched separately via
|
||||
* {@link PDFDocumentProxy.getSignatureData} so they don't ride the
|
||||
* worker boundary unless verification is actually requested.
|
||||
*/
|
||||
getSignatures() {
|
||||
return this._transport.getSignatures();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id Signature `id` from a {@link getSignatures} entry.
|
||||
* @returns {Promise<{ data: Uint8Array[], pkcs7: Uint8Array } | null>}
|
||||
* The byte payload needed to verify the signature, or `null` if the
|
||||
* id is unknown.
|
||||
*/
|
||||
getSignatureData(id) {
|
||||
return this._transport.getSignatureData(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>} A promise that is resolved with `true`
|
||||
* if some /AcroForm fields have JavaScript actions.
|
||||
@ -3035,6 +3058,17 @@ class WorkerTransport {
|
||||
return this.#cacheSimpleMethod("GetFieldObjects");
|
||||
}
|
||||
|
||||
getSignatures() {
|
||||
return this.#cacheSimpleMethod("GetSignatures");
|
||||
}
|
||||
|
||||
getSignatureData(id) {
|
||||
// Not cached: bytes should be one-shot. Holding them in the
|
||||
// `#methodPromises` map would keep them alive for the document's
|
||||
// lifetime, which defeats the metadata/data split.
|
||||
return this.messageHandler.sendWithPromise("GetSignatureData", id);
|
||||
}
|
||||
|
||||
hasJSActions() {
|
||||
return this.#cacheSimpleMethod("HasJSActions");
|
||||
}
|
||||
|
||||
3
test/pdfs/sig_corpus/.gitignore
vendored
Normal file
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 () {
|
||||
const stream = new StringStream("Dummy_PDF_data");
|
||||
// Padded to 1024 bytes so signature ByteRange tests using offsets
|
||||
// like `[0, 100, 200, 800]` stay within `stream.end` (the new
|
||||
// `#parseSignatureDict` validation rejects ByteRanges that exceed
|
||||
// the file length).
|
||||
const stream = new StringStream("Dummy_PDF_data".padEnd(1024, " "));
|
||||
|
||||
function getDocument(acroForm, xref = new XRefMock()) {
|
||||
const catalog = { acroForm };
|
||||
@ -188,6 +192,221 @@ describe("document", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSignatures", function () {
|
||||
function makeSigDict({
|
||||
byteRange,
|
||||
contents = "00".repeat(8),
|
||||
subFilter = "adbe.pkcs7.detached",
|
||||
name = null,
|
||||
reason = null,
|
||||
location = null,
|
||||
m = null,
|
||||
}) {
|
||||
const dict = new Dict();
|
||||
dict.set("Type", Name.get("Sig"));
|
||||
dict.set("Filter", Name.get("Adobe.PPKLite"));
|
||||
dict.set("SubFilter", Name.get(subFilter));
|
||||
dict.set("ByteRange", byteRange);
|
||||
dict.set("Contents", contents);
|
||||
if (name !== null) {
|
||||
dict.set("Name", name);
|
||||
}
|
||||
if (reason !== null) {
|
||||
dict.set("Reason", reason);
|
||||
}
|
||||
if (location !== null) {
|
||||
dict.set("Location", location);
|
||||
}
|
||||
if (m !== null) {
|
||||
dict.set("M", m);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
function makeSigField({ T, sigRef }) {
|
||||
const dict = new Dict();
|
||||
dict.set("FT", Name.get("Sig"));
|
||||
dict.set("T", T);
|
||||
dict.set("V", sigRef);
|
||||
return dict;
|
||||
}
|
||||
|
||||
it("returns null when no signatures are present", async function () {
|
||||
const acroForm = new Dict();
|
||||
const pdfDocument = getDocument(acroForm);
|
||||
const signatures = await pdfDocument.signatures;
|
||||
expect(signatures).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts metadata for a single signature", async function () {
|
||||
const acroForm = new Dict();
|
||||
acroForm.set("SigFlags", 3);
|
||||
|
||||
const sigRef = Ref.get(20, 0);
|
||||
const fieldRef = Ref.get(21, 0);
|
||||
const sigDict = makeSigDict({
|
||||
byteRange: [0, 100, 200, 300],
|
||||
name: "Alice Becker",
|
||||
reason: "Approved for release",
|
||||
m: "D:20251014103200+00'00'",
|
||||
});
|
||||
const fieldDict = makeSigField({ T: "sig_alice", sigRef });
|
||||
|
||||
const xref = new XRefMock([
|
||||
{ ref: sigRef, data: sigDict },
|
||||
{ ref: fieldRef, data: fieldDict },
|
||||
]);
|
||||
acroForm.set("Fields", [fieldRef]);
|
||||
|
||||
const pdfDocument = getDocument(acroForm, xref);
|
||||
const signatures = await pdfDocument.signatures;
|
||||
expect(signatures.length).toEqual(1);
|
||||
const [sig] = signatures;
|
||||
expect(sig.signerName).toEqual("Alice Becker");
|
||||
expect(sig.reason).toEqual("Approved for release");
|
||||
expect(sig.signingTime).toEqual("D:20251014103200+00'00'");
|
||||
expect(sig.fieldName).toEqual("sig_alice");
|
||||
expect(sig.subFilter).toEqual("adbe.pkcs7.detached");
|
||||
expect(sig.signatureType).toEqual(0);
|
||||
expect(sig.byteRange).toEqual([0, 100, 200, 300]);
|
||||
expect(sig.parentId).toEqual(null);
|
||||
expect(sig.revisionIndex).toEqual(0);
|
||||
// The bytes (pkcs7 + signed-data spans) are no longer attached
|
||||
// to the metadata array — they're fetched on demand via
|
||||
// `getSignatureData(id)` so the worker→main message stays
|
||||
// small.
|
||||
expect(sig.pkcs7).toBeUndefined();
|
||||
expect(sig.data).toBeUndefined();
|
||||
const bytes = await pdfDocument.getSignatureData(sig.id);
|
||||
expect(bytes.pkcs7).toBeInstanceOf(Uint8Array);
|
||||
expect(Array.isArray(bytes.data)).toBeTrue();
|
||||
expect(bytes.data.length).toEqual(2);
|
||||
});
|
||||
|
||||
it("walks Kids recursively to find nested signature fields", async function () {
|
||||
const acroForm = new Dict();
|
||||
acroForm.set("SigFlags", 3);
|
||||
|
||||
const sigRef = Ref.get(30, 0);
|
||||
const sigFieldRef = Ref.get(31, 0);
|
||||
const containerRef = Ref.get(32, 0);
|
||||
|
||||
const sigDict = makeSigDict({
|
||||
byteRange: [0, 50, 100, 150],
|
||||
name: "John Smith",
|
||||
});
|
||||
const sigField = makeSigField({ T: "sig_john", sigRef });
|
||||
const container = new Dict();
|
||||
container.set("Kids", [sigFieldRef]);
|
||||
|
||||
const xref = new XRefMock([
|
||||
{ ref: sigRef, data: sigDict },
|
||||
{ ref: sigFieldRef, data: sigField },
|
||||
{ ref: containerRef, data: container },
|
||||
]);
|
||||
acroForm.set("Fields", [containerRef]);
|
||||
|
||||
const pdfDocument = getDocument(acroForm, xref);
|
||||
const signatures = await pdfDocument.signatures;
|
||||
expect(signatures.length).toEqual(1);
|
||||
expect(signatures[0].signerName).toEqual("John Smith");
|
||||
});
|
||||
|
||||
it("skips signatures with malformed ByteRange", async function () {
|
||||
const acroForm = new Dict();
|
||||
acroForm.set("SigFlags", 3);
|
||||
|
||||
const sigRef = Ref.get(40, 0);
|
||||
const fieldRef = Ref.get(41, 0);
|
||||
const sigDict = makeSigDict({ byteRange: [0, 100] }); // wrong length
|
||||
const fieldDict = makeSigField({ T: "bad", sigRef });
|
||||
|
||||
const xref = new XRefMock([
|
||||
{ ref: sigRef, data: sigDict },
|
||||
{ ref: fieldRef, data: fieldDict },
|
||||
]);
|
||||
acroForm.set("Fields", [fieldRef]);
|
||||
|
||||
const pdfDocument = getDocument(acroForm, xref);
|
||||
expect(await pdfDocument.signatures).toBeNull();
|
||||
});
|
||||
|
||||
it("groups sub-signatures under the outer revision", async function () {
|
||||
const acroForm = new Dict();
|
||||
acroForm.set("SigFlags", 3);
|
||||
|
||||
// Outer covers more bytes (c+d larger) → parent.
|
||||
// Inner covers fewer bytes → sub-signature of outer.
|
||||
const outerSigRef = Ref.get(50, 0);
|
||||
const outerFieldRef = Ref.get(51, 0);
|
||||
const innerSigRef = Ref.get(52, 0);
|
||||
const innerFieldRef = Ref.get(53, 0);
|
||||
|
||||
const outerSig = makeSigDict({
|
||||
byteRange: [0, 100, 200, 800],
|
||||
name: "Outer",
|
||||
});
|
||||
const innerSig = makeSigDict({
|
||||
byteRange: [0, 50, 100, 200],
|
||||
name: "Inner",
|
||||
});
|
||||
|
||||
const xref = new XRefMock([
|
||||
{ ref: outerSigRef, data: outerSig },
|
||||
{
|
||||
ref: outerFieldRef,
|
||||
data: makeSigField({ T: "outer", sigRef: outerSigRef }),
|
||||
},
|
||||
{ ref: innerSigRef, data: innerSig },
|
||||
{
|
||||
ref: innerFieldRef,
|
||||
data: makeSigField({ T: "inner", sigRef: innerSigRef }),
|
||||
},
|
||||
]);
|
||||
acroForm.set("Fields", [outerFieldRef, innerFieldRef]);
|
||||
|
||||
const pdfDocument = getDocument(acroForm, xref);
|
||||
const signatures = await pdfDocument.signatures;
|
||||
expect(signatures.length).toEqual(2);
|
||||
// Sorted descending by c+d, so outer comes first.
|
||||
expect(signatures[0].signerName).toEqual("Outer");
|
||||
expect(signatures[0].parentId).toEqual(null);
|
||||
expect(signatures[0].revisionIndex).toEqual(0);
|
||||
expect(signatures[1].signerName).toEqual("Inner");
|
||||
expect(signatures[1].parentId).toEqual(signatures[0].id);
|
||||
expect(signatures[1].revisionIndex).toEqual(1);
|
||||
});
|
||||
|
||||
it("maps SubFilter to the PDFSignatureAlgorithm enum", async function () {
|
||||
const acroForm = new Dict();
|
||||
acroForm.set("SigFlags", 3);
|
||||
|
||||
async function signatureType(subFilter) {
|
||||
const sigRef = Ref.get(60, 0);
|
||||
const fieldRef = Ref.get(61, 0);
|
||||
const sigDict = makeSigDict({
|
||||
byteRange: [0, 10, 20, 30],
|
||||
subFilter,
|
||||
});
|
||||
const xref = new XRefMock([
|
||||
{ ref: sigRef, data: sigDict },
|
||||
{
|
||||
ref: fieldRef,
|
||||
data: makeSigField({ T: "sig", sigRef }),
|
||||
},
|
||||
]);
|
||||
acroForm.set("Fields", [fieldRef]);
|
||||
const pdfDocument = getDocument(acroForm, xref);
|
||||
const [sig] = await pdfDocument.signatures;
|
||||
return sig.signatureType;
|
||||
}
|
||||
|
||||
expect(await signatureType("adbe.pkcs7.detached")).toEqual(0);
|
||||
expect(await signatureType("adbe.pkcs7.sha1")).toEqual(1);
|
||||
expect(await signatureType("ETSI.CAdES.detached")).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get calculation order array or null", function () {
|
||||
const acroForm = new Dict();
|
||||
|
||||
|
||||
47
web/app.js
47
web/app.js
@ -94,6 +94,7 @@ import { Preferences } from "web-preferences";
|
||||
import { RenderingStates } from "./renderable_view.js";
|
||||
import { SecondaryToolbar } from "web-secondary_toolbar";
|
||||
import { SignatureManager } from "web-signature_manager";
|
||||
import { SignaturePropertiesManager } from "web-digital_signature_properties_manager";
|
||||
import { Toolbar } from "web-toolbar";
|
||||
import { ViewHistory } from "./view_history.js";
|
||||
import { ViewsManager } from "web-views_manager";
|
||||
@ -164,6 +165,8 @@ const PDFViewerApplication = {
|
||||
l10n: null,
|
||||
/** @type {AnnotationEditorParams} */
|
||||
annotationEditorParams: null,
|
||||
/** @type {SignaturePropertiesManager|null} */
|
||||
signaturePropertiesManager: null,
|
||||
/** @type {ImageAltTextSettings} */
|
||||
imageAltTextSettings: null,
|
||||
isInitialViewSet: false,
|
||||
@ -1204,6 +1207,7 @@ const PDFViewerApplication = {
|
||||
this.pdfViewer.setDocument(null);
|
||||
this.pdfLinkService.setDocument(null);
|
||||
this.pdfDocumentProperties?.setDocument(null);
|
||||
this.signaturePropertiesManager?.reset();
|
||||
}
|
||||
this.pdfLinkService.externalLinkEnabled = true;
|
||||
this.store = null;
|
||||
@ -1869,12 +1873,42 @@ const PDFViewerApplication = {
|
||||
}
|
||||
|
||||
if (info.IsSignaturesPresent) {
|
||||
console.warn("Warning: Digital signatures validation is not supported");
|
||||
const success = this._maybeInitSignatureProperties(pdfDocument);
|
||||
if (!success) {
|
||||
console.warn("Warning: Digital signatures validation is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
this.eventBus.dispatch("metadataloaded", { source: this });
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean} `true` when signature verification was wired up;
|
||||
* `false` when the runtime doesn't expose a verifier (everything
|
||||
* except the Firefox build) or the option is turned off.
|
||||
*/
|
||||
_maybeInitSignatureProperties(pdfDocument) {
|
||||
if (!AppOptions.get("enableSignatureVerification")) {
|
||||
return false;
|
||||
}
|
||||
const verifier = this.externalServices.createSignatureVerifier();
|
||||
if (!verifier) {
|
||||
return false;
|
||||
}
|
||||
if (pdfDocument !== this.pdfDocument) {
|
||||
// Don't warn about a previous document.
|
||||
return true;
|
||||
}
|
||||
this.signaturePropertiesManager ??= new SignaturePropertiesManager({
|
||||
appConfig: this.appConfig.toolbar,
|
||||
verifier,
|
||||
eventBus: this.eventBus,
|
||||
});
|
||||
this.signaturePropertiesManager.loadFromDocument(pdfDocument);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
@ -2999,6 +3033,12 @@ function closeEditorUndoBar(evt) {
|
||||
}
|
||||
}
|
||||
|
||||
function closeSignatureProperties({ target }) {
|
||||
if (this.signaturePropertiesManager?.shouldCloseOnClick(target)) {
|
||||
this.signaturePropertiesManager.close();
|
||||
}
|
||||
}
|
||||
|
||||
function onBeforeUnload(evt) {
|
||||
if (this._hasChanges()) {
|
||||
evt.preventDefault();
|
||||
@ -3011,6 +3051,7 @@ function onBeforeUnload(evt) {
|
||||
function onClick(evt) {
|
||||
closeSecondaryToolbar.call(this, evt);
|
||||
closeEditorUndoBar.call(this, evt);
|
||||
closeSignatureProperties.call(this, evt);
|
||||
}
|
||||
|
||||
function onKeyUp(evt) {
|
||||
@ -3226,6 +3267,10 @@ function onKeyDown(evt) {
|
||||
this.secondaryToolbar.close();
|
||||
handled = true;
|
||||
}
|
||||
if (this.signaturePropertiesManager?.isOpen) {
|
||||
this.signaturePropertiesManager.close();
|
||||
handled = true;
|
||||
}
|
||||
if (!this.supportsIntegratedFind && this.findBar?.opened) {
|
||||
this.findBar.close();
|
||||
handled = true;
|
||||
|
||||
@ -277,6 +277,15 @@ const defaultOptions = {
|
||||
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
|
||||
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
|
||||
},
|
||||
enableSignatureVerification: {
|
||||
// On in MOZCENTRAL (NSS provides the verification path) and in
|
||||
// local dev builds; off in shipping GENERIC builds, where no
|
||||
// verifier is wired up by default.
|
||||
/** @type {boolean} */
|
||||
value:
|
||||
typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING || MOZCENTRAL"),
|
||||
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
|
||||
},
|
||||
enableSplitMerge: {
|
||||
/** @type {boolean} */
|
||||
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a signature verifier for the Digital signature properties panel.
|
||||
*
|
||||
* The MOZCENTRAL build returns a verifier that calls into NSS via
|
||||
* the chrome bridge. The default GENERIC implementation returns
|
||||
* `null` — there is no portable cryptographic verification path
|
||||
* outside Firefox, so the toolbar button stays hidden and the
|
||||
* worker is never asked for `getSignatures()`.
|
||||
*
|
||||
* Downstream consumers of `pdfjs-dist` that want the Signature
|
||||
* Properties UI should subclass `BaseExternalServices` and return
|
||||
* an object exposing `verify(signature)` (and optionally
|
||||
* `viewCertificate(certificate)`) that resolves to a
|
||||
* `VerificationResult` — see `web/firefoxcom.js` for the exact
|
||||
* shape.
|
||||
*
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
createSignatureVerifier() {
|
||||
return null;
|
||||
}
|
||||
|
||||
updateEditorStates(data) {
|
||||
throw new Error("Not implemented: updateEditorStates");
|
||||
}
|
||||
|
||||
@ -532,6 +532,140 @@ class SignatureStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// `nsresult` codes from PSM/NSS that we recognize. Names mirror
|
||||
// Firefox's nsINSSErrorsService and security/nss codes; values aren't
|
||||
// stable, so we compare strings instead of integers.
|
||||
const NSS_ERR_CODES = {
|
||||
EXPIRED: new Set([
|
||||
"SEC_ERROR_EXPIRED_CERTIFICATE",
|
||||
"SEC_ERROR_EXPIRED_ISSUER_CERTIFICATE",
|
||||
"MOZILLA_PKIX_ERROR_NOT_YET_VALID_CERTIFICATE",
|
||||
"MOZILLA_PKIX_ERROR_NOT_YET_VALID_ISSUER_CERTIFICATE",
|
||||
]),
|
||||
// Only definite-revoked codes belong here. We intentionally do NOT
|
||||
// include OCSP-response-missing-style codes (e.g.
|
||||
// MOZILLA_PKIX_ERROR_OCSP_RESPONSE_FOR_CERT_MISSING), since those
|
||||
// mean "we couldn't reach the responder" — they fall through to the
|
||||
// generic untrusted bucket.
|
||||
REVOKED: new Set([
|
||||
"SEC_ERROR_REVOKED_CERTIFICATE",
|
||||
"SEC_ERROR_REVOKED_KEY",
|
||||
"MOZILLA_PKIX_ERROR_REVOKED_CERTIFICATE",
|
||||
]),
|
||||
UNTRUSTED: new Set([
|
||||
"SEC_ERROR_UNKNOWN_ISSUER",
|
||||
"SEC_ERROR_UNTRUSTED_CERT",
|
||||
"SEC_ERROR_UNTRUSTED_ISSUER",
|
||||
"MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT",
|
||||
"MOZILLA_PKIX_ERROR_ADDITIONAL_POLICY_CONSTRAINT_FAILED",
|
||||
]),
|
||||
CMS_NOT_YET_ATTEMPTED: "NS_ERROR_CMS_VERIFY_NOT_YET_ATTEMPTED",
|
||||
};
|
||||
|
||||
function mapVerificationStatus(signatureCode, certificateCode) {
|
||||
if (signatureCode === NSS_ERR_CODES.CMS_NOT_YET_ATTEMPTED) {
|
||||
return { status: "unknown", errorCode: signatureCode };
|
||||
}
|
||||
if (signatureCode && signatureCode !== "NS_OK") {
|
||||
return { status: "invalid", errorCode: signatureCode };
|
||||
}
|
||||
if (!certificateCode || certificateCode === "NS_OK") {
|
||||
return { status: "verified", errorCode: null };
|
||||
}
|
||||
if (NSS_ERR_CODES.REVOKED.has(certificateCode)) {
|
||||
return { status: "revoked", errorCode: certificateCode };
|
||||
}
|
||||
if (NSS_ERR_CODES.EXPIRED.has(certificateCode)) {
|
||||
return { status: "expired", errorCode: certificateCode };
|
||||
}
|
||||
if (NSS_ERR_CODES.UNTRUSTED.has(certificateCode)) {
|
||||
return { status: "untrusted", errorCode: certificateCode };
|
||||
}
|
||||
return { status: "untrusted", errorCode: certificateCode };
|
||||
}
|
||||
|
||||
class SignatureVerifier {
|
||||
async verify(signature) {
|
||||
if (signature.signatureType === null) {
|
||||
return {
|
||||
status: "unknown",
|
||||
errorCode: "SUBFILTER_NOT_SUPPORTED",
|
||||
message: signature.subFilter,
|
||||
certificate: null,
|
||||
documentModifiedAfterSigning: !signature.coversWholeDocument,
|
||||
};
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await FirefoxCom.requestAsync("verifyPdfSignature", {
|
||||
pkcs7: signature.pkcs7,
|
||||
data: signature.data,
|
||||
signatureType: signature.signatureType,
|
||||
});
|
||||
} catch (ex) {
|
||||
return {
|
||||
status: "unknown",
|
||||
errorCode: "BRIDGE_ERROR",
|
||||
message: ex?.message ?? null,
|
||||
certificate: null,
|
||||
documentModifiedAfterSigning: !signature.coversWholeDocument,
|
||||
};
|
||||
}
|
||||
if (!response || response.error) {
|
||||
return {
|
||||
status: "unknown",
|
||||
errorCode: response?.error ?? "EMPTY_RESPONSE",
|
||||
message: null,
|
||||
certificate: null,
|
||||
documentModifiedAfterSigning: !signature.coversWholeDocument,
|
||||
};
|
||||
}
|
||||
|
||||
// The chrome side returns an Array<nsIPDFVerificationResult>, but for
|
||||
// a single PKCS#7 input it has exactly one entry.
|
||||
const entry = Array.isArray(response) ? response[0] : response;
|
||||
if (!entry) {
|
||||
return {
|
||||
status: "unknown",
|
||||
errorCode: "EMPTY_RESPONSE",
|
||||
message: null,
|
||||
certificate: null,
|
||||
documentModifiedAfterSigning: !signature.coversWholeDocument,
|
||||
};
|
||||
}
|
||||
const { status, errorCode } = mapVerificationStatus(
|
||||
entry.signatureResult,
|
||||
entry.certificateResult
|
||||
);
|
||||
return {
|
||||
status,
|
||||
errorCode,
|
||||
message: entry.message ?? null,
|
||||
certificate: entry.certificate ?? null,
|
||||
documentModifiedAfterSigning: !signature.coversWholeDocument,
|
||||
};
|
||||
}
|
||||
|
||||
async viewCertificate(certificate) {
|
||||
if (!certificate) {
|
||||
return false;
|
||||
}
|
||||
const certs =
|
||||
Array.isArray(certificate.chain) && certificate.chain.length
|
||||
? certificate.chain.map(c => c.derBase64).filter(Boolean)
|
||||
: [certificate.derBase64].filter(Boolean);
|
||||
if (certs.length === 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await FirefoxCom.requestAsync("viewPdfCertificate", { certs });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExternalServices extends BaseExternalServices {
|
||||
updateFindControlState(data) {
|
||||
FirefoxCom.request("updateFindControlState", data);
|
||||
@ -623,6 +757,10 @@ class ExternalServices extends BaseExternalServices {
|
||||
return new SignatureStorage(eventBus, signal);
|
||||
}
|
||||
|
||||
createSignatureVerifier() {
|
||||
return new SignatureVerifier();
|
||||
}
|
||||
|
||||
dispatchGlobalEvent(event) {
|
||||
FirefoxCom.request("dispatchGlobalEvent", event);
|
||||
}
|
||||
|
||||
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 SecondaryToolbar = null;
|
||||
const SignatureManager = null;
|
||||
const SignaturePropertiesManager = null;
|
||||
const ViewsManager = null;
|
||||
|
||||
export {
|
||||
@ -44,5 +45,6 @@ export {
|
||||
PDFThumbnailViewer,
|
||||
SecondaryToolbar,
|
||||
SignatureManager,
|
||||
SignaturePropertiesManager,
|
||||
ViewsManager,
|
||||
};
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
@import url(pdf_viewer.css);
|
||||
@import url(digital_signature_properties.css);
|
||||
|
||||
:root {
|
||||
--dir-factor: 1;
|
||||
@ -83,6 +84,10 @@
|
||||
--toolbarButton-editorInk-icon: url(images/toolbarButton-editorInk.svg);
|
||||
--toolbarButton-editorStamp-icon: url(images/toolbarButton-editorStamp.svg);
|
||||
--toolbarButton-editorSignature-icon: url(images/toolbarButton-editorSignature.svg);
|
||||
--toolbarButton-signaturePropertiesVerified-icon: url(images/toolbarButton-signaturePropertiesVerified.svg);
|
||||
--toolbarButton-signaturePropertiesWarn-icon: url(images/toolbarButton-signaturePropertiesWarn.svg);
|
||||
--toolbarButton-signaturePropertiesError-icon: url(images/toolbarButton-signaturePropertiesError.svg);
|
||||
--signatureProperties-rowCheck-icon: url(images/signature-properties-row-check.svg);
|
||||
--toolbarButton-menuArrow-icon: url(images/toolbarButton-menuArrow.svg);
|
||||
--toolbarButton-viewsManagerToggle-icon: url(images/toolbarButton-viewsManagerToggle.svg);
|
||||
--toolbarButton-secondaryToolbarToggle-icon: url(images/toolbarButton-secondaryToolbarToggle.svg);
|
||||
|
||||
@ -107,6 +107,7 @@ See https://github.com/adobe-type-tools/cmap-resources
|
||||
"web-print_service": "./pdf_print_service.js",
|
||||
"web-secondary_toolbar": "./secondary_toolbar.js",
|
||||
"web-signature_manager": "./signature_manager.js",
|
||||
"web-digital_signature_properties_manager": "./digital_signature_properties_manager.js",
|
||||
"web-toolbar": "./toolbar.js",
|
||||
"web-views_manager": "./views_manager.js"
|
||||
}
|
||||
@ -423,6 +424,27 @@ See https://github.com/adobe-type-tools/cmap-resources
|
||||
</div>
|
||||
<div id="toolbarViewerRight" class="toolbarHorizontalGroup">
|
||||
<div id="editorModeButtons" class="toolbarHorizontalGroup">
|
||||
<div id="signatureProperties" class="toolbarButtonWithContainer" hidden="true">
|
||||
<button
|
||||
id="signaturePropertiesButton"
|
||||
class="toolbarButton"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-controls="signaturePropertiesPanel"
|
||||
data-l10n-id="pdfjs-digital-signature-properties-button"
|
||||
>
|
||||
<span data-l10n-id="pdfjs-digital-signature-properties-button-label"></span>
|
||||
</button>
|
||||
<div class="editorParamsToolbar hidden doorHangerRight" id="signaturePropertiesPanel" role="region">
|
||||
<div id="signaturePropertiesContainer" class="signaturePropertiesContainer">
|
||||
<div id="signaturePropertiesBanner" class="sigBanner" hidden="true" role="status"></div>
|
||||
<ul id="signaturePropertiesList" class="signaturePropertiesList" tabindex="-1"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="signaturePropertiesSeparator" class="verticalToolbarSeparator" hidden="true"></div>
|
||||
<div id="editorComment" class="toolbarButtonWithContainer" hidden="true">
|
||||
<button
|
||||
id="editorCommentButton"
|
||||
|
||||
@ -71,6 +71,24 @@ function getViewerConfiguration() {
|
||||
editorSignatureParamsToolbar: document.getElementById(
|
||||
"editorSignatureParamsToolbar"
|
||||
),
|
||||
signaturePropertiesButton: document.getElementById(
|
||||
"signaturePropertiesButton"
|
||||
),
|
||||
signaturePropertiesPanel: document.getElementById(
|
||||
"signaturePropertiesPanel"
|
||||
),
|
||||
signaturePropertiesContainer: document.getElementById(
|
||||
"signaturePropertiesContainer"
|
||||
),
|
||||
signaturePropertiesBanner: document.getElementById(
|
||||
"signaturePropertiesBanner"
|
||||
),
|
||||
signaturePropertiesList: document.getElementById(
|
||||
"signaturePropertiesList"
|
||||
),
|
||||
signaturePropertiesSeparator: document.getElementById(
|
||||
"signaturePropertiesSeparator"
|
||||
),
|
||||
download: document.getElementById("downloadButton"),
|
||||
},
|
||||
secondaryToolbar: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user