概述
最近在项目中提及需要开发一个浏览器插件,这个插件本身功能其实可以在项目中实现的,但是作为多一种渠道实现,故而也开发实现一下。本文就以 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 + tabs | popup 读取当前 Tab 的 URL 做域名检测 |
host_permissions | 允许向这些域名发起 fetch / XHR |
externally_connectable | 允许这些域名的页面通过 chrome.runtime.sendMessage 与本扩展通信 |
content_scripts.run_at: "document_start" | 在页面 JS 执行前注入,确保 window.__CIPHERFLOW_EXT_ID__ 最早可用 |
wasm-unsafe-eval | MV3 加载 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:
- 地址栏输入
chrome://extensions - 开启右上角 开发者模式
- 点击 加载已解压的扩展程序
- 选择
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 商店审核时被重点关注,需在说明中解释原因。