从 0 到 1 开发一个 Chrome 扩展:TFHE 同态加密密钥管理器

0 阅读8分钟

概述

最近在项目中提及需要开发一个浏览器插件,这个插件本身功能其实可以在项目中实现的,但是作为多一种渠道实现,故而也开发实现一下。本文就以 TFHE 为实战项目,完整记录从零搭建一个 Chrome MV3 (MV2已经淘汰了吧)扩展的全过程。

这里借助公司的说法,简单描述一下全同态加密技术,全同态加密是一种允许直接对密文进行任意计算的颠覆性加密范式。借助FHE,我们可以在不暴露原始数据内容的前提下,对加密数据执行加法、乘法等基本运算,进而组合成任意复杂的计算函数。计算结果同样以加密形式存在,只有持有密钥的用户才能解密并获取最终的明文结果。这意味着,从数据产生、传输、存储到计算的全生命周期,信息都可以保持加密状态,从而在理论上实现了“零信任”环境下的绝对数据安全。

对于TFHE估计很多人也陌生,这里需要知道一个点,解密私钥和加密公钥、同态计算密钥都在客户端生成,同态计算密钥很大(几十到数百 MB),所以应用最好能值传递一次,后续不用重复。

对于前端来说,TFHE是什么,有兴趣的可以来这里看看:docs.zama.org/tfhe-rs/int…

好了,进入主题,首先列一下插件的核心功能:

  • 在浏览器本地生成 TFHE 同态加密三套密钥(解密私钥、加密公钥、同态计算公钥)
  • 将同态计算公钥上传至服务端
  • 通过 externally_connectable 机制,将密钥安全暴露给白名单域名的 Vue3 前端

技术栈:Vite + JS + TFHE-rs WASM + Chrome MV3


一、基础知识

1.1 扩展的四类脚本

┌─────────────────────────────────────────┐
│  Chrome 扩展                            │
│                                         │
│  ┌──────────┐   ┌────────────────────┐  │
│  │  popup   │   │  background        │  │
│  │ (弹出页)  │   │  (service worker)  │  │
│  └──────────┘   └────────────────────┘  │
│                                         │
│  ┌──────────┐   ┌────────────────────┐  │
│  │ content  │   │  worker            │  │
│  │ (注入页)  │   │  (Web Worker)      │ │
│  └──────────┘   └────────────────────┘  │
└─────────────────────────────────────────┘
  • popup:点击图标弹出的 UI,关闭即销毁,有自己的 DOM
  • background (service worker):无 DOM,事件驱动,处理消息/存储/网络
  • content script:注入到目标网页的脚本,可访问页面 DOM,但与页面 JS 隔离
  • web worker:popup 创建的计算线程,避免阻塞 UI(本项目用于 TFHE 密钥生成)

1.2 脚本间通信

popup  ──postMessage──▶  worker(同源,直接通信)
popup  ──sendMessage──▶  background(扩展内部)
外部页面 ──sendMessage──▶  background(externally_connectable)
content ──window事件──▶  外部页面(同一 DOM 环境)

二、环境准备

node -v   # 建议 18+
npm -v    # 建议 9+

三、初始化项目

mkdir my-tfhe-crx
cd my-tfhe-crx
npm init -y

3.1 安装依赖

# 构建工具
npm install -D vite vite-plugin-wasm vite-plugin-top-level-await

# TFHE WASM 库(固定版本,避免 API 变动)
npm install tfhe@1.3.3

3.2 目录结构

cipherflow-tfhe-crx/
├── public/                  # 静态资源(原样复制到输出目录)
│   ├── manifest.json        # 扩展清单
│   └── icons/
│       ├── icon-16.png
│       ├── icon-48.png
│       └── icon-128.png
├── src/
│   ├── background.js        # Service Worker
│   ├── content.js           # 注入目标页面的脚本
│   ├── tfhe-worker.js       # TFHE 密钥生成 Web Worker
│   ├── popup.js             # 弹出页逻辑
│   └── popup.css            # 弹出页样式
├── example/                 # 文档
├── index.html               # popup 入口 HTML
├── vite.config.js
└── package.json

public/ 目录下的文件会被 Vite 原样复制到输出目录,不经过任何转换。manifest.json 和图标必须放这里。


四、Vite 配置

// vite.config.js
import { defineConfig } from 'vite'
import wasm from 'vite-plugin-wasm'
import topLevelAwait from 'vite-plugin-top-level-await'
import { resolve } from 'path'
import { name, version } from './package.json'

