Deep dive: Pluto’s Chrome Extension for generating Web Proofs

May 1, 2025

Deep dive: Pluto’s Chrome Extension for generating Web Proofs

TL;DR: Pluto's Chrome extension enables users to generate "Web Proofs" – our term for cryptographic attestations of public or authenticated data (e.g. verifying your Venmo balance or amazon purchase) – directly in the browser with strong privacy guarantees. Web Proofs allow users to privately and verifiably demonstrate facts about their online data without exposing personal details. In this post, I'll dive into the extension's architecture and the technical decisions behind it, focusing on how we leveraged Chrome's multi-context extension model, WebAssembly for the computationally expensive proof generation, and various browser APIs to securely prove web data.

Background

A Web Proof is a cryptographic statement signed inside a Trusted Execution Environment (TEE). It proves both the source (the actual website) and the integrity (the actual data returned) for any internet data, like "I have $100 in my Venmo account" without revealing any other private information. The proof is independently verifiable because it's signed by tamper-proof hardware using a unique cryptographic signature.

Under the hood, the browser connects to a Notary running inside a TEE, verifies it's real (e.g., this is trusted hardware from Intel), and streams the relevant website data through it. The Notary extracts only the requested fields, hashes them, signs the hash, and returns the proof - so anyone can verify the claim without ever seeing your private data or needing to fetch it themselves. These proofs can then be verified onchain - allowing for very interesting data portability and verification.

I am not a networking or hardware guru, but this is roughly how it works. Our chrome extension handles the client-side portion of setting this up securely so the user to generate a proof over any web data.

Extension Architecture Overview

Building an extension for complex tasks like proof generation means coordinating multiple execution contexts in Chrome. Unlike a typical web app, a Chrome extension isn't just a page of JavaScript – it runs across isolated contexts including a background script (service worker), content scripts, injected page scripts, an offscreen document, and even sandboxed iframes. Chrome extensions have elevated permissions so they can use APIs regular web apps can't. Each context has distinct capabilities and limitations, so we split responsibilities among them and set up communication channels to make them work together. Below is a high-level breakdown of these contexts and why each is used in our extension:.

  • Background Service Worker: It coordinates the entire proof generation flow, handles messages between all the contexts, captures low-level HTTP metadata at the browser network level, and manages long-lived tasks.
  • Content Script: Injected into the parent page, and target webpages (e.g. Reddit, Amazon) to interact with the page's DOM and scripts. Content scripts can read page content and initiate proof flows, and captures full request/response payloads.
  • Injected Page Script: A helper script injected into the parent page's execution context. This is used to run Pluto's Web SDK within the page as if it were native page script, bypassing extension sandboxing limitations.
  • Offscreen Document: A hidden offscreen page used for heavy computation and multi-threading. The offscreen document spawns a Web Worker and this is where proof generation (WebAssembly execution) happens without blocking the UI
  • Sandboxed Iframe: An isolated, invisible iframe used for tasks that need a fully isolated environment – for example, running content in a context not subject to the page's Content Security Policy (CSP) or executing untrusted scripts to parse data.

Chrome Extension Architecture

Below is an abbreviated flow of the process for generating a Web Proof. The rest of the article will expand on this flow and how each context of the extension works. We abbreviate the different communication channels for brevity.

Abbreviations
WM = window.postMessage
CRM = chrome.runtime.sendMessage
CTS = chrome.tabs.sendMessage

  1. The SDK injects the pluto window object so the parent page can call window.pluto.prove() → (WM)
  2. The Content Script forwards this message to the Background script → (CRM)
  3. The Background scripts sends "RequestPermissionsScope" to Sidebar → (CRM)
  4. The Sidebar returns approved permissions to Background script→ (CRM)
  5. The Background script initializes the Offscreen document and opens the popup window for the provided authentication login url. Steps 6 - 9 all happen at the same time once per second while we're polling waiting for the user to login.
  6. The Background script captures cookies and sends them to the Content Script → (CTS)
  7. The Content Script captures the HTML content and forwards it with the cookies to the Background script → (CRM)
  8. The Background forwards HTML & cookies to Offscreen → (CRM)
  9. The Offscreen document continually polls the user-provided prepare script running in the popup window through the Sandbox until the user successfully authenticates → (WM)
  10. User authenticates and the Sandbox sends success message back to the Offscreen document → (WM)
  11. The Offscreen document forwards the poll success message to Background → (CRM)
  12. All data required to generate the proof has been acquired, and the Background alerts the Offscreen document to begin the proof generation process → (CRM)
  13. The Offscreen document initializes the WASM Module and Worker, and sends the proof configuration to the Worker → (Worker API)
  14. The Worker executes WASM, generates the proof, and returns to the Offscreen doc → (Worker API)
  15. The Offscreen forwards the proof result to the Background script → (CRM)
  16. the Background script forwards the proof result to Content Script → (CTS)
  17. Finally, the Content Script returns the proof result to parent page! → (WM)

