VerifyKitv0.13.3

VerifyKit Architecture

Technical architecture of the VerifyKit SDK, covering the Rust/WASM core engine, package structure, verification pipeline, plugin system, and build pipeline.


SDK Architecture

Loading diagram...

Tip: Click the diagram to open it fullscreen. Use scroll to zoom, drag to pan, and keyboard shortcuts (+, -, 0, Esc).

ColorPackageDescription
🔵 Indigoverifykit-reactReact components, hooks, viewer with toolbar, search, zoom, and signature panel
🟣 Purpleverifykit-vanillaZero-build UMD/ESM viewer for CDN and vanilla JS projects
🟢 Greenverifykit-coreVerification engine, trust store, crypto, chain builder, CMS parser, PAdES detection
🟡 Amberverifykit-plugin-revocationOnline OCSP and CRL certificate revocation checking

Verification Pipeline

Loading diagram...

The verification engine runs eight independent checks on every signature:

#CheckWhat it validates
1Document IntegrityByteRange coverage and hash match
2Cryptographic SignatureRSA, ECDSA, or Ed25519 signature verification
3Certificate ChainChain of trust to a known root CA
4Certificate ExpiryValidity period at signing time
5RFC 3161 TimestampTrusted timestamp token verification
6Revocation StatusCRL and OCSP revocation data
7Algorithm StrengthRejects MD5/MD2; SHA-1 is scored by policy — see below
8Extended Key UsageDocument signing and timestamping EKU

Packages

VerifyKit ships as four npm packages. Install only what you need -- the verification engine works on its own, with no UI.

PackageWhat it containsDepends on
@trexolab/verifykit-coreThe Rust/WASM verification engine, the embedded trust store, and a thin TypeScript wrapper over it. Headless: no React, no DOM.--
@trexolab/verifykit-reactThe viewer, the plugin system, the signature and certificate UI, hooks, themes and translations.core
@trexolab/verifykit-vanillaA zero-build UMD/ESM drop-in wrapping the React viewer behind an imperative VerifyKit.create() API. React is bundled in.react
@trexolab/verifykit-plugin-revocationOnline CRL and OCSP revocation checking, as an optional plugin.core
@trexolab/verifykit-plugin-revocation ──> @trexolab/verifykit-core
@trexolab/verifykit-react ──────────────> @trexolab/verifykit-core
@trexolab/verifykit-vanilla ────────────> @trexolab/verifykit-react ──> @trexolab/verifykit-core

Each package publishes a dist/ directory with type declarations for ESM and CJS; the React and vanilla packages also publish their stylesheet, and the React package re-ships the pdf.js CMap and standard-font assets. The rest of this page describes the surface you build against: the engine, the plugin contracts, the viewer store and the toolbar slots.

Third-party dependencies

Everything the engine needs for cryptography, ASN.1 / X.509 / CMS parsing and PDF stream decompression is compiled into the WASM binary from audited Rust crates. The core package has no npm runtime dependencies at all -- nothing is fetched from the registry at install time, and there is no transitive JavaScript supply chain under it to keep patched.

The UI packages depend on:

