Cryptographic Signing in the Browser: A Windows Companion App + WebSocket Bridge
Between late 2023 and early 2024 I worked on an electronic signature system for the Supreme Court of Justice of Panama. The brief was simple — let a court clerk sign a PDF in the browser with the same legal weight as ink on paper, using their government-issued smart card. The implementation was several layers deeper than that summary suggests.
The thing modern browsers stopped doing a decade ago is talk directly to hardware. They used to. NPAPI plugins let you wire a smart card driver right into a Java applet inside Internet Explorer or Firefox. NPAPI is dead. WebUSB exists but doesn't speak the protocols our government smart cards use. Web Crypto can sign — but only with keys it generated, and you can't extract a hardware-rooted national-identity key into a browser by design.
So in 2024, a browser by itself cannot produce a legally-valid signature with a citizen's smart card. That's not a bug; it's the threat model. The fix is a small piece of trusted software running on the user's own machine that sits between the browser and the card.
The shape of what I built
A Windows companion app that the court installs on every authorized workstation. It's a native installer (.msi) that drops three things in place:
- The smart card reader's drivers (provided by the card vendor, but the user shouldn't have to source them)
- The Java-based signing service that does the actual cryptographic work
- A WebSocket server bound to localhost, which is how the browser eventually reaches the service
The website (the actual signing UI) lives in a normal browser tab. When a court clerk needs to sign a document, the page opens a WebSocket to the companion app, the app handles everything below the browser layer, and a signed PDF lands back in the page.
[Browser tab] ◀──WebSocket (localhost)──▶ [Companion app: Java service]
│
├──▶ [Smart card reader + drivers]
│ (PKCS#11 to the card)
│
├──▶ [Windows Certificate Store]
│ (certificate chain lookup)
│
└──▶ [Government cloud vault]
(PIN verification +
authorization green light)
A few details in this picture matter a lot:
The browser never sees the PIN. Ever. The companion app prompts the user through a native Windows dialog the browser cannot fake or screen-scrape. The user types their PIN once, the companion verifies it against the government's cloud vault, and the cloud responds with an authorization token bounded to that signing session. If the PIN is wrong, the cloud says no, and nothing else in the chain even gets a chance to fail.
The companion app never sees the document body. The browser hashes the PDF locally, sends only the hash over the WebSocket, gets a detached signature back, and applies it to the original bytes. The companion's threat surface is "signs arbitrary 32-byte digests for an authenticated user who already typed their PIN."
The certificate comes from the Windows Certificate Store. This is the part most "smart card in the browser" architectures skip past. The card holds the private key, but the certificate chain — the proof that the key belongs to a real human authorized by the national PKI — has to be looked up somewhere. We could've stored it on the card, but the canonical Windows place for it is the user's certificate store, populated by the government's CA-issued install package. The companion reads from there, builds the chain, validates against the trusted root, and embeds it into the signed document.
What the Java service is actually doing
For each signing request, in order:
- Receive the document hash over the WebSocket from the page
- Locate the user's signing certificate in the Windows Certificate Store, filtered to the certs issued by the government's national CA
- Prompt for PIN through a native dialog
- Authenticate against the government cloud vault — sends the PIN, receives a session token if valid
- Use PKCS#11 to talk to the smart card reader and ask the card to sign the hash with its private key
- Validate revocation — OCSP first, CRL fallback if the responder is down
- Wrap the signature into PAdES-LTV format — embeds the timestamp, the certificate chain, and the OCSP response into the PDF signature dictionary itself, so the document still validates years later when the responder is gone
- Return the signed bytes over the WebSocket
- Tear down the session — PIN-derived session tokens are use-once
The PAdES-LTV step is the difference between a signature that works today and one that holds up legally in 2034. Without LTV, validators in the future have no way to confirm that the cert chain was valid at signing time — they'd just see an expired cert and reject the document. With LTV, every piece of evidence needed to validate is sealed inside the PDF.
The thing I underestimated
The protocol design was the part I worried about going in. It turned out to be the easy part.
The hard parts were:
Driver distribution. The smart card reader's drivers come from the card vendor, ship with the card, and tend to drift slightly across hardware revisions. We had to test against three reader models and two driver versions. The installer ended up bundling all of them and detecting at runtime.
Localhost WebSocket origin gating. A WebSocket on localhost accepts connections from any browser tab on the machine. If a malicious site loads in another tab while the companion is running, it could try to invoke the signing endpoint. We hard-wired the companion's CORS allowlist to a single origin (the court's domain) and refused everything else.
The user experience of "go install this app first" is genuinely a UX cliff. For the court — IT-managed Windows laptops, controlled rollout, mandatory training — it was fine. For consumer-facing systems with the same need (banks, tax agencies), I'd push much harder for alternative paths: cloud HSM signatures triggered via a separate strong factor (mobile push, app OTP), or mobile signature flows where the phone is the smart card. The desktop-installer cliff is an acceptable cost for a captive workforce; for the public, it isn't.
What I'd put in the runbook
For anyone building a thing like this:
- Pin the JCA provider explicitly. Don't let the JVM pick a default that changes between versions.
- Test with a known-revoked test certificate. If your verification path doesn't reject one from the test PKI, you have no verification — you have a friendly nod.
- Treat the timestamp authority as a critical dependency. No timestamp = no LTV. Have a fallback configured.
- Log signature events but not signature payloads. "User X signed document Y at time T" is an audit record. The full PKCS#7 blob in your logs is gigabytes of garbage and a small data leak risk.
- Be paranoid about the WebSocket origin gate. It's the only thing standing between the companion and a phishing page on the same machine.
The hardware crypto and PAdES sides of this are well-trodden territory if you stay close to the standards. Most of the engineering judgment is at the seams: between the browser and the companion, between the companion and the card, between the local card and the remote cloud vault, and between the user and a UI they trust enough to type a PIN into. None of those seams are covered by an RFC.