I wanted to create a demo that does something browsers were never meant to do: the browser tab generates a private key, obtains a real, publicly trusted TLS certificate for a real public hostname, and then serves live HTTPS to anyone on the internet — say, your phone. No account, no install, and the private key never leaves the tab.
The working demo is at snif.host/demo.
This post is about how that works: running a TLS server in WebAssembly over memory buffers, getting a CA to issue a certificate via fetch(), and the pile of gotchas in between.
Why do this to a browser
The demo exists because of SNIF — SNI-based TLS Forwarder, an Apache-2.0 solution that gives a device behind NAT a browser-trusted HTTPS hostname without a public IP, a port forward, or handing its TLS private key to a tunnel provider. The device keeps its own key; a public relay forwards still-encrypted streams by their TLS SNI record. TLS terminates on the device, so the relay can't read or alter anything. This IETF Internet-Draft describes the SNIF protocol suite.
That claim — the relay can't read your traffic — is the kind of claim you shouldn't take from a README. Diagrams prove nothing. But a browser tab is itself a device behind NAT: it has no public IP, no listening sockets, no inbound reachability at all. Even worse — its outbound capabilities are limited to HTTPS and WebSockets, no raw TCP/UDP sockets. If a browser tab can become a SNIF connector, it would be running in one of the most locked-down network environments there are.
So that became the spec: the tab must do everything a native SNIF connector does. Generate the key. Get the cert. Accept visitors' TLS handshakes. Serve pages.
The constraint that shapes everything: browsers have no sockets
A SNIF connector talks to the relay over raw TCP. Browser JavaScript — and browser wasm — has no raw TCP and no listening sockets. That's a sandbox boundary, not a wasm limitation; Emscripten's POSIX socket emulation just tunnels over WebSockets anyway, and the Direct Sockets API is restricted to Isolated Web Apps, unusable for a public page.
So something has to translate browser transport into the relay's TCP. The answer is a websockify-style WebSocket↔TCP bridge sitting next to the relay: every byte of the connector's relay connections rides a binary WebSocket to the bridge, which splices it onto a plain TCP socket. Two fixed routes (/snif/ctl → the relay's control port, /snif/srv → its forward pool), fixed targets only — the bridge can't be coerced into connecting anywhere else, so there's no SSRF surface.
Crucially, the relay daemon itself is not modified. The bridge is pure demo infrastructure, and — this matters for the trust model — it only ever sees opaque bytes. The end-to-end TLS is terminated inside the tab, not at the bridge. The wss:// layer is just transport framing.
The bridge is ~300 lines of dependency-free PHP — a single non-blocking stream_select() loop with its own RFC 6455 handshake and frame de-framing. Not because PHP is anyone's idea of a WebSocket runtime, but because the host it runs on is a PHP stack, and one small php-cli daemon beats importing Node onto a relay host.
Plot twist: the browser has to be the TLS server
Here's the architectural fact that shaped all the crypto work. In the SNIF protocol, on the control connection the relay is the TLS client and the connector is the TLS server. That's not an accident — it's how the connector proves it holds the private key for its hostname: it completes a TLS handshake presenting its certificate, and the relay matches the hostname the connector claims against the certificate's CN.
And when a visitor connects, the relay blind-pipes the visitor's raw TLS bytes to the connector, which has to SSL_accept them — again, be the server.
So "run a SNIF connector in a tab" really means: run a TLS 1.3 server inside WebAssembly, with no sockets, over memory buffers fed by a WebSocket.
Keeping the wasm small: only the TLS engine goes in
OpenSSL compiled to wasm is famously heavy and painful. The demo uses wolfSSL instead, and — more importantly — draws a hard line on what goes into wasm at all:
| Piece | Where it runs | wasm cost |
|---|---|---|
| SNIF protocol framing (text lines) | JavaScript | 0 |
| Key generation + CSR | wolfCrypt wasm shim | ~155 KB |
| Certificate issuance HTTP flow | fetch() |
0 |
| WebSocket transport | JS WebSocket
|
0 |
| TLS server over memory buffers | wolfSSL wasm shim | ~535 KB, lazy-loaded |
The TLS module only loads when you click "Make it live" — the certificate-issuance page stays light.
The TLS-server shim is the interesting part. wolfSSL lets you replace its transport with callbacks (wolfSSL_CTX_SetIORecv / wolfSSL_CTX_SetIOSend), so the wasm module runs a TLS server against two in-memory byte queues and never touches a socket API. JavaScript feeds inbound WebSocket bytes into one queue and drains outbound bytes from the other.
Getting a real certificate with fetch()
SNIF's certificate issuance runs through a CA proxy that fronts ACME (Let's Encrypt does the actual issuing). The native connector drives it with libcurl; the whole flow can run on HTTPS or on plain HTTP:
-
GETthe init endpoint → the relay allocates you a hostname zone and returns it in anX-SNIF-CNheader. It's a wildcard, like*.snif-xxxx-yyyy.snif.xyz. - Generate an EC key and a PKCS#10 CSR in wasm, with the full wildcard CN as the subject.
-
PUTthe CSR to the cert API. - Authorize. The native connector prints an authorization link for a human to open in a browser; the demo is already in a browser, so it authorizes with a CAPTCHA instead — you solve a Cloudflare Turnstile widget and the token goes up in an
X-SNIF-Captchaheader. This is the only human step, and it's what rate-limits a free public cert API against abuse (per-IP and global daily caps). - Poll for the
.crt— you get503while ACME is doing its thing, then200with a real, browser-trusted certificate chain. Typically well under 30 seconds.
The default zero-conf behavior of the SNIF connector is to use plain http:// for the CA proxy calls — from an HTTPS page that's a hard mixed-content block, not a warning you can click through. The connector protocol already had an API-base override, so the demo points it at the HTTPS init origin and the whole flow stays on one secure origin, with CORS opened up for the PUT and the custom headers.
The private key never goes anywhere in this flow. The CA sees a CSR (public key + signature); the certificate comes back for the key that was generated in your tab and lives in your tab.
Gotchas, in the order they drew blood
A PKCS#10 CSR's version must be 0. wolfSSL's wc_InitCert defaults the version field to X.509v3 (that's 2), which is correct for certificates and fatally wrong for CSRs. OpenSSL — and therefore Let's Encrypt — rejects the signature. One line (req.version = 0), found the hard way.
wolfSSL needs --disable-asm for wasm. wolfCrypt's cpuid.c contains x86 inline assembly that obviously won't compile to wasm. Fine — but not documented anywhere as a wasm recipe.
Do NOT --disable-filesystem in Emscripten, even though you have no files. wolfCrypt seeds its RNG from /dev/urandom, which Emscripten emulates via crypto.getRandomValues. Disable the virtual filesystem to save size and the RNG init fails with a cryptic -199, taking all of wolfSSL_Init down with it.
RSA-4096 keygen in wasm is brutally slow. The native SNIF connector's lowest-common-denominator fallback is RSA-4096; in wasm that's a multi-second stall. The demo generates EC keys instead — and the CA proxy advertises its preferred curve in a response header (the public relay currently steers P-384), with the demo honoring the policy and falling back to P-256. The relay is key-type-agnostic; nothing in the protocol pins the algorithm.
Your hostname is derived from your key. The allocated CN is a wildcard zone; the connector's actual hostname is a stable label under it, derived from a digest of the private key DER. It's reproducible by the connector, which holds the private key, but cannot be derived by anyone who has only the wildcard zone CN. The per-device label never appears in CT logs, only the wildcard does — deliberate hostname privacy. Amusing detail: in the SNIF connector code, the digest is MD5 (a non-security, label-only use). The connector in the browser does not need to use exactly the same derivation rules as the original SNIF code, as long as the hostname is stable per connector. But it was a matter of principle. So the demo carries a tiny pure-JS MD5 for exactly this.
Serving HTTPS from a tab
With a cert and the TLS engine in place, the connector logic itself is small, transport-neutral JavaScript that mirrors the relay's actual source:
The control connection is pure TLS from byte zero — remember, the relay connects as a TLS client, so the tab's wasm TLS server accepts the relay's handshake, presenting its shiny new certificate. Then it sends SNIF LISTEN <hostname> as application data and waits.
When a visitor hits https://<your-hostname>/, the relay sends SNIF CONNECT on the control channel. The tab opens a second WebSocket to the bridge, sends a plaintext SNIF ACCEPT <connid> preamble, and then the relay blind-pipes the visitor's raw TLS bytes through. The tab's wasm TLS server accepts that handshake too, and answers the HTTP request with a small live page.
At no point does the relay — or the bridge — hold a key or see plaintext. What they see: which hostname (that's the SNI routing, it's the design), source IPs, timing, volume. What they can't do: read, modify, or impersonate. Impersonation would require getting a new certificate mis-issued for the name, which lands in public Certificate Transparency logs — and SNIF ships snifdog, a CT watchdog pinned to your key's SPKI, so that move is detected rather than trusted away. The demo doesn't ask you to believe the trust model; it lets you run both ends of it.
More than just one page
A single web page served by a browser tab is already a proof of concept. But I wanted the demo to do something a visitor would actually use, so it grew into a small file-sharing server, served entirely by the tab. Drop files into the demo page and each one becomes a real https:// link under your hostname; anyone who opens your URL sees a live listing, and can send files back to your tab.
Under the hood that's an actual — if minimal — web server, in the same transport-neutral JavaScript, on top of the wasm TLS engine. Requests come off the decrypted stream and get routed: GET / renders the listing page, GET /d/<id> serves a file (inline, or as an attachment with ?dl=1), PUT /u/<name> accepts a visitor upload — raw body, no multipart — capped at 25 MB so a stranger can't balloon the tab's memory. Responses are hand-assembled HTTP/1.0 byte arrays: status line, Content-Length, Content-Disposition, Connection: close.
The part that made me grin: long-polling works. The listing page holds a GET /events request open, and the connector defers the response — a promise that resolves when the file list changes, or times out politely with a 204. So when you drop a file into your tab, everyone currently viewing your page sees it appear, live, no reload. A browser tab, holding a hanging request open for its own visitor, over a TLS session it terminates itself.
Two mundane bits of realism turned out to matter: multi-megabyte downloads are paced against the WebSocket's send queue instead of being buffered whole (backpressure, or the tab's memory eats the file twice), and a visitor who disconnects mid-request tears down any deferred response so the tab doesn't accumulate dead waiters.
And the trust property carries all the way through: the file bytes exist in your tab's memory and in the visitor's browser, and nowhere in between. The relay forwards ciphertext. It's a file transfer through infrastructure that can't see the files.
Honest caveats
The demo optimizes for "no install, no account, nothing to clean up" — and that shows in a few corners worth being honest about.
The private key and certificate live in the browser's localStorage. That's plaintext, same-origin-readable storage — acceptable for a throwaway *.snif.xyz demo identity that gates nothing, and it's what lets a reload come back as the same hostname instead of burning another cert issuance. A real SNIF connector keeps its key on the device it protects; don't put a TLS key that matters in localStorage because a demo did.
The shared files are the opposite: memory only, never persisted anywhere. Reload the tab and your identity survives but the files are gone. And your server has the uptime of a browser tab — close it, or let the laptop sleep, and your hostname goes dark until the tab is back.
Open the demo in two tabs and it gets funnier: both read the same stored key, so both claim the same hostname, and the relay — by design — announces each incoming visitor connection to every connector listening on that name. Whichever tab answers first serves the request. Since each tab's file list is its own in-memory state, a visitor can get different content from one request to the next. Entertaining to watch; not a mode you'd ship.
None of these are protocol limits — a native connector has real key storage, real persistence, and one process per hostname. They're the price of doing it in a tab, which is the whole stunt.
Try it
- The demo: https://snif.host/demo/ — solve one CAPTCHA, watch a real cert get issued to your tab, click "Make it live", open the URL on your phone. (Issuance is capacity-limited; if it asks you to come back later, that's the rate limiting working.)
- The demo's source (the connector JS, the wolfSSL wasm shims, the PHP bridge, the mock-relay tests — GPLv3): https://github.com/vesvault/snif-demo
- On an actual device (outbound-only, no published ports):
docker run -d -v snif-etc:/etc/snif ghcr.io/vesvault/snif - The repo (connector, relay, CA proxy, watchdog — all Apache-2.0, all self-hostable): https://github.com/vesvault/snif
- The protocol: draft-zubov-snif · The trust model, precisely: security-model.md
The questions I'm most hoping for are the skeptical ones — what exactly does the relay see, and how would I catch it misbehaving? That boundary is the whole point. Ask away.
Top comments (0)