@trexolab/verifykit-react
├── @trexolab/verifykit-core
├── pdfjs-dist 5.5.207        (PDF rendering — pinned, see below)
├── pdf-lib                    (PDF document manipulation)
├── @radix-ui/*                (accessible dialog, tabs, accordion, separator)
├── lucide-react               (icons)
└── react 19+, react-dom 19+  (peer dependencies)

@trexolab/verifykit-vanilla
├── @trexolab/verifykit-react
└── react 19, react-dom 19     (bundled — not peer dependencies)

A full bill of materials for the WASM binary -- every crate, version and licence -- is available to customers for security review. Ask us for it.


Rust/WASM Core Architecture

The @trexolab/verifykit-core package is a Rust library compiled to WebAssembly. All cryptographic operations -- signature validation, hash computation, certificate chain building, ASN.1 parsing -- run entirely in WASM. The JavaScript layer is a thin wrapper that:

  1. Initializes the WASM module
  2. Converts inputs (ArrayBuffer, File, URL string) to Uint8Array for WASM
  3. Rehydrates ISO 8601 date strings from WASM output into JavaScript Date objects
  4. Runs plugin-based revocation checking as a post-processing step

Why Rust/WASM

AspectPrevious JS EngineRust/WASM Engine
CryptoWeb Crypto API plus four npm crypto/ASN.1 librariesCompiled into the WASM binary
Dependencies~8 npm runtime deps0 npm runtime deps
PerformanceBaseline3.7x faster
Trust storePEM strings parsed at runtime134 DER certificates compiled into WASM binary
Node.jsRequired @peculiar/webcrypto polyfillWorks natively (WASM runs in Node.js)
Bundle~180 KB (minified JS + deps)~19 KB JS + a 1.1 MB .wasm fetched on first use

WASM Loading Pipeline

1. createVerifier() or verifyPdf() called
2. WASM module loaded on first use (dynamic import — never at import time)
3. If setWasmUrl() was called: loads from that source
   Else browser: the URL wasm-bindgen resolves against the bundle, via
        instantiateStreaming — compiled as it downloads
   Else Node:    read from pkg/ in the installed package
4. WASM module initializes, trust store loads 134 DER certs
5. Module cached in memory — subsequent calls are instant

Step 3 is where the two environments differ, and it is the only place they do. Node needs its own path because wasm-bindgen's resolution ends in fetch(new URL(…, import.meta.url)) and Node's fetch rejects file: URLs; the split is expressed as a node condition in exports rather than as a runtime check, so a browser bundler never sees the node:fs/promises import.

No bundler configuration is required either way. Vite, webpack, Rollup and Parcel all understand the new URL('….wasm', import.meta.url) the engine resolves through and emit the file as a hashed asset.

The setWasmUrl() function provides an escape hatch to load from a custom URL, ArrayBuffer, or precompiled WebAssembly.Module if needed.

Between v0.3.1 and v0.11.0 the binary was base64-encoded into the JavaScript instead. That needed no bundler support at all, which is why it was chosen, and it charged for it in three ways: base64 is four bytes per three, so 1.1 MB became 1.5 MB; a string literal cannot be split out of the chunk holding it, so every route importing the package carried the engine whether or not it verified anything; and the bytes had to be decoded before compilation could begin.

The WASM module is a singleton. Multiple createVerifier() calls share the same underlying WASM instance, and each verifier can still carry its own trustStore configuration.

Supported Algorithms

CategoryAlgorithms
SignatureRSA (PKCS#1 v1.5, PSS), ECDSA (P-256, P-384, P-521), Ed25519
HashSHA-1 (legacy), SHA-256, SHA-384, SHA-512, MD5 (detection only)
CertificateX.509 v3, CMS/PKCS#7 SignedData

How SHA-1 is scored

SHA-1 is broken for collision resistance and still common in older signed PDFs, so what a verifier reports for it is a policy choice rather than a fact. This one makes the choice explicit.

For a stricter verdict than Adobe's, set the policy before verifying:

ts
createVerifier({ algorithmPolicy: { sha1: 'warn' } })

The default is { sha1: 'valid' } — Adobe Reader parity. A SHA-1 signature reads as valid, with the algorithm named in the check's disclosure so the reader can see what it was signed with. This is deliberate: the common case is a user comparing a verdict here against the one Acrobat gives them on the same file, and a viewer that disagrees with Acrobat reads as broken rather than as stricter. 'warn' moves it to a warning without failing the document.

MD5 and MD2 are not configurable and are always rejected.


Config Hierarchy (Vanilla -> React -> Core)

Options set in @trexolab/verifykit-vanilla flow through to the React provider and core engine:

VerifyKitOptions (vanilla)           VerifyKitConfig (react)           VerifyKitCoreConfig (core)
─────────────────────              ──────────────────              ─────────────────────

.trustStore                    ->  .trustStore                 ->  .trustStore
.plugins                       ->  .plugins                    ->  .plugins
.theme                         ->  .theme
.workerSrc                     ->  .worker.src
.locale                        ->  .locale
.translations                  ->  .translations
.features                      ->  defaultLayoutPlugin({ disable: ... })
                                   (inverted: features.X = false
                                    -> disable.X = true)

The vanilla wrapper creates a defaultLayoutPlugin() internally and maps features flags to disable flags (inverted logic). Core verification config (trustStore, plugins) passes through unchanged.


Verification Pipeline

Each PDF signature is validated with 8 independent checks:

CheckWhat It Verifies
integrityCheckDocument bytes match the signed range (tampering detection)
signatureCheckCryptographic signature is mathematically valid
certificateChainCheckCertificate chains to a trusted root CA
expiryCheckCertificates were valid at signing time
timestampCheckRFC 3161 timestamp is present and valid
revocationCheckCertificate not revoked (CRL/OCSP)
algorithmCheckAlgorithms meet the configured strength policy (RSA >= 2048; SHA-1 per algorithmPolicy)
ekuCheckExtended Key Usage permits document signing

Verification Flow

1. PDF Parsing
   ├── Scan PDF bytes for signature dictionaries
   ├── Extract ByteRange (bytes covered by signature)
   ├── Extract Contents (DER-encoded CMS blob)
   └── Detect visibility, field names, permissions, deleted signatures

2. CMS Verification
   ├── Parse CMS/PKCS#7 SignedData structure
   ├── Validate cryptographic signature (RSA/ECDSA/Ed25519)
   ├── Check certificate chain against trust store (134 embedded roots)
   ├── Validate certificate expiry, key usage, EKU
   └── Check algorithm strength

3. AIA Resolution (when enableAIA is true)
   ├── Check if certificate chain is incomplete
   ├── Fetch missing intermediates from AIA CA Issuers URLs
   ├── Support PKCS#7 containers and individual certificate downloads
   └── Cache fetched certificates for the verifier lifetime

4. Timestamp Verification
   ├── Extract RFC 3161 timestamp token
   ├── Verify timestamp signature
   └── Extract TSA certificate info

5. Revocation Checking
   ├── Extract embedded CRL/OCSP data from signature
   └── Optional: online checking via @trexolab/verifykit-plugin-revocation (JS post-processing)

6. Document Integrity
   ├── Check if ByteRange covers whole document
   ├── Detect DSS (Document Security Store) for LTV data
   └── Identify deleted/tampered signatures

7. Results Assembly
   ├── WASM returns JSON with ISO 8601 date strings
   ├── JS wrapper rehydrates dates to Date objects
   ├── Plugin-based revocation updates applied (if plugins configured)
   └── Final VerificationResult returned

Status Aggregation

The 8 individual checks aggregate into overallStatus:

StatusMeaning
validAll checks pass
warningMinor issues (no timestamp, algorithm concerns)
invalidSignature broken, certificate untrusted, or document modified
unknownCould not determine (missing data)

PAdES Conformance

The engine detects PAdES conformance levels:

LevelDescription
B-BBasic signature (CMS signed data)
B-TWith trusted timestamp
B-LTWith long-term validation data (CRL/OCSP embedded)
B-LTAWith archive timestamp for long-term archival

Plugin System

VerifyKit has two separate plugin systems:

Core Plugins (Verification)

Extend the verification engine. Implemented as VerifyKitPlugin:

ts
interface VerifyKitPlugin {
  name: string
  setup?: (ctx: PluginContext) => void | Promise<void>
  trustStore?: TrustStoreConfig
  revocation?: {
    checkCRL?: (cert, urls) => Promise<RevocationCheckResult>
    checkOCSP?: (cert, issuer, urls) => Promise<RevocationCheckResult>
  }
  inputResolver?: (input: PdfInput) => Promise<ArrayBuffer | null>
}

Core plugins can:

  • Add trusted root certificates (merge or replace the built-in AATL store)
  • Provide online revocation checking (CRL/OCSP via @trexolab/verifykit-plugin-revocation)
  • Resolve custom input types (fetch from cloud storage, decrypt buffers)

The revocation plugin runs as a JavaScript post-processing step after WASM verification completes. For each signature whose revocationCheck.status is "unknown", the plugin performs live OCSP/CRL lookups and updates the result.

Viewer Plugins (React UI)

Extend the React viewer. Implemented as ViewerPlugin:

ts
interface ViewerPlugin {
  name: string
  dependencies?: string[]
  composedPlugins?: ViewerPlugin[]
  install?(ctx: PluginContext): void
  onDocumentLoad?(e: DocumentLoadEvent): void
  onDocumentUnload?(): void
  onPageChange?(e: PageChangeEvent): void
  onZoomChange?(e: ZoomChangeEvent): void
  onRotationChange?(e: RotationChangeEvent): void
  destroy?(): void
  renderToolbarSlot?: Partial<ToolbarSlots>
  sidebarTabs?: SidebarTabDefinition[]
  renderOverlay?: (props) => React.ReactNode
  renderRightPanel?: (props) => React.ReactNode
  renderPageOverlay?: (props) => React.ReactNode
}

Plugin Lifecycle (Viewer)

1. Plugin factory called:     const zoom = zoomPlugin()
2. Passed to Viewer:          <Viewer plugins={[zoom]} />
3. CoreViewer creates store:  const store = createViewerStore()
4. PluginHost resolves:       resolvePlugins(plugins) -> flat, deduped list
5. install() called:          zoom.install({ store, registerShortcut, ... })
6. Document loads:            zoom.onDocumentLoad({ document, numPages })
7. User interacts:            zoom.api.zoomIn() -> store.update({ scale: 1.35 })
8. Component unmounts:        zoom.destroy() -> cleanup

PluginHost

Manages the full viewer plugin lifecycle:

  1. Resolve -- Flatten composed plugins (meta-plugins like defaultLayoutPlugin), deduplicate by name
  2. Install -- Call install(ctx) on each plugin in order
  3. Collect UI -- Gather toolbar slots, sidebar tabs, overlays, and page overlays from all plugins
  4. Dispatch events -- Route lifecycle events to all plugins
  5. Destroy -- Clean up all plugins on unmount

defaultLayoutPlugin

A meta-plugin that composes all 24 viewer plugins into a batteries-included experience:

ts
const layout = defaultLayoutPlugin({
  disable: { print: true, download: true },
  toolbar: { transform: (slots) => { delete slots.Print; return slots } },
})

Disabled plugins are never instantiated, so they do not contribute to bundle size with proper tree-shaking.

The result exposes APIs for all sub-plugins:

ts
layout.zoom.zoomIn()
layout.navigation.goToPage(5)
layout.sidebar.toggle('thumbnails')
layout.search.open()
layout.theme.toggleTheme()
layout.signature.togglePanel()

ViewerStore

A lightweight pub-sub reactive store that replaces prop drilling across the component tree. Plugins read and write state through this store, and React components subscribe via useViewerStoreKey() for selective re-rendering.

State Shape

CategoryKeys
Documentdocument, fileBuffer, fileName, loadState, errorMessage
NavigationcurrentPage, totalPages
Zoomscale, fitMode
Rotationrotation
LayoutscrollMode, spreadMode, cursorTool
ThemethemeMode
FullscreenisFullscreen
Verificationsignatures, unsignedFields, verificationStatus
UI panelssidebarOpen, sidebarTab, sigPanelOpen, findOpen
PasswordpasswordNeeded, passwordError

Key Features

  • Key-level subscriptions -- Subscribe to individual keys. A zoom change does not re-render page navigation components.
  • Batch updates -- Multiple update() calls inside batch() fire listeners only once.
  • Shallow merge -- Only changed keys notify subscribers.
  • Zero dependencies -- No Zustand, Redux, or external state library.

Toolbar Slot System

Plugins contribute to named toolbar slots. The toolbarPlugin collects all contributions and renders them in a 3-section grid layout:

| LEFT                    | CENTER                                    | RIGHT                  |
| SearchPopover           | GoToPreviousPage  CurrentPageInput        | Download Print         |
|                         | NumberOfPages GoToNextPage                | ThemeToggle Fullscreen |
|                         | ZoomOut Zoom ZoomIn Rotate                | MoreMenu               |

The transform API lets consumers remove, reorder, or add custom slots:

ts
toolbarPlugin({
  transform: (slots) => {
    delete slots.Print
    delete slots.Download
    slots.MyButton = ({ store }) => <button>Custom</button>
    return slots
  },
})

What Each Package Publishes

PackageModule formatsAlso published
@trexolab/verifykit-coreESM + CJSType declarations, and pkg/verifykit_core_wasm_bg.wasm — the engine, which bundlers emit as an asset and Node reads from the package. Separate browser and Node builds, selected by the node export condition.
@trexolab/verifykit-plugin-revocationESM + CJSType declarations
@trexolab/verifykit-reactESM + CJSType declarations, one stylesheet, and the pdf.js CMap and standard-font assets
@trexolab/verifykit-vanillaUMD + ESMOne stylesheet, and its own copy of the .wasm — Vite builds this package in library mode, which inlines assets, so the file is placed in dist/ and pointed at explicitly. React is bundled in, so there is nothing to install alongside it.

Shipping the engine as a file rather than a string costs nothing in configuration -- every bundler listed above resolves it without help -- and buys back the 33% base64 adds, streaming compilation, and a cache entry that does not turn over every time the JavaScript does. The one package that cannot rely on the consumer's bundler is vanilla, which has none: it carries the file in dist/ and resolves it against its own script URL.


Browser and Node.js Support

Browser Support

BrowserMinimum VersionNotes
Chrome109+Full support
Firefox115+Full support
Safari16.4+Full support
Edge109+Full support (Chromium-based)

Required browser capabilities:

  • WebAssembly (instantiation and streaming)
  • Web Workers (ES module workers)
  • ES2022+ features (private class fields, structuredClone)
  • CSS custom properties

No IE11, legacy Edge (EdgeHTML), or polyfill path is provided.

Node.js Support

RuntimeMinimum VersionNotes
Node.js20.19.0+WASM runs natively, no polyfills needed

The WASM engine works identically in Node.js. Unlike the previous JS engine, there is no need for @peculiar/webcrypto or any crypto polyfill -- all crypto operations are compiled into the WASM binary.


Worker Architecture

PDF.js uses a Web Worker for CPU-intensive parsing and rendering.

Explicit workerUrl — no auto-resolution

workerUrl is a required config option. VerifyKitProvider throws if it is missing, and VerifyKit.create() behaves the same. There is no CDN auto-detection and no fallback guess: the worker must be a URL the application chooses, because only the application knows whether it can reach a CDN, what its CSP allows, and where its static assets live.

Whatever you set as workerUrl is applied before the first document is opened, on every code path that reaches pdf.js — the ordering does not depend on which effect in your app happened to run first.

How pdf.js is loaded (v0.6.0+)

pdf.js is bundled into this package and loaded lazily, on first use rather than at import time. Both properties are load-bearing, and both exist because the obvious alternatives break in a real application:

  • Lazy — a static top-level import is evaluated during a server render (a 'use client' module is still server-rendered by Next.js) and throws before any component renders.
  • Bundled — leaving pdf.js as an external dependency breaks in the browser under webpack. Shipping it inside a chunk this package emits gives every bundler a plain ES module to code-split on.

A consequence worth knowing: the package is published unminified, because minifier name-mangling corrupts pdf.js's private class fields. Your own bundler minifies it normally, so this does not reach production.

Map polyfills

pdfjs-dist 5.5+ calls TC39 proposal methods that browsers have not shipped yet. VerifyKit polyfills them from its package entry, before anything else loads. The polyfill is SSR-safe: it only defines missing Map methods, which is harmless in Node.

Pinned pdf.js version

pdf.js is pinned to 5.5.207 (exact, no semver range). Reasons:

  1. Unstable TC39 usage. New versions may call additional proposal-stage APIs that the current polyfill does not cover.
  2. Worker/library version coupling. The bundled library and the worker fetched from workerUrl must match exactly.
  3. CMap and font asset compatibility. Version bumps can change these assets, which this package re-ships under cmaps/ and standard_fonts/.

Because the library is bundled and the worker is not, upgrading pdf.js is a breaking change for integrators: every workerUrl in the wild points at the old version. When VerifyKit moves to a new pdf.js, it is called out in the changelog, and you update your workerUrl to match.


Theme System

CSS custom properties on :root with a data-theme attribute:

css
:root {
  --bg: #ffffff;
  --fg: #1a1a1a;
  --primary: #0072c6;
  /* 100+ variables */
}
 
