Sign and verify PDFs with your own certificate, entirely in your browser. Nothing is uploaded.
What it is · Architecture · Stack · Run · Honest limits
Pick a PDF, load your .pfx (PKCS#12) certificate, type its passphrase, optionally place a visible signature on the page, and download a signed PDF. Separately, drop in a signed PDF to see whether it was changed after signing and who the certificate claims the signer is.
The certificate and passphrase are read and used only in the browser tab. There is no server, so privacy is structural, not a promise: no endpoint exists that could receive a secret. Part of AV LABS.
One static SPA, no backend. How the bytes are handled, end to end:
%%{init: {'theme':'base','themeVariables':{'edgeLabelBackground':'#f6f6f4','lineColor':'#9a9da3','fontFamily':'-apple-system, system-ui, Segoe UI, Roboto, sans-serif','fontSize':'13px'}}}%%
flowchart TB
app(["av/sign · static web app, runs entirely in the browser"])
subgraph browser["No network · the private key never leaves the page · pdf-lib + node-forge"]
direction LR
subgraph s["Sign"]
direction TB
si[("PDF + certificate (PKCS#12) + passphrase")]:::data
s1["Reserve a signature placeholder:<br/>/ByteRange + empty /Contents"]
s2["Hash the signed byte range (SHA-256)"]
s3["Sign the hash with the private key:<br/>detached PKCS#7 / CMS"]
s4["Embed the CMS into /Contents"]
so[("Signed PDF")]:::data
si --> s1 --> s2 --> s3 --> s4 --> so
end
subgraph v["Verify"]
direction TB
vi[("A signed PDF")]:::data
v1["Read /ByteRange + the embedded CMS"]
v2["Re-hash the byte range,<br/>compare to the signed digest"]
v3["Verify the CMS against its certificate,<br/>read the signer fields"]
v4["Verdict: intact / modified / appended<br/>(integrity, not trust)"]
vi --> v1 --> v2 --> v3 --> v4
end
end
app --> s
app --> v
classDef default fill:#ffffff,stroke:#cfd0d4,color:#0a0a0b;
classDef data fill:#efeeec,stroke:#c4c5c9,color:#0a0a0b;
style browser fill:#f6f6f4,stroke:#dcdcda,color:#6b6e76;
style s fill:#fbfbfa,stroke:#e6e6e4,color:#0a0a0b;
style v fill:#fbfbfa,stroke:#e6e6e4,color:#0a0a0b;
Decision record (goal, stack debate, spec, plan) in docs/.
- React 19 + Vite 6 - static SPA, no server
- Tailwind v4 + shadcn primitives, on the AV LABS token cascade
pdf-lib,@signpdf/*,node-forge- in-browser signingpdf-signature-reader- in-browser verificationpdfjs-dist- page rendering for placement- TypeScript 5.7, Biome (format + lint)
- Vitest (crypto round-trip) + Playwright (the e2e gate, against the production build)
Needs Node 22+.
npm install
npm run dev # http://localhost:5174npm run typecheck
npm run lint # Biome
npm test # Vitest: sign/verify round-trip
npm run e2e # Playwright against the production build
npm run build
npm run fixtures # regenerate the non-confidential test PDFsThe signing path is proven by the Vitest round-trip (a real signature: digest match plus a valid RSA signature over the signed attributes) and by Playwright, which signs the bundled sample in a real browser and verifies the result reads as intact.
Local verification proves two things: the document has not changed since it was signed, and the signature is cryptographically valid for the certificate embedded in the file. It does not prove trust. It does not check the certificate against a trusted authority, does not check revocation (CRL/OCSP), and does not validate a trusted timestamp. A file can pass this check and still be signed with an untrusted, self-issued, expired, or revoked certificate. The app states this under every verification result and never renders a bare "valid" badge.
In Brazil, a signed PDF can also be confirmed on the official government validator (ICP-Brasil) at validar.iti.gov.br.
V1 scope is one user, one signature, sign and verify. Trusted timestamps, trust-chain and revocation validation, multi-signer flows, and any server are out of scope by design.
MIT (c) 2026 Arthur Vasconcellos.