☰
Cap 工作原理深度解析:从 Proof-of-Work 挑战到签名 Token 的完整链路
2026/9/28 2:45:40 网站建设 项目流程
  • 网络安全
  • 应用安全
  • 后端

【免费下载链接】cap

Free, open-source and self-hosted CAPTCHA alternative to reCAPTCHA. Privacy-first and powered by proof-of-work and instrumentation challenges.

项目地址:https://gitcode.com/gh_mirrors/cap13/cap
点击查看免费下载

Cap 是一个免费、开源、可自托管的 CAPTCHA 替代方案,其核心思想是:不依赖图像拼图或行为追踪,而是用**工作量证明(Proof-of-Work)与浏览器环境检测(Instrumentation)**双重挑战来区分人与机器人。本文基于仓库文档 docs/guide/workings.md,逐层拆解 Cap 从组件初始化、请求挑战、客户端求解、服务端验收到最终签发 Token 的完整技术链路,并结合cap.js组件、worker.js求解器与capjs-core服务端库的源码实现,帮助你理解这套"无感验证"机制的底层原理。

说明:本文聚焦于 Cap 的 SHA-256 工作量证明与 instrumentation 挑战(即workings.md的主体内容)。GPU 抗性的 HashWX 挑战协议 不在本文展开,但在关键环节会指出其与本文主线的差异与衔接;整体效果评估可参考 effectiveness 文档。

整体流程:九步带你走完一次验证

workings.md将一次完整的验证概括为三个阶段的九个步骤,这是理解 Cap 全貌的最佳骨架:

  1. Cap 初始化时,在浏览器中自动注册一个自定义元素(cap-widget)作为验证组件;
  2. 组件创建 Shadow DOM,并向其中挂载所有必要元素;
  3. 当需要求解时,组件向服务器请求挑战,服务器返回 Token、待解的挑战配置,以及(可选的)压缩后的 instrumentation 数据;
  4. 组件以挑战 Token 为种子、结合服务器配置在本地生成多个挑战;若存在 instrumentation 数据,则在沙箱 iframe 中解压并执行;
  5. 组件使用 Rust 风格 WASM 与 Web Worker 并行求解:每个 Worker 反复尝试把 salt 与不同 nonce 组合、计算 SHA-256 哈希、检查哈希是否以目标前缀开头,WASM 递增 nonce 直到找到匹配;
  6. 若存在 instrumentation 挑战,解压并执行之;
  7. 找到合法解后,组件把结果回传服务器进行校验;
  8. 服务器使用同一 Token 与配置自行生成相同的挑战,逐一验证组件提交的解;
  9. 验证通过后,服务器"兑换"该解并签发可用于后续请求认证的 Token。

下面逐阶段深入讲解。


阶段一:组件初始化(步骤 1–2)

注册自定义元素

当cap.min.js被加载并执行时,脚本先做环境探测(typeof window === "undefined"时直接返回),随后把整个 widget 定义为 Web Components 自定义元素。在 widget/src/src/cap.js#L2002 可以看到注册语句:

customElements.define("cap-widget", CapWidget);

