本文讲述如何零服务器、零数据库,仅凭 Cloudflare Workers + KV 实现一套完整的激活码 + 设备指纹鉴权体系。涵盖 ECDSA 签名验签、离线宽限期、心跳续签、时钟防回拨等工程细节。
为什么选择 Cloudflare Workers + KV
做付费/授权分发时,开发者面临的核心难题是:
| 痛点 | 传统方案代价 |
|---|---|
| 需要一台服务器 | 域名 + SSL + 运维,月成本 ≥ ¥50 |
| 需要数据库 | 激活码、设备记录的存储与备份 |
| 全球低延迟 | 需要 CDN 加速 API 响应 |
| 需要防破解 | 自建签名服务,安全性难保障 |
Cloudflare Workers + KV 的组合拳,把上面的痛点一次性击穿:
- Workers:Serverless 函数,全球 300+ 边缘节点部署,冷启动 < 5ms
- KV:全球分布式键值存储,最终一致(~60s 全球同步),免费层每天 10 万次读
- 零成本启动:免费层可支撑约 1 万活跃用户
- Web Crypto API 原生支持:Workers 和浏览器端都内置
crypto.subtle,ECDSA-P256 签名/验签零依赖
系统架构总览
📦 付费授权系统整体流程(树状图)
│
├── 🌱 【用户打开插件】
│ │
│ ├── 本地有没有“激活凭证”?
│ │ ├── 没有 → 🌳 【显示激活表单】
│ │ │ │
│ │ │ ├── 用户输入激活码
│ │ │ ├── 插件采集设备指纹
│ │ │ ├── 发送给 Cloudflare Worker
│ │ │ │ │
│ │ │ │ ├── 查激活码是否有效?
│ │ │ │ │ ├── 无效 → ❌ 拒绝(码不存在/过期/被拉黑)
│ │ │ │ │ └── 有效 ↓
│ │ │ │ │
│ │ │ │ ├── 查该码绑了几台设备?
│ │ │ │ │ ├── 已达上限 → ❌ 拒绝(换台电脑再买一个吧)
│ │ │ │ │ └── 未达上限 ↓
│ │ │ │ │
│ │ │ │ └── 存进 KV:{激活码 + 指纹 + 当前时间}
│ │ │ │
│ │ │ └── Worker 返回“带签名的激活凭证”
│ │ │ │
│ │ │ └── 插件验签名 → 存本地 → ✅ 激活成功
│ │ │
│ │ └── 有 ↓
│ │
│ ├── 【检查本地凭证是否被篡改】
│ │ │
│ │ ├── 验数字签名 → 不通过 → ❌ 拒绝(凭证被篡改)
│ │ └── 通过 ↓
│ │
│ ├── 【时钟回拨检测】
│ │ │
│ │ ├── 当前时间 < 上次记录的最大时间 - 5分钟?
│ │ │ ├── 是 → 强制联网验证(防改时间作弊)
│ │ │ └── 否 ↓
│ │ │
│ │ └── 更新“最大时间戳”为当前时间
│ │
│ ├── 【检查 7 天宽限期】
│ │ │
│ │ ├── 当前时间 < 宽限期截止时间?
│ │ │ ├── 是 → ✅ 放行(免联网,直接使用)
│ │ │ └── 否 ↓(已超过 7 天)
│ │ │
│ │ └── 【联网续命(心跳)】
│ │ │
│ │ ├── 发送设备指纹 + Token 给 Worker
│ │ │ │
│ │ │ ├── 查 KV:这个指纹还在吗?
│ │ │ │ ├── 找不到 → ❌ 拒绝(被拉黑/已解绑)
│ │ │ │ └── 找得到 ↓
│ │ │ │
│ │ │ └── 更新 KV 中的最后心跳时间
│ │ │
│ │ └── Worker 返回新的凭证(宽限期刷新到“当前+7天”)
│ │ │
│ │ └── 插件存新凭证 → ✅ 续命成功,继续用
│ │
│ └── ✅ 【正常使用】—— 后台每30分钟自动续一次心跳
│
│
└── 🛠️ 【管理员后台】(独立分支)
│
├── 生成激活码(批量或单个)
├── 查看所有码的使用情况
├── 解绑某码下的所有设备
└── 一键撤销激活码(写入黑名单,立即生效)
为了帮你更快理解,我再配一张“数据流向”简图:
采集设备指纹 + 检查本地是否有激活凭证
用户输入激活码 → 发送给 Cloudflare Worker
📌 查 KV:激活码有效吗?
📌 查 KV:该码绑了几台设备?
📌 通过后 → 写入 “激活码 + 指纹 + 时间”
Worker 返回带签名的“激活凭证” → 插件存本地 → 可离线使用 7 天
📌 验数字签名 → 防篡改
📌 时钟回拨检测 → 防改时间作弊
📌 检查 7 天宽限期 → 没过期就直接用,不联网
插件发送指纹 + Token → Worker 刷新宽限期 → 续上 7 天 → 用户无感知
数据模型设计
四个 KV 命名空间,职责分明:
| Namespace | Key 格式 | Value | 用途 |
|---|---|---|---|
LICENSES | lic:<CODE> | {maxDevices, expireAt, createdAt, revoked, note} | 激活码本身的信息 |
DEVICES | dev:<deviceId> | {deviceId, code, fingerprintHash, fingerprintParts[], token, tokenExpireAt, activatedAt, lastHeartbeat} | 每台设备的详细记录 |
CODE_DEVICES | cd:<CODE> | [deviceId, deviceId, ...] | 激活码 → 设备列表的反向索引 |
TOKEN_INDEX | tk:<token> | deviceId | Token → 设备的快速查找索引 |
为什么需要四个 KV?
KV 不支持按 value 查询,也不支持二级索引。如果只有DEVICES,想知道"某个 token 对应哪个设备"就得全量扫描——这在 KV 中是不可行的。因此用TOKEN_INDEX 做了一层手工索引:
activate/verify/heartbeat 请求带 token
→ TOKEN_INDEX["tk:<token>"] 得到 deviceId
→ DEVICES["dev:<deviceId>"] 得到完整设备记录
→ 2 次 KV 读操作搞定
同理,CODE_DEVICES 解决的是"一个激活码绑了几台设备"的查询:
activate 时检查设备数
→ CODE_DEVICES["cd:<CODE>"] 得到 deviceId[]
→ 判断 length < maxDevices
设备指纹:
设备指纹是整套方案中"谁在用"的核心依据。
采集什么
const parts = [
navigator.platform, // 操作系统平台
navigator.userAgent, // 浏览器 UA
navigator.hardwareConcurrency, // CPU 核心数
screen.width + "x" + screen.height + "x" + screen.colorDepth, // 屏幕
Intl.DateTimeFormat().resolvedOptions().timeZone, // 时区
navigator.language, // 语言
chrome.runtime.id, // 扩展 ID
];
const hash = sha256(parts.join("|") + "|y3t00l-salt-v1");
7 个维度,全部是浏览器原生 API 可获取、无需用户授权的信息。
模糊匹配策略
浏览器升级、屏幕分辨率微调等情况会导致指纹漂移。如果严格匹配,用户体验会很差。因此引入模糊匹配:
7 项指纹中匹配 ≥ 5 项 → 认定为同一设备
function countFingerprintMatch(a, b) {
const len = Math.min(a.length, b.length);
let hit = 0;
for (let i = 0; i < len; i++) if (a[i] === b[i]) hit++;
return hit;
}
// 匹配数 >= 5 即通过
这样即使用户升级了浏览器版本(UA 变化),只要平台、屏幕、时区、语言、扩展 ID 等没变,依然被识别为同一设备。
指纹漂移自动更新
心跳环节有一个精巧设计——当指纹轻微漂移时,自动更新服务端记录:
// 心跳时发现指纹变了
if (device.fingerprintHash !== fingerprintHash) {
const matchCount = countFingerprintMatch(device.fingerprintParts, body.parts);
if (matchCount >= 4) { // 至少 4 项匹配才允许更新
device.fingerprintHash = fingerprintHash;
device.fingerprintParts = body.parts;
}
}
这意味着指纹会随用户环境自然演化,而非一次绑定终身不变。
ECDSA-P256 数字签名
为什么需要签名
激活/校验的响应通过公网传输。如果不签名,攻击者可以:
- 伪造一个假的后端返回,让插件"永久激活"
- 中间人劫持后篡改
graceUntil(宽限期)的值
密钥分发
gen-keys.js 生成 ECDSA-P256 密钥对
├─ 私钥(PKCS8 hex) → wrangler secret put → 注入 Worker
└─ 公钥(SPKI hex) → 写入 config.js → 硬编码在插件里
私钥永远不出 Worker。客户端只持有公钥,即使反编译插件也只能拿到公钥,无法伪造签名。
签名内容
签名原文 = JSON.stringify(responseData) + "|" + serverTime + "|" + nonce
responseData:响应体,包含deviceId, token, expireAt, graceUntil等serverTime:服务器时间戳,防止重放攻击(客户端校验 ±60s 时间漂移)nonce:一次性随机数,防止同一响应被重复利用
客户端验签
async function verifySign(body, serverTime, nonce, signB64) {
const payload = strToBytes(
JSON.stringify(body) + "|" + serverTime + "|" + nonce
);
const sig = base64ToBytes(signB64);
const key = await getPublicKey(); // 从 config.js 导入公钥
return crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
key, sig, payload
);
}
Web Crypto API 是浏览器原生支持的,性能极高且安全——私钥永远不需要在客户端出现。
文章评论