export default defineConfig({
  plugins: [wasm(), topLevelAwait()],
  publicDir: 'public',
  build: {
    outDir: `${name}-${version}`,   // 输出目录带版本号,如 my-tfhe-crx-1.0.0
    emptyOutDir: true,
    target: 'esnext',
    minify: false,                  // 扩展审核时不压缩,方便审核人员阅读
    rollupOptions: {
      input: {
        popup:      resolve(__dirname, 'index.html'),
        background: resolve(__dirname, 'src/background.js'),
        worker:     resolve(__dirname, 'src/tfhe-worker.js'),
        content:    resolve(__dirname, 'src/content.js'),
      },
      output: {
        // background / worker / content 必须在根目录,manifest.json 直接引用
        entryFileNames: (chunk) => {
          if (['background', 'worker', 'content'].includes(chunk.name)) {
            return '[name].js'
          }
          return 'assets/[name].[hash].js'
        },
        chunkFileNames: 'assets/[name].[hash].js',
        assetFileNames: 'assets/[name].[hash].[ext]',
      },
    },
  },
  // Web Worker 内部也需要 WASM 支持
  worker: {
    format: 'es',
    plugins: () => [wasm(), topLevelAwait()],
  },
})

关键点:

  • entryFileNames 函数区分入口:background.js / worker.js / content.js 放根目录,popup 相关文件放 assets/
  • target: 'esnext' 确保不降级 ES 语法,WASM 需要现代语法支持
  • worker.plugins 单独配置,Web Worker 的构建流水线独立于主线程

五、manifest.json

{
  "manifest_version": 3,
  "name": "CipherFlow TFHE",
  "version": "1.0.0",
  "description": "同态加密密钥生成与管理 — 基于 TFHE-rs WASM",

  "permissions": ["storage", "activeTab", "tabs", "unlimitedStorage"],

  "host_permissions": [
    "http://localhost:8878/*",
    "http://cipherflow-test.cn/*",
    "https://cipherflow.cn/*",
    "https://cipherflow.ai/*"
  ],

  "externally_connectable": {
    "matches": [
      "http://localhost:8878/*",
      "http://cipherflow-test.cn/*",
      "https://cipherflow.cn/*",
      "https://cipherflow.ai/*"
    ]
  },

  "action": {
    "default_popup": "index.html",
    "default_title": "CipherFlow TFHE",
    "default_icon": {
      "16": "icons/icon-16.png",
      "48": "icons/icon-48.png",
      "128": "icons/icon-128.png"
    }
  },

  "background": {
    "service_worker": "background.js",
    "type": "module"
  },

  "content_scripts": [
    {
      "matches": [
        "http://localhost:8878/*",
        "http://cipherflow-test.cn/*",
        "https://cipherflow.cn/*",
        "https://cipherflow.ai/*"
      ],
      "js": ["content.js"],
      "run_at": "document_start"
    }
  ],

  "icons": {
    "16":  "icons/icon-16.png",
    "48":  "icons/icon-48.png",
    "128": "icons/icon-128.png"
  },

  "content_security_policy": {
    "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
  }
}

字段说明:

字段说明
permissions.storage读写 chrome.storage.local
permissions.unlimitedStorage密钥体积可达数十 MB,需要突破默认 5MB 限制
permissions.activeTab + tabspopup 读取当前 Tab 的 URL 做域名检测
host_permissions允许向这些域名发起 fetch / XHR
externally_connectable允许这些域名的页面通过 chrome.runtime.sendMessage 与本扩展通信
content_scripts.run_at: "document_start"在页面 JS 执行前注入,确保 window.__CIPHERFLOW_EXT_ID__ 最早可用
wasm-unsafe-evalMV3 加载 WASM 的必要 CSP 指令

六、各模块实现

6.1 content.js — 桥接扩展与页面

// src/content.js
window.__CIPHERFLOW_EXT_ID__ = chrome.runtime.id

window.dispatchEvent(
  new CustomEvent('cipherflow:ready', {
    detail: { extId: chrome.runtime.id },
  })
)

content.js 运行在扩展的隔离环境中,但可以读写页面的 window。通过注入 __CIPHERFLOW_EXT_ID__,Vue3 页面拿到扩展 ID 后即可调用 chrome.runtime.sendMessage 通信,不需要硬编码扩展 ID(每次安装 ID 都不同)。

6.2 background.js — 消息中枢

// src/background.js
const STORAGE_KEY = 'tfhe_state'

chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'cflo:ping') {
    sendResponse({ ok: true })
    return false
  }

  if (msg.type === 'cflo:get-keys') {
    // sender.origin 自动区分不同域名,实现存储隔离
    const key = `${STORAGE_KEY}:${sender.origin}`
    chrome.storage.local.get(key, (result) => {
      const saved = result[key]
      if (!saved?.hasKeys) {
        sendResponse({ ok: false, error: '密钥尚未生成' })
        return
      }
      sendResponse({
        ok:                  true,
        taskId:              saved.sessionId          ?? null,
        compactPublicKeyB64: saved.compactPublicKeyB64 ?? null,
        clientKeyB64:        saved.clientKeyB64        ?? null,
      })
    })
    return true  // 异步响应必须返回 true,否则通道提前关闭
  }
})