Background Service Worker

The background script is the extension's central hub. It runs in the background to perform privileged operations and coordinate between other parts. We use the background for tasks that require broad access or persistence, such as managing extension state, calling browser APIs (like networking and identity), and routing messages between the different contexts. For example, when a content script or UI requests a proof generation, the background will kick off the process and keep track of progress. A message handler with an action enum to differentiate requests, ensuring a clean separation of concerns. It will receive a message from one context, and route it to the appropriate next step and context in the flow. For instance, a simplified snippet of our background message listener:

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    switch (message.action as AppActions) {
      case AppActions.StartProveProcess:
        startProveProcessHandler(
          sendResponse,
          message.proofSource,
          message.proverMode,
          message.manifestUrl,
          tabId,
        );
        break;

       case AppActions.UpdateLoadingStatus:
        setProofLoadingHandler(message.loading);
        if (sender?.tab?.id) {
          chrome.tabs.sendMessage(sender?.tab?.id, {
            target: AppTargets.Parent,
            action: AppActions.UpdateLoadingStatus,
            loading: message.loading,
          });
        }
        break;

Content Scripts & Injected Page Scripts (Pluto SDK)

Content scripts are scripts that we inject into web pages to interact with the page content. They run in an isolated context embedded in the page, meaning they can read the DOM of the page and call Web APIs, but they do not share the page's JavaScript environment or variables.

Because content scripts run in an "isolated world," they can't directly call functions defined by the webpage or access JS objects of the site. In cases where we need to tap into the page's own scripts or intercept network calls made by the page, we use an injected page script. The content script injects a <script> tag into the parent host page to run code as if it were part of the page itself. That injected script can hook into page JS variables - this is how our developers are able to trigger proof generation from their webpage. When a user has the chrome extension installed, this SDK is injected into the page, and a tiny API on window.pluto becomes available to call.

// sdk-injector.ts
const script = document.createElement('script')
script.setAttribute('type', 'text/javascript')
script.setAttribute('async', 'true')
const url = chrome.runtime.getURL('parent-page/pluto-sdk.js')
script.setAttribute('src', url)
;(document.head || document.documentElement).appendChild(script)
// parent-page/pluto-sdk.js
window.pluto = {
  prove: async (manifestUrl: any, proverMode?: string) => {
    window.postMessage({
      target: AppTargets.Parent,
      action: AppActions.StartProveProcess,
      proofSource: ProofSourceEnum.ParentPage,
      manifestUrl,
      proverMode,
    });
  },

The parent page content scripts act as a bridge, using window.postMessage events between the SDK (page context) and the extension's background worker. This bridge script keeps extension APIs out of the page context, maintaining the Chrome security boundary while still giving the SDK a clean way to talk to the extension.

Offscreen Document

One of the trickier parts of developing this extension was performing the heavy WASM computation required for proof generation. For this, we used Chrome's Offscreen Document capability. An offscreen document is a hidden extension page (with a DOM and full web context) that we can create on the fly. It runs in the background without a UI, but unlike the service worker, it can use web APIs. The background script cannot spawn Web Workers - attempting this causes a "worker in a worker" error. Offscreen being a full Window context means we can spawn Workers there, and run WebAssembly (WASM) in a multi-threaded environment. Proof generation is very CPU-intensive and takes a while. It was not possible to run the heavy WASM execution elsewhere. Running WASM execution in other contexts hangs the main thread. Offscreen provides a separate context that can churn on the CPU without blocking the main event loop.

We structured our offscreen script to act as a computation engine: it receives a message (with the data needed to prove), loads the large cryptographic artifacts (circuits, etc), spawns a Web Worker, the Worker generates a proof, and then sends results back.

Github excerpt

Sandboxed Iframes

Another context we used is a sandboxed iframe, which can be used for running unprivileged or less-trusted code. Every data-collection flow—Venmo, Reddit, etc—goes through the sandboxed iframe. This is how we're able to safely run user-generated, arbitrary, untrusted scripts to obtain data. The sandboxed iframe helped deal with certain Content Security Policy (CSP) restrictions. By default, extension scripts cannot use eval() or dynamically create functions, and even WebAssembly can be restricted by CSP.

A sandboxed page (declared in the manifest's "sandbox" key) runs with a much stricter environment – it has no access to extension APIs or data. This means even if that code tries something risky, it can't directly impact our extension; we only take the specific output we need via messaging.

"content_security_policy": {
    "sandbox": "sandbox allow-scripts allow-forms allow-popups allow-modals; script-src * 'unsafe-inline' 'unsafe-eval'; child-src *;"
  },

Before the offscreen document can begin proof generation, it must wait for user-specific auth tokens (cookies, localStorage items, etc.) A popup window is oepned from the user's prepare.js script. The offscreen page then polls a sandboxed iframe, passing in the HTML collected by the popup's content script and the cookies captured by the background service worker. Inside the sandbox, the user‑supplied prepare.js runs with those inputs, and edits the manifest object, which is our proof configuration object, adding headers and other requested values. When this prepare script returns true (this is how they must be structured), this means the user successfully authenticated, and the sandbox sends the completed manifest object back to offscreen for proof generation. Below is an example prepare.js script:

// prepare.js
function prepare(ctx, manifest) {
  const cookies = ctx.cookies

  if (cookies['api_access_token']) {
    manifest.request.set('authToken', cookies['api_access_token'].value)
    return true
  }

  return false
}

Because only JSON messages cross the iframe boundary, the extension's privileged contexts stay safe, while the sandbox does all DOM parsing and token extraction. Once the conditions of the users script are satisfied, we then have all the data to begin our proof generation. This architecture—background orchestration, offscreen computation, and a polled sandbox for auth readiness—lets Pluto capture authenticated responses and generate proofs without exposing tokens or page internals to any untrusted contexts or even Pluto itself.

WebAssembly for Proof Generation

The actual code and process for generating the proofs are outside the scope of this article. However, we will discuss how and why we chose to use WASM in the extension to generate proofs.

Generating a proof is computationally intensive – lots of big integer math, cryptographic hashing, and circuit logic. JavaScript is too slow for this task, so our proof generation logic is written in Rust and compiled to WASM and a JS/TS interface. WebAssembly gives us near-native performance inside the browser, which is necessary to feasibly generate proofs.

As discussed above, we load and initialize the WASM module in the extension's offscreen context so we can create a Worker to handle the heavy computation. The WASM module uses multi-threading, so we spread proof computation across multiple cores if they're available.

  1. Initialize Worker
// offscreen.ts
async function initWorker() {
  try {
    const response = await fetch(
      chrome.runtime.getURL('workers/offscreenWorker.js')
    );
    const workerContent = await response.text();
    const blob = new Blob([workerContent], { type: 'application/javascript' });
    const workerUrl = URL.createObjectURL(blob);
    const worker = new Worker(workerUrl);
  1. Loading the WASM binary - loading as an ArrayBuffer gives the off‑screen context full control over when and where the module is instantiate and lets you use the same shared_memory the worker pool will use for multithreaded proof generation.
// offscreen.ts
async function getWasmBytes(): Promise<ArrayBuffer> {
  const wasmUrl = chrome.runtime.getURL('wasm/prover/pkg/client_wasm_bg.wasm')
  try {
    const response = await fetch(wasmUrl)
    if (!response.ok) {
      throw new Error(`Failed to fetch wasm: ${response.statusText}`)
    }
    const arrayBuffer = await response.arrayBuffer()
    return arrayBuffer
  } catch (error) {
    throw error
  }
}
  1. Initialize WASM with shared memory
// offscreen.ts
// Initialize WASM with shared memory, this is necessary to correctly initialize
// the shared thread pool. We require threading for our wasm execution.
const memory = new WebAssembly.Memory({
  initial: 16384, // 256 pages = 16MB
  maximum: 65536, // 1024 pages = 64MB
  shared: true
})

this.wasmInstance = await init(wasmURL, memory)
this.sharedMemory = memory
const numConcurrency = navigator.hardwareConcurrency
await initThreadPool(numConcurrency)
  1. Pass to worker
// offscreen.ts
worker.postMessage({
  proverConfig: config,
  proving_params: this.provingParams,
  wasm_bytes: this.wasmBytes,
  circuit_wasm_bytes: this.circuitWasmBytes,
  shared_memory: this.sharedMemory
})

Generating proofs requires not just the circuits (in WASM) but also circuit artifacts. These can be large (multiple megabytes), so our extension downloads them once (or includes some of them in the package) and then stores them. We utilized IndexedDB (which is available in the offscreen context) to persist these binaries between sessions.

Side Panel

Although a UI isn't 100% necessary for the proof generation process, it allows us to offer a superior UX during the process. We chose to use the chrome.sidePanel API for best UX. For example, we ensure users accept to sharing read access to their data. Additionally, the sidebar UI keeps track of loading state as the proof process flows. However, all of this functionality is also available on the parent page to the developer through our APIs.

Side Panel

Security and Trust Assumptions

Throughout these approaches, user consent and security are our highest priority. We only operate after the user actively triggers a proof (no background snooping), and we limit our permissions to just the sites the user is requesting. All data fetched is used transiently to produce a proof, and the proof itself is a small cryptographic artifact that reveals nothing except the statement being proven (e.g. "User has at least 500 karma on Reddit"). By keeping the data handling client-side in the extension, we avoid having to trust an external server with the user's private information – the extension serves as a personal prover that the user controls and developers can tap into. However, client side proving is still in its infancy (mostly due to performance limitations).

Notable Issues

A few notable challenges we encountered:

  • Sharing Memory - A major bug causing performance limitations occurred because we were not properly using WASM's shared memory. For a while we were incorrectly initializing memory in the wrong context which was limiting us. By initializing the memory in the main thread, we could share this memory and allow access to it in the primary proving worker threads. Voila!

  • WASM error handling - we needed to catch errors because we ran into limitations with what could catch. We override console.error to grab WASM "panicked" messages—since WASM panics aren't caught through normal try/catch . WASM error handling

  • Proving artifact size - we fetch and cache the proving artifacts up-front because each circuit's WASM and parameter files can be hundreds of megabytes in size. On startup we do a parallel fetch of all required .wasm and setup files and and store them in IndexedDB— so in the futurewe can load them from the local db cache instead of re-downloading huge blobs over the network.

    // indexedDB.ts
    export const openDB = async (): Promise<IDBDatabase> => {
      return new Promise((resolve, reject) => {
        const request = indexedDB.open(DB_NAME, DB_VERSION)
        // more code
      })
    }
    
    // fetch function
    export async function retrieveProvingArtifact(filename: string): Promise<ArrayBuffer> {
      try {
        const url = `${WASM_BASE_URL}/${WEB_PROVER_CIRCUIT_GIT_HASH}/${fullCircuitVersion}/${filename}`
        const cachedData = await getDB(url, WASM_BINARY_STORE_NAME)
    
        if (cachedData) {
          return cachedData
        }
    
        let artifact = await fetchAndCacheArtifact(url)
    
        return artifact
      } catch (error) {
        throw error
      }
    }
    

Conclusion

Building this extension was a rewarding exercise in architecture and creative problem solving. We combined cutting-edge cryptography (zero-knowledge proofs via WASM) with the latest Chrome extension APIs, while juggling multiple isolated scripts running in parallel.

We can now perform non-trivial cryptographic computations purely on the client side and integrate with existing websites securely. This was achieved by carefully composing Chrome extension contexts: a background service worker as the central hub, content and injected scripts to interface with websites, offscreen documents for heavy lifting with WebAssembly, and sandboxed iframes to handle security constraints.

The Chrome extension we built carries us further on our journey to enable the proving and modularity of any data on the web. It was challenging to build, but its a powerful showcase of what web extensions can do with the right architecture.