:root[data-theme="dark"] {
  --bg: #1d1d1d;
  --fg: #e0e0e0;
  --primary: #4da6ff;
}

The VerifyKitProvider sets data-theme on <html>. The themePlugin toggles it at runtime. All library styles are scoped to .verifykit-root to avoid conflicts with host application styles.


Security Considerations

Worker Isolation

The PDF.js Web Worker runs in a separate JavaScript context. Polyfills and security patches applied to the main thread have no effect inside the worker. VerifyKit injects polyfills directly into the worker via a Blob URL wrapper.

Blob URL and CSP

Applications with a Content Security Policy must allow:

Content-Security-Policy: worker-src 'self' blob:; script-src 'self' blob:;

If the worker is fetched from a CDN, the CDN origin must be permitted in connect-src:

Content-Security-Policy: connect-src 'self' https://unpkg.com;

Self-host the worker to avoid CDN allowlisting.

WASM and CSP

WebAssembly compilation requires 'wasm-eval' or 'unsafe-eval' in script-src on some browsers. Alternatively, use 'wasm-unsafe-eval':

Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval' blob:;

Trust Store in Memory

The 134 embedded root CA certificates -- 118 from the Adobe Approved Trust List and the 16-certificate Indian CCA hierarchy -- and any additional certificates provided via trustStore config are held in the WASM linear memory for the lifetime of the module. They are not written to disk or persisted to browser storage.