这意味着页面中只需书写<cap-widget></cap-widget>,浏览器即会自动实例化CapWidget类。CapWidget还通过static formAssociated = true(cap.js#L493)声明为可关联表单的元素,配合attachInternals()实现表单校验(required属性未满足时触发valueMissing错误),因此它能无缝嵌入现有<form>提交流程。

创建 Shadow DOM

在connectedCallback()(元素挂载到文档时触发)中,组件创建并挂载 Shadow DOM(cap.js#L953-L954):

if (!this.shadowRoot) { this.#shadow = this.attachShadow({ mode: "open" }); }

随后把样式与 UI 全部注入 Shadow DOM 内部(cap.js#L1643-L1645):

this.#shadow.innerHTML = `<style${window.CAP_CSS_NONCE ? ` nonce=${window.CAP_CSS_NONCE}` : ""}>%%capCSS%%</style>`; this.#shadow.appendChild(this.#div);

Shadow DOM 带来的隔离性是 Cap 无需依赖 CSS 框架的关键:外部页面的样式无法污染组件内部,组件样式也不会泄漏到页面,配合window.CAP_CSS_NONCE属性可适配严格 CSP 环境。

同时组件还会在宿主元素内插入一个隐藏表单域(cap.js#L969):

this.#host.innerHTML = `<input type="hidden" name="${this.#fieldName}">`;

默认字段名为cap-token(data-cap-hidden-field-name可改),验证成功后 Token 会被写入该字段,随表单一并提交到你的后端。


阶段二:请求挑战(步骤 3–4)

向服务器要一份"挑战说明书"

当组件需要求解(用户点击验证按钮,或开启"后台预解"时,页面首次交互后经SPECULATIVE_DELAY_MS = 2500毫秒的延迟触发),它会向{api-endpoint}/challenge发送POST请求(cap.js#L616-L619)。服务器返回三类数据:

  • token:挑战令牌,后续赎回(redeem)时回传,用于服务端复原挑战;
  • 挑战配置:包括挑战数量、盐长度、难度等参数({ c, s, d }或 format-2 的challenges数组);
  • instrumentation(可选):一份经 deflate 压缩再 base64 编码的 JavaScript 程序。

这份配置在服务端由capjs-core的generateChallenge()生成——它返回{ challenge, token, expires, instrumentation? },其中token是一个携带挑战配置的签名 JWT(详见 capjs-core 文档)。SHA-256 模式下的默认参数为:

参数默认值含义
challengeCount50生成的 PoW 谜题数量
challengeSize32盐长度(十六进制字符数)
challengeDifficulty4目标前缀长度(十六进制字符数)
expiresMs600_000挑战有效期(10 分钟)

在浏览器端"重建"挑战

服务器并不会把 50 个具体的谜题都下发给浏览器——那样负载过大。它只给一个种子(即token),由组件在本地确定性再生出全部挑战。从源码看(cap.js#L645-L651):

if (!Array.isArray(challenges)) { let i = 0; challenges = Array.from({ length: challenge.c }, () => { i++; return [ prng(`${token}${i}`, challenge.s), // 第 i 个谜题的 salt prng(`${token}${i}d`, challenge.d), // 第 i 个谜题的 target 前缀 ]; }); }

prng()(cap.js#L86-L113)是一个以 FNV-1a 做种子、xorshift 风格的确定性伪随机数生成器:同样的token + i输入必然产生同样的 salt,同样的token + i + "d"必然产生同样的 target。这保证了"客户端生成什么、服务端就能生成什么"——步骤 8 中服务器自行重算挑战成为可能。

Instrumentation 数据怎么"复活"

若响应中携带instrumentation字段,组件调用runInstrumentationChallenge()(cap.js#L168-L238):

  1. 先用atob解码 base64,再经DecompressionStream("deflate-raw")解压(不支持时降级加载 pako,可通过window.CAP_PAKO_URL指定 CDN);
  2. 解压出的是一段自包含的 JavaScript 程序,它被注入到一个sandbox="allow-scripts"的 1×1 像素隐藏 iframe 中执行(srcdoc注入);
  3. iframe 内程序执行浏览器 API 探测与 DOM 运算链,最终通过postMessage以{ type: "cap:instr" }消息把结果回传父窗口;
  4. 若 20 秒内未收到结果(__timeout)或程序自报blocked(__blocked,自动化浏览器被拦截),组件按失败处理。

沙箱化的意义在于:即使服务器下发的 JS 被恶意注入或被篡改,它也只能在一个与主页面完全隔离、无权限的 1×1 不可见 iframe 里运行,无法接触页面数据。关于 instrumentation 挑战为何要混入 DOM 运算、以及七项自动化检测的细节,参见 instrumentation 文档。


阶段三:计算解(步骤 5–6)

SHA-256 工作量证明:找前缀匹配的 nonce

核心求解逻辑是标准的哈希碰撞式 PoW:给定 salt 与 target 前缀,暴力递增 nonce,寻找满足

sha256(salt + nonce) 的前缀 == target

的 nonce。workings.md第 5 步描述的正是这一过程。仓库中 widget/src/src/worker.js#L18-L77 的solveFallback给出了最直白的参考实现(这是无 WASM 时的纯 JS 兜底,同样逻辑的 WASM 版本性能高得多):

while (true) { for (let i = 0; i < batchSize; i++) { // 每批 50000 次 const inputString = salt + nonce; const hashBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(inputString))); // 逐字节比较 hash 前缀与 targetBytes;匹配则上报 nonce if (matches) { self.postMessage({ nonce, found: true }); return; } nonce++; } }

该实现对 target 的处理很讲究:targetBits = target.length * 4(前缀位数),完整字节部分逐字节比对,不足一字节的剩余位数用掩码(0xff << (8 - remainingBits)) & 0xff只比较高位——这正是"哈希以目标前缀开头"的精确语义。

WASM + Web Worker:并行暴力破解

纯 JS 逐次await crypto.subtle.digest太慢。生产路径下,组件会:

  1. 通过getWasmModule()(cap.js#L242-L268)从@cap.js/wasm包(默认 jsDelivr CDN,可用window.CAP_CUSTOM_WASM_URL覆盖)拉取并WebAssembly.compile编译cap_wasm_bg.wasm,它由 Rust 编译而来——这就是workings.md所说的 "Rust-flavoured WASM";
  2. 用WorkerPool(cap.js#L344-L490)创建数量等于navigator.hardwareConcurrency || 8(可用data-cap-worker-count覆盖)的 Web Worker,把 salt、target 与编译好的wasmModule通过postMessage分发给各 Worker;
  3. 每个 Worker 初始化 WASM 实例后调用wasm.solve_pow(saltPtr, saltLen, targetPtr, targetLen)(worker.js#L358-L364),Rust 侧循环递增 nonce 直至找到前缀匹配的哈希,随后把 nonce 上报主线程。

WorkerPool还包含健壮性设计:Worker 崩溃时自动terminate并替换(最多尝试 3 次);任务以队列 + 空闲 Worker 分发的形式调度;stopAll()会向所有 Worker 发送{ kind: "stop" }优雅停机(250ms 宽限期后强制终止),用于组件重置时立刻停止无谓的算力消耗。

HashWX:默认协议的方向性差异

值得说明的是,workings.md明确声明本文不覆盖 HashWX。但在当前仓库中,HashWX 已是 Standalone 新站点密钥的默认挑战协议(见 standalone/options.md#hashwx-proof-of-work),其思路与 SHA-256 PoW 有本质区别:不再用固定哈希函数,而是由服务器下发的 32 字节挑战c通过sha256(c || u64le(block))生成种子,再据种子现场构造一个全新的、含大量分支与 16KB 非对齐 scratchpad 访问的哈希函数(见 core/src/hashwx.js#L58-L67 的hashwxSeed)。由于每个挑战的函数都不同,GPU 无法像对待固定 SHA-256 那样锁步并行,优势从约 150 倍被压到约 2 倍。

从 core/src/hashwx.js#L98-L143 的mintHashwxChallenges可见其默认参数:difficulty = 1_000_000(期望哈希数,即难度)、noncesPerHash = 65_536、challengeCount = 4(默认拆成 4 个子挑战以缩短长尾延迟)。而求解端,Worker 通过hashwx_make(ctx, seedPtr)现场生成函数、以WebAssembly.ModuleJIT 编译该函数再对整块 nonce 区执行(worker.js#L182-L265)。若浏览器不支持 WebAssembly,HashWX 会直接报错——这是它与 SHA-256 路径(有纯 JS 兜底)最大的可用性差异。

Instrumentation 挑战的执行

步骤 6 中,若存在 instrumentation 数据,组件在求解 PoW 的同时(或之后)执行沙箱 iframe 程序,取回计算结果向量{ vp: [window.innerWidth, window.innerHeight], ...result }(cap.js#L212),将其随解一并提交。该向量中既包含程序主运算链的期望终值,也包含navigator.webdriver、文本度量、窗口几何等浏览器事实——服务端据此判定是否运行在真实渲染引擎中。


阶段四:兑换 Token(步骤 7–9)

提交解:redeem 端点

找到全部合法 nonce(数量必须等于challenge.c)且 instrumentation 程序返回结果后,组件向{api-endpoint}/redeem发送POST(cap.js#L770-L779):

const redeemRaw = await capFetch(`${apiEndpoint}redeem`, { method: "POST", body: JSON.stringify({ token: challengeResp.token, // 原挑战令牌 solutions, // 每个谜题找到的 nonce 数组 ...(instrOut && { instr: instrOut }), // instrumentation 结果向量 }), headers: { "Content-Type": "application/json" }, });

服务端"重算一遍"再验证

服务端收到请求后调用validateChallenge(),其验证次序是:

  1. 令牌核验:JWT 签名、参数边界、scope匹配、有效期(默认 10 分钟,过期即拒);
  2. 解格式核验:solutions必须是数组且长度等于挑战数;
  3. 重算挑战:用同样的 token 与配置(即步骤 4 中客户端所用同一套确定性再生逻辑)生成每个谜题的 salt 与 target;
  4. 逐一验证 PoW 解:对 SHA-256 路径重算sha256(salt + nonce)比对前缀;对 HashWX 路径则调用verifyHashwxSolution()——先解析 nonce、由nonce / n推算所在 block、hashwxSeed(challenge, block)还原该 block 的种子并生成对应哈希函数、执行一次并判断H <= (2^64 - 1) / d(core/src/hashwx.js#L145-L175)。整个验证只执行一次哈希(而非客户端那样暴力搜索),因此服务端成本极低;
  5. 校验 instrumentation(若启用):在服务端用detectAutomation对提交的向量跑检测器,失败则返回reason: "instr_automated_browser"及blockedBy数组;该过程同时产出riskFlags(如native_tamper)供业务方决定是否提高下一轮难度;
  6. 防重放(可选):若配置了consumeNonce回调,以 JWT 签名的十六进制为键执行SET NX EX语义的原子写入,重复提交返回false,对应失败原因already_redeemed。注意此检查排在 PoW 与 instrumentation 验证之后,垃圾解不会烧掉合法用户的 nonce。

签发可用 Token

全部通过后,服务器"兑换"解并签发新 Token:默认格式为id:secret的二元组,id可公开,secret的 SHA-256 哈希作为查询键。业务后端按 capjs-core 文档 中给出的方式持久化该键的过期时间(默认 20 分钟),并在后续请求中据用户回传的 Token 重新派生键来鉴权。组件拿到resp.token后将其写入隐藏表单域、触发solve事件并设置到期自动重置定时器(cap.js#L808-L846),一次验证闭环至此完成。


两个值得注意的设计细节

确定性再生是整套机制的地基

从步骤 4 到步骤 8,客户端与服务端没有任何共享的"谜题库",全靠同一个 token 种子 + 确定性 PRNG / 确定性哈希函数生成。这意味着:挑战配置必须由签名 JWT 承载且不可被篡改(篡改即 JWT 验签失败),这是 capjs-core 把挑战参数放进签名 payload 的原因;也意味着服务端验证是 O(1) 级别的单次重算,而非重放整个搜索过程。

后台预解:把验证藏进用户交互间隙

组件实现了"投机性预解"(speculative solving):监听mousemove/touchstart/keydown等首次交互事件,交互后延迟 2.5 秒且组件可见时,就开始静默请求挑战并求解(cap.js#L565-L668),期间只启用 1 个 Worker 以控制资源占用。用户真正提交表单时若预解已完成,直接"零等待"取用缓存 Token(日志里甚至记录了省下的毫秒数:served from speculative cache (saved ...))。这就是 Cap 能在多数场景下做到"无用户交互、无感知通过"的工程支撑。


相关文档导航

本文是workings.md的深度展开,以下文档可继续深入:

  • Cap 对抗效果评估(effectiveness):为何选择 PoW、成本如何影响攻击者经济模型;
  • HashWX 挑战协议:默认协议的完整协议描述、GPU 抗性来源与实测数据;
  • Instrumentation 挑战:七项自动化检测细则与安全边界;
  • capjs-core 服务端库:generateChallenge/validateChallenge的完整 API 与无状态部署模式;
  • Standalone 部署选项:站点密钥级协议切换与难度配置(默认1_000_000,合法范围50_000–5_000_000)。

如需查看实现源码,可分别阅读 widget 组件、Worker 求解器 与 服务端 HashWX 验证。

  • 网络安全
  • 应用安全
  • 后端

【免费下载链接】cap

Free, open-source and self-hosted CAPTCHA alternative to reCAPTCHA. Privacy-first and powered by proof-of-work and instrumentation challenges.

项目地址:https://gitcode.com/gh_mirrors/cap13/cap
点击查看免费下载

相关推荐

上一篇:DLRS安装与配置完全攻略:从零基础到熟练应用
下一篇:OpenTracing-Python异步编程支持:asyncio、gevent和Tornado集成指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询