mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-07-01 20:55:48 +02:00
Adds a new "Digital signature properties" doorhanger to the pdf.js toolbar that lists every digital signature found in the opened PDF, verifies each one (via NSS in the Firefox build through a new chrome bridge), and shows per-signature status + certificate state. The viewer side parses /Sig dicts in the worker (`PDFDocument.signatures`), strict-validates the /ByteRange offsets before slicing, and ships only signature metadata across the worker boundary. The PKCS#7 blob and signed-data byte spans live in a worker-side map and are fetched lazily one signature at a time via a new `getSignatureData(id)` RPC, immediately before verification runs, so the bytes never sit in main-thread memory for the document's lifetime. The panel is feature-gated by `pdfjs.enableSignatureVerification` (true in MOZCENTRAL/TESTING, off by default in the GENERIC build). External services expose a `createSignatureVerifier()` factory that the Firefox build wires up to `nsIX509CertDB.asyncVerifyPKCS7Object`; GENERIC builds return null and the toolbar button stays hidden. UI summary: - Toolbar button states: loading dots while in flight, then green check, orange `!`, or red `✕` based on the worst aggregate signature status. - Doorhanger contains a banner summarising the document state, then one card per signature with status row + certificate row (sub- signatures nested under their outer revision via /ByteRange containment). - Icons are mono SVGs themed via `mask-image` + `background-color` so they pick up light/dark/HCM via `--sig-icon-*` vars; flipped under RTL via `scaleX(var(--dir-factor))`. The HCM mapping reuses the alt-text vocabulary (ButtonFace / ButtonText / ButtonBorder / GrayText / AccentColor / LinkText) so this panel reads the same as the rest of the editor toolbar in high-contrast mode. - All visible strings are localized via Fluent (`pdfjs-digital-signature-properties-*`); status row, banner, and certificate row use explicit lookup tables instead of generated ids so a grep finds them. - Esc + outside-click close the panel through the viewer's existing handlers; the manager exposes `isOpen`, `close()`, and `shouldCloseOnClick(target)` for that. This commit also adds a `test/pdfs/sig_corpus/` directory holding a Python generator that produces a corpus of signed PDFs covering every visible state of the doorhanger (verified / untrusted / expired / invalid / unknown / multi-signature variants). The corpus is intentionally NOT part of the automated test suite — it is a manual-test tool. Generated `.pdf` files are gitignored; only the generator, README, and a `user.js.example` snippet are tracked. The generator shells out to mozilla-central's `security/manager/tools/pycms.py` (resolved via `--mozilla-central <path>` or the `MOZILLA_CENTRAL_SRC` env var) and the embedded test trust anchors (`pdf-sign-ca` / `pdf-sign-ca-expired`), gated by `security.pdf_signature_verification.enable_test_trust_anchors` so the test certificates never validate in shipping Firefox.
413 lines
13 KiB
CSS
413 lines
13 KiB
CSS
/* 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;
|
|
}
|
|
}
|