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
Tip: Click the diagram to open it fullscreen. Use scroll to zoom, drag to pan, and keyboard shortcuts (+, -, 0, Esc).
| Color | Package | Description |
|---|---|---|
| 🔵 Indigo | verifykit-react | React components, hooks, viewer with toolbar, search, zoom, and signature panel |
| 🟣 Purple | verifykit-vanilla | Zero-build UMD/ESM viewer for CDN and vanilla JS projects |
| 🟢 Green | verifykit-core | Verification engine, trust store, crypto, chain builder, CMS parser, PAdES detection |
| 🟡 Amber | verifykit-plugin-revocation | Online OCSP and CRL certificate revocation checking |
Verification Pipeline
The verification engine runs eight independent checks on every signature:
| # | Check | What it validates |
|---|---|---|
| 1 | Document Integrity | ByteRange coverage and hash match |
| 2 | Cryptographic Signature | RSA, ECDSA, or Ed25519 signature verification |
| 3 | Certificate Chain | Chain of trust to a known root CA |
| 4 | Certificate Expiry | Validity period at signing time |
| 5 | RFC 3161 Timestamp | Trusted timestamp token verification |
| 6 | Revocation Status | CRL and OCSP revocation data |
| 7 | Algorithm Strength | Rejects MD5/MD2; SHA-1 is scored by policy — see below |
| 8 | Extended Key Usage | Document 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.
| Package | What it contains | Depends on |
|---|---|---|
@trexolab/verifykit-core | The Rust/WASM verification engine, the embedded trust store, and a thin TypeScript wrapper over it. Headless: no React, no DOM. | -- |
@trexolab/verifykit-react | The viewer, the plugin system, the signature and certificate UI, hooks, themes and translations. | core |
@trexolab/verifykit-vanilla | A zero-build UMD/ESM drop-in wrapping the React viewer behind an imperative VerifyKit.create() API. React is bundled in. | react |
@trexolab/verifykit-plugin-revocation | Online 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:
- Initializes the WASM module
- Converts inputs (
ArrayBuffer,File, URLstring) toUint8Arrayfor WASM - Rehydrates ISO 8601 date strings from WASM output into JavaScript
Dateobjects - Runs plugin-based revocation checking as a post-processing step
Why Rust/WASM
| Aspect | Previous JS Engine | Rust/WASM Engine |
|---|---|---|
| Crypto | Web Crypto API plus four npm crypto/ASN.1 libraries | Compiled into the WASM binary |
| Dependencies | ~8 npm runtime deps | 0 npm runtime deps |
| Performance | Baseline | 3.7x faster |
| Trust store | PEM strings parsed at runtime | 134 DER certificates compiled into WASM binary |
| Node.js | Required @peculiar/webcrypto polyfill | Works 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
| Category | Algorithms |
|---|---|
| Signature | RSA (PKCS#1 v1.5, PSS), ECDSA (P-256, P-384, P-521), Ed25519 |
| Hash | SHA-1 (legacy), SHA-256, SHA-384, SHA-512, MD5 (detection only) |
| Certificate | X.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:
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:
| Check | What It Verifies |
|---|---|
integrityCheck | Document bytes match the signed range (tampering detection) |
signatureCheck | Cryptographic signature is mathematically valid |
certificateChainCheck | Certificate chains to a trusted root CA |
expiryCheck | Certificates were valid at signing time |
timestampCheck | RFC 3161 timestamp is present and valid |
revocationCheck | Certificate not revoked (CRL/OCSP) |
algorithmCheck | Algorithms meet the configured strength policy (RSA >= 2048; SHA-1 per algorithmPolicy) |
ekuCheck | Extended 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:
| Status | Meaning |
|---|---|
valid | All checks pass |
warning | Minor issues (no timestamp, algorithm concerns) |
invalid | Signature broken, certificate untrusted, or document modified |
unknown | Could not determine (missing data) |
PAdES Conformance
The engine detects PAdES conformance levels:
| Level | Description |
|---|---|
B-B | Basic signature (CMS signed data) |
B-T | With trusted timestamp |
B-LT | With long-term validation data (CRL/OCSP embedded) |
B-LTA | With archive timestamp for long-term archival |
Plugin System
VerifyKit has two separate plugin systems:
Core Plugins (Verification)
Extend the verification engine. Implemented as VerifyKitPlugin:
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:
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:
- Resolve -- Flatten composed plugins (meta-plugins like
defaultLayoutPlugin), deduplicate byname - Install -- Call
install(ctx)on each plugin in order - Collect UI -- Gather toolbar slots, sidebar tabs, overlays, and page overlays from all plugins
- Dispatch events -- Route lifecycle events to all plugins
- Destroy -- Clean up all plugins on unmount
defaultLayoutPlugin
A meta-plugin that composes all 24 viewer plugins into a batteries-included experience:
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:
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
| Category | Keys |
|---|---|
| Document | document, fileBuffer, fileName, loadState, errorMessage |
| Navigation | currentPage, totalPages |
| Zoom | scale, fitMode |
| Rotation | rotation |
| Layout | scrollMode, spreadMode, cursorTool |
| Theme | themeMode |
| Fullscreen | isFullscreen |
| Verification | signatures, unsignedFields, verificationStatus |
| UI panels | sidebarOpen, sidebarTab, sigPanelOpen, findOpen |
| Password | passwordNeeded, 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 insidebatch()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:
toolbarPlugin({
transform: (slots) => {
delete slots.Print
delete slots.Download
slots.MyButton = ({ store }) => <button>Custom</button>
return slots
},
})What Each Package Publishes
| Package | Module formats | Also published |
|---|---|---|
@trexolab/verifykit-core | ESM + CJS | Type 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-revocation | ESM + CJS | Type declarations |
@trexolab/verifykit-react | ESM + CJS | Type declarations, one stylesheet, and the pdf.js CMap and standard-font assets |
@trexolab/verifykit-vanilla | UMD + ESM | One 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
| Browser | Minimum Version | Notes |
|---|---|---|
| Chrome | 109+ | Full support |
| Firefox | 115+ | Full support |
| Safari | 16.4+ | Full support |
| Edge | 109+ | 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
| Runtime | Minimum Version | Notes |
|---|---|---|
| Node.js | 20.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:
- Unstable TC39 usage. New versions may call additional proposal-stage APIs that the current polyfill does not cover.
- Worker/library version coupling. The bundled library and the worker fetched from
workerUrlmust match exactly. - CMap and font asset compatibility. Version bumps can change these assets, which this package re-ships under
cmaps/andstandard_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:
: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:
- Worker CDN fetch -- A single
import()to download the PDF.js worker (avoidable by self-hosting). - Revocation checking (opt-in) -- When
@trexolab/verifykit-plugin-revocationis 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.