重要: 异步回调中调用 sendResponse 时,监听函数必须 return true,否则 Chrome 会提前关闭消息通道导致响应丢失。

6.3 tfhe-worker.js — 密钥生成

TFHE 密钥生成是 CPU 密集型操作(数十秒),必须放到 Web Worker 中,否则 popup UI 会完全卡死。

// src/tfhe-worker.js
import init, {
  TfheClientKey,
  TfheCompactPublicKey,
  TfheCompressedServerKey,
  TfheConfigBuilder,
  ShortintParameters,
  ShortintParametersName,
} from 'tfhe'

self.onmessage = async (event) => {
  if (event.data.type !== 'generate') return

  try {
    await init()  // 初始化 WASM 模块

    // 使用明确的参数集(与 tfhe/index.ts 参考实现保持一致)
    const params = new ShortintParameters(
      ShortintParametersName.PARAM_MESSAGE_2_CARRY_2_KS_PBS_TUNIFORM_2M128
    )
    const config = TfheConfigBuilder.with_custom_parameters(params).build()

    // ① 解密私钥 — 生成后立即发送,不等后续密钥完成
    const clientKey      = TfheClientKey.generate(config)
    const clientKeyBytes = clientKey.serialize()
    self.postMessage(
      { type: 'key', keyType: 'client', buffer: clientKeyBytes.buffer },
      [clientKeyBytes.buffer]   // Transferable:零拷贝传输
    )

    // ② 加密公钥
    const publicKey      = TfheCompactPublicKey.new(clientKey)
    const publicKeyBytes = publicKey.serialize()
    self.postMessage(
      { type: 'key', keyType: 'public', buffer: publicKeyBytes.buffer },
      [publicKeyBytes.buffer]
    )

    // ③ 同态计算公钥(体积最大,耗时最长)
    const serverKey      = TfheCompressedServerKey.new(clientKey)
    const serverKeyBytes = serverKey.serialize()
    self.postMessage(
      { type: 'key', keyType: 'server', buffer: serverKeyBytes.buffer },
      [serverKeyBytes.buffer]
    )

    self.postMessage({ type: 'done' })
  } catch (err) {
    self.postMessage({ type: 'error', message: String(err) })
  }
}

Transferable 的意义: 普通 postMessage 会深拷贝数据,server key 可能有数百 MB,拷贝耗时极长且双倍占用内存。使用 Transferable 可以将 ArrayBuffer 的所有权从 Worker 转移到主线程,零拷贝、瞬间完成。

流式发送: 每个密钥生成后立即 postMessage,而非等三个全部完成再统一发送。这样 popup 可以逐步渲染每张卡片,用户能实时看到进度。

6.4 popup.js — 域名检测与状态管理

域名检测(白名单校验):

const ALLOWED_ORIGINS = new Set([
  'http://xxx-test.cn',
  'https://xxx.cn'
])

async function detectDomain() {
  try {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
    if (tab?.url) {
      const url = new URL(tab.url)
      // url.host 含端口,url.hostname 不含
      // 两个都检查,兼容带端口和不带端口的域名
      const originWithPort = `${url.protocol}//${url.host}`
      const originNoPort   = `${url.protocol}//${url.hostname}`
      if (ALLOWED_ORIGINS.has(originWithPort) || ALLOWED_ORIGINS.has(originNoPort)) {
        state.baseUrl = originWithPort
        domainBadge.textContent = url.host
        return true
      }
    }
  } catch { /* 扩展内部页面无 url */ }
  return false
}

域名隔离存储:

// 不同域名的密钥互不干扰
function storageKey() {
  return `tfhe_state:${state.baseUrl}`
}

// 存储: tfhe_state:https://cipherflow.cn → { hasKeys, sessionId, ... }
// 存储: tfhe_state:https://cipherflow.ai → { hasKeys, sessionId, ... }

数据持久化:

chrome.storage.local 是 JSON 序列化的,Uint8Array 不能直接存储,需转为 Base64:

function uint8ToBase64Full(bytes) {
  const CHUNK = 8192  // 分块处理,避免大数组调用栈溢出
  let binary = ''
  for (let i = 0; i < bytes.length; i += CHUNK) {
    binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK))
  }
  return btoa(binary)
}

Hex 预览:

密钥预览显示前 64 字节的十六进制字符串,与参考实现 tfhe/index.ts 保持一致:

function toHex(bytes) {
  return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}