No External Data Transmission

VerifyKit does not send document data, verification results, or telemetry to external servers. The only network requests are:

  1. Worker CDN fetch -- A single import() to download the PDF.js worker (avoidable by self-hosting).
  2. Revocation checking (opt-in) -- When @trexolab/verifykit-plugin-revocation is installed, it makes direct connections to CA infrastructure (CRL distribution points, OCSP responders).

All verification runs entirely client-side in the WASM engine.


Known Limitations

Single Theme per Page

The theme system sets data-theme on <html>, making it global. Multiple VerifyKitProvider instances with different theme modes will conflict. Use a single provider or the same theme mode for all viewers.

Worker URL is a Global Singleton

The generated Blob URL is cached globally and set on pdfjsLib.GlobalWorkerOptions.workerSrc. All PDF.js instances share the same worker URL. Revoking it breaks worker creation for all viewers.

Status Icon Bundle Cost

Drawing status icons on the signature field pulls in pdf-lib (~300 KB minified), and tree-shaking cannot remove it even in an application that never uses the feature. Budget for it when sizing the React package.

No Incremental Verification

Verification processes all signatures in a single pass. There is no API to verify a single signature in isolation.

CMap and Font File Hosting

PDF.js requires CMap and standard font files for CJK text and certain form fields. These files are not bundled by VerifyKit -- consumers must copy them from pdfjs-dist and serve them.