function buildKeyMeta(bytes) {
  const size    = bytes.byteLength >= 1024 * 1024
    ? `${(bytes.byteLength / 1024 / 1024).toFixed(2)} MB`
    : `${(bytes.byteLength / 1024).toFixed(1)} KB`
  const preview = toHex(bytes.slice(0, 64)) + '…'
  return { size, preview }
}

6.5 状态驱动 UI(data-state 模式)

避免在 JS 中用 show/hide 逐个操作元素,改用 CSS 属性选择器统一控制:

HTML:

<div class="key-card" id="card-client" data-state="pending">
  <div class="card-status">
    <span class="cs-pending">私钥</span>      <!-- pending 时显示 -->
    <span class="cs-loading">生成中…</span>   <!-- loading 时显示 -->
    <span class="cs-done"></span>            <!-- done 时显示 -->
  </div>
</div>

CSS:

.key-card[data-state="pending"] .cs-loading,
.key-card[data-state="pending"] .cs-done    { display: none; }

.key-card[data-state="loading"] .cs-pending,
.key-card[data-state="loading"] .cs-done    { display: none; }

.key-card[data-state="done"] .cs-pending,
.key-card[data-state="done"] .cs-loading    { display: none; }

JS:

// 只需一行,CSS 自动处理显示逻辑
document.getElementById('card-client').dataset.state = 'loading'

这种模式让状态机逻辑和 DOM 操作完全解耦,增加新状态只需改 CSS。


七、上传 server key(XHR multipart)

使用 XHR 而非 fetch,因为 fetch 不支持上传进度事件:

function uploadServerKey(serverKeyBytes, sessionId) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.open('POST', `${state.baseUrl}/finmind/api/evaluator/v2/set_server_key`)

    xhr.upload.addEventListener('progress', (e) => {
      if (!e.lengthComputable) return
      const pct = Math.round((e.loaded / e.total) * 100)
      progressBar.style.width = `${pct}%`
      uploadPct.textContent   = `${pct}%`
    })

    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) resolve()
      else reject(new Error(`上传失败: HTTP ${xhr.status}`))
    }

    xhr.onerror = () => reject(new Error('网络错误'))

    const blob = new Blob([serverKeyBytes.buffer], { type: 'application/octet-stream' })
    const form = new FormData()
    form.append('server_key', blob, 'server_key.bin')
    form.append('session_id', String(sessionId))
    // 注意:不要手动设置 Content-Type,浏览器会自动添加 multipart boundary
    xhr.send(form)
  })
}

八、构建与加载

npm run build

输出目录为 my-tfhe-crx-1.0.0/,结构如下:

my-tfhe-crx-1.0.0/
├── manifest.json
├── index.html
├── background.js
├── worker.js
├── content.js
├── icons/
│   └── *.png
└── assets/
    ├── popup.xxxxx.js
    ├── popup.xxxxx.css
    ├── tfhe.xxxxx.js
    └── tfhe_bg.xxxxx.wasm

加载到 Chrome:

  1. 地址栏输入 chrome://extensions
  2. 开启右上角 开发者模式
  3. 点击 加载已解压的扩展程序
  4. 选择 cipherflow-tfhe-crx-1.0.0/ 文件夹

九、调试技巧

popup 的 DevTools

右键插件图标 → 检查弹出内容 → 独立 DevTools 窗口

// Console 中查看当前域名的存储数据
chrome.storage.local.get(null, console.log)

background 的 DevTools

chrome://extensions → CipherFlow TFHE → Service Worker → 检查

content.js 注入验证

在白名单页面 F12 → Console:

window.__CIPHERFLOW_EXT_ID__  // 有值则注入成功

Application → Extension Storage

popup DevTools → Application 标签 → Storage → Extension Storage → Local 可直视看到各域名隔离的 tfhe_state:* 键值


十、常见问题

WASM 加载失败

检查 manifest.json 的 CSP:

"content_security_policy": {
  "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}

缺少 wasm-unsafe-eval 会导致 WASM 实例化被 CSP 拦截。

background 异步响应丢失

onMessageExternal 中若有异步操作,必须 return true

chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'cflo:get-keys') {
    chrome.storage.local.get(key, (result) => {
      sendResponse(result)  // 异步回调中发送
    })
    return true  // ← 缺少这行,sendResponse 调用时通道已关闭
  }
})

Worker 无法传递 WASM 对象

WASM 对象(TfheClientKey 等)不能跨线程传递,必须序列化为 Uint8Array 再传:


// ✅ 正确:传序列化后的字节
self.postMessage(
  { buffer: clientKey.serialize().buffer },
  [clientKey.serialize().buffer]  // Transferable
)

unlimitedStorage 为何必要

chrome.storage.local 默认限制 5MB,而 client key 和 public key 加起来可能超过此限制。

注:`unlimitedStorage` 权限突破该限制,但会在 Chrome 商店审核时被重点关注,需在说明中解释原因。