
我最近把玩一个叫 Onefile-unlock 的小项目时第一反应是都什么年代了还有人在用纯 HTML 做付费墙一个 HTML 文件里面又能装多少东西但等我顺着思路走完一遍以后我发现自己的判断得改一改。Onefile-unlock 最核心的价值不是“用 HTML 做付费墙”这个动作而是它在单文件里把内容加密、密文存储、前端解锁这三件事做了收敛。对整个内容分发流程来说它相当于把传统的“用户系统 权限判断 数据库”压缩成了“一个加密文本 一个密钥验证”。如果你愿意花几分钟把加密、解锁、浏览器兼容性和密钥管理这几个环节理一遍你会得到一个很实用的判断这类方案适合独立创作者、小团队、临时内容分发和静态托管场景但它确实不是真正意义上的数字版权保护它解决的核心问题是“让没有密钥的人看不到完整内容”。1. 为什么有人会把付费墙做成一个 HTML 文件1.1 传统付费墙的工程量到底重在哪一说“付费墙”很多人脑子里会出现一套标准技术栈用户表、订单表、登录态、支付回调、权限中间件、日志记录再加上一个专门判断“这个用户能不能看这篇文章”的接口。这套东西对大型内容平台是合理的但对独立创作者、小团队、或者只是想把一篇长文设置访问门槛的人来说成本太高了。你得先有个数据库再写登录注册还要处理支付回调和会员过期最后前端还要接一套跟用户状态绑定的路由守卫。如果只是发布一次性的付费内容比如一份教程、一个行业报告、一段私有笔记你并不需要一个完整的账号系统。你真正需要的是用户付过钱就能看到内容没付钱就看不到。至于“用户是谁”你其实没那么关心。Onefile-unlock 的思路就是从这里切进去的。它把问题从“判断用户身份”变成了“判断用户是否持有正确密钥”。你不需要数据库记录权限因为密钥本身就是权限。你不需要一套复杂的后端权限校验因为加密逻辑已经决定了解密成功与否。1.2 单文件付费墙的“轻”是有代价的单文件方案有三点很直接的收益第一它可以部署在任意静态服务器、OSS、甚至本地文件系统上。不需要 Node 服务不需要 PHP 环境不需要数据库。只要对方打开的是一个现代浏览器页面就能工作。第二它的分发方式很灵活。你可以把整个 HTML 文件压缩发出去也可以把它放在静态托管上生成链接甚至拷贝给别人离线阅读。对“不想维护在线系统”的场景这很合适。第三内容安全边界是静态的。密文和页面代码是一个整体密钥单独交付发布者自己可控。但“轻”从来不是免费的。单文件付费墙的代价是用户一旦解开内容就能截图、复制、另存。页面没法追踪谁做了什么也没法在密钥泄露后立刻吊销某个用户的访问。你只能通过更换密钥、重新加密内容来“作废”旧的版本没法像数据库权限系统那样即时封禁一个账号。换句话说这类方案适合中等价值的、非实时性的内容。它不适合商业机密不适合需要严格账号审计的场景也不适合需要动态权限控制的会员系统。1.3 它和传统付费墙的本质区别传统付费墙是在门口查身份证服务器知道你是谁知道你买了什么于是选择放行或拦截。Onefile-unlock 更像是在保险箱外面发钥匙。内容已经提前锁好了文件本身不再判断“你是谁”只判断“你手里的钥匙能不能打开这把锁”。这个区别决定了整个系统的复杂度和安全模型。钥匙可以一个人拿着也可以被用户转发。所以Onefile-unlock 真正考验的不是加密算法而是“密钥如何生成、如何分发、如何回收”。加密本身是数学问题密钥生命周期才是工程问题。2. 加密层为什么选择 Web Crypto 而不是 CryptoJS2.1 浏览器已经给你准备了一把原生钥匙在很多老教程里前端内容加密第一反应就是引入 CryptoJS然后调用 AES 或 MD5。但现在浏览器已经内置了 Web Crypto API大部分现代环境都支持crypto.subtle。而且社区里已经出现了明确信号控制行里经常能看到类似using cryptojs is deprecated. use global crypto object instead.的警告。继续在新项目里依赖 CryptoJS不仅增加体积还可能因为维护状态和浏览器环境不一致踩坑。Web Crypto API 的使用前提是安全上下文。也就是说页面必须在 HTTPS 下运行或者在本地开发时使用localhost。如果你的页面放在某些不支持 HTTPS 的内网 IP 或非安全端口上crypto.subtle会是undefined功能直接失效。这个前提本身也是一个很好的筛选条件Onefile-unlock 这种方案更适合部署在支持 HTTPS 的静态托管环境里。如果只是为了本地演示localhost没问题如果要分享给外部用户必须保证线上地址是 HTTPS。2.2 为什么主推 AES-GCM单文件付费墙需要加密的内容是一段完整文本不是流式音视频。对于这种场景对称加密就够了性能好实现简单浏览器原生支持。常见的对称加密模式里我更推荐 AES-GCM。原因有三第一AES-GCM 是带认证的加密模式。它不仅能隐藏明文还能校验密文是否被篡改。如果有人把密文里的二进制数据改了几个字节解密时会直接失败而不是输出一串乱码。第二AES-GCM 使用 256 位密钥时安全强度足够适合内容保护。第三浏览器 Web Crypto API 原生支持 AES-GCM不需要额外安装算法库。使用 AES-GCM 时要注意 IV每条内容尽量生成新的 12 字节随机 IV不要把 IV 固定写死。IV 不需要保密但它必须不能重复使用。密文和 IV 可以拼在一起存进 HTML加密时每次随机生成即可。2.3 发布端加密密钥和密文必须分散存放Onefile-unlock 的思路是把密文放进 HTML但密钥绝不放进 HTML。如果密钥和密文放在同一个文件里那这个锁对懂技术的人来说等于不存在。密文也好密钥也好都在同一个文件里谁都能打开源码直接找到。最终效果只是“一个普通用户看不到明文”但防不了任何有基本开发能力的人。所以正常做法是发布者本地生成密钥用密钥加密内容把密文和 IV 嵌进 HTML密钥通过另一条通道分发给已付费用户。下面是一个很简化的加密集合示例适合在浏览器控制台或 Node 环境里运行function base64Encode(buffer) { const bytes new Uint8Array(buffer); let binary ; bytes.forEach((b) { binary String.fromCharCode(b); }); return btoa(binary); } async function generateContentKey() { return crypto.subtle.generateKey( { name: AES-GCM, length: 256 }, true, [encrypt, decrypt] ); } async function encryptPlainText(plainText, key) { const iv crypto.getRandomValues(new Uint8Array(12)); const encoded new TextEncoder().encode(plainText); const cipherBuffer await crypto.subtle.encrypt( { name: AES-GCM, iv }, key, encoded ); const rawKey await crypto.subtle.exportKey(raw, key); return { key: base64Encode(rawKey), iv: base64Encode(iv), cipher: base64Encode(cipherBuffer) }; }注意这里的generateKey生成了一个可导出的原始密钥。你可以在发布前先把原始密钥存成一个单独文件加密时用它然后再把密钥文件安全地分发给已付费用户。初次落地时我建议先不要直接自动生成密钥就完事。你应该先跑一次加密流程打印出密钥、IV 和密文用一个小工具验证“能用原始密钥解密回明文”确认流程没断然后再批量处理多篇内容。2.4 密文放进 HTML 的正确位置很多新手会直接把密文塞进一个div甚至塞进 HTML 注释。这样并不理想因为如果密文里包含了类似script的字符串或者被 HTML 解析器误判可能导致页面结构错乱。更稳妥的方法是把密文放在一个typetext/plain的script标签里。浏览器不会把它当作可执行脚本也不会把它显示在页面上但它可以作为一个普通的文本节点被 JavaScript 读取。script idpayloadData typetext/plain {iv:...,cipher:...} /script这样的好处是内容在页面加载时不会被渲染但也保留了完整的文本结构方便前端脚本解析 JSON。3. 解锁层一个单文件 HTML 的最小结构3.1 页面骨架先搭起来Onefile-unlock 的单文件页面通常包含三块一个输入框、一个按钮、一个内容展示区域。输入框让用户输入密钥按钮触发解密内容区域负责展示解密后的正文。下面是一个极简的骨架!DOCTYPE html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 / title加密内容解锁页/title /head body main h1内容已加密/h1 p输入解锁密钥后查看完整内容。/p input idunlockKey typepassword placeholder请输入密钥 / button idunlockBtn解锁/button div idcontentArea/div /main script idpayloadData typetext/plain {iv:...,cipher:...} /script script // 解锁逻辑 /script /body /html密文数据放在payloadData脚本标签里而不是直接硬编码进 JavaScript 变量这样内容更长时不容易出现转义问题。如果你希望页面更精致可以加一些 CSS比如未解锁时显示模糊封面解锁后才显示完整正文。CSS 不影响密钥逻辑但会提升整个页面的完成度。3.2 解密逻辑其实只有三步解密的核心逻辑可以压缩成三步读取密文结构、导入密钥、执行解密。先写一个 base64 转换函数把用户输入的密钥字符串转成ArrayBufferfunction base64ToBuffer(base64) { const binary atob(base64); const bytes new Uint8Array(binary.length); for (let i 0; i binary.length; i) { bytes[i] binary.charCodeAt(i); } return bytes.buffer; }然后写一个解锁函数async function unlockContent() { const keyInput document.getElementById(unlockKey).value.trim(); const payload JSON.parse( document.getElementById(payloadData).textContent.trim() ); if (!keyInput) { alert(请输入密钥); return; } try { const key await crypto.subtle.importKey( raw, base64ToBuffer(keyInput), { name: AES-GCM }, false, [decrypt] ); const plainBuffer await crypto.subtle.decrypt( { name: AES-GCM, iv: base64ToBuffer(payload.iv) }, key, base64ToBuffer(payload.cipher) ); const plainText new TextDecoder().decode(plainBuffer); document.getElementById(contentArea).innerHTML plainText; } catch (error) { alert(解锁失败密钥可能不正确或内容被篡改。); } } document.getElementById(unlockBtn).addEventListener(click, unlockContent);这里最容易被忽略的点是crypto.subtle.decrypt如果密钥错误、IV 不匹配、密文被篡改都会走catch分支。你不用在页面上暴露具体的底层报错避免给有心人提供太多猜测信息。只提示“解锁失败”就够了。3.3 解锁状态可以做但别把它当成安全措施有人会把“已解锁”状态存到localStorage这样用户刷新页面后不需要重复输入密钥。从体验角度这是合理的优化。但要知道这只是一个本地标记。用户只要复制了内容甚至把整个 HTML 文件另存为一份就不需要再走解锁流程。所以不要把这个状态当作授权凭据。真正有价值的做法是解锁成功后把解密出来的内容继续放在页面内存里不要再从服务器请求同时把密钥从输入框清空降低密钥在页面上残留的风险。3.4 渲染内容时的 XSS 风险解密内容可能包含 HTML 标签。如果直接用innerHTML插入内容又是你自己写的通常问题不大但如果这个 HTML 文件被设计成通用工具允许别人导入自己加密的内容那解密结果就可能是用户生成的 HTML。这种情况下XSS 风险不可忽略。如果你只需要支持文本内容最简单可靠的做法是使用textContent而不是innerHTML。如果确实要支持标题、列表、链接建议只允许固定的白名单标签把其他标签全部转义。不要为了方便直接插入一整段不可信 HTML。4. 真实运行中的坑和长期维护边界4.1 热词里的 crypto 报错到底是怎么回事开发这类项目时最容易碰到的两个报错恰好也出现在相关热词里。第一个是error when starting dev server: typeerror: crypto$2.getrandomvalues is not a这个报错通常发生在非浏览器环境或者被某个依赖污染了crypto对象。比如在 Node 旧版本里crypto不是全局对象或者在一些打包工具里polyfill 没有正确注入getRandomValues。排查方式很简单先确认页面是在浏览器环境还是 Node 环境。检查代码里是否自定义了名为crypto的变量。确认当前页面是否是localhost或 HTTPS。在代码里使用更稳妥的写法const webCrypto globalThis.crypto避免被局部变量遮蔽。第二个是using cryptojs is deprecated. use global crypto object instead.这是很多依赖或控制台脚本在提示你不要再用 CryptoJS。浏览器已经内置了全局crypto再安装一个第三方加密库不仅多余还会带来体积和兼容性负担。4.2 它防不住截图也不该防截图每次看到这类方案都会有人问能不能做成不能截图、不能复制从技术上浏览器可以做一些限制比如禁止右键、禁止选中。但它们都是脆弱的而且只会降低正常用户的体验。截图、OCR、手机拍照、甚至直接查看页面内存都可以拿到已经解密的内容。真正铁了心要复制内容的人没有任何纯前端方案能拦住。所以 Onefile-unlock 这类项目更适合用来“提高门槛”而不是“绝对安全”。它能做到的是防止搜索引擎抓取全文防止普通访客顺手复制防止未付费用户直接看到正文。对大多数独立内容创作者来说这个门槛已经够了。如果你需要防技术用户那必须把解密放在后端甚至用 DRM这已经超出单文件 HTML 的范畴。4.3 密钥管理和分发的现实问题单文件解决方案把安全性押在了密钥分发的环节。密钥一旦泄露整个内容的保护就失效了。密钥管理最简单的起步办法是人工分发用户付费后你把密钥通过邮件或私聊发给对方。缺点是人工成本高但胜在可控。如果后续订单变多可以考虑用一个极简订单系统来生成专属密钥。密钥和订单关联后你至少能追踪到“这个密钥是哪笔订单发出去的”。但要注意单文件页面本身没有办法验证“这个用户是不是这个密钥的所有者”因为页面没有用户身份概念。你可以为同一个内容生成多个不同密钥每个密钥对应不同用户。这样当你发现某个密钥被公开传播时可以定位到从哪一笔订单泄露然后再重新生成内容密文、更新 HTML 文件旧密钥自然失效。所以如果做长期运营你的核心资产其实是“内容原文、密钥清单、订单记录”。HTML 文件只是交付时的一个外壳。4.4 一个可复用的落地与排查框架把整个 Onefile-unlock 的经验收敛一下可以沉淀成四步落地法和五层排查法。四步落地法准备内容文本先完成校验确认最终版本不会再改。生成密钥用 AES-GCM 加密内容得到 IV 和密文。把 IV 和密文放进script typetext/plain构建解锁页面。部署到静态 HTTPS 托管将密钥安全分发给已付费用户。五层排查法层级检查点现象是页面打不开、解锁无反应、解锁报错还是内容显示乱码输入密钥是否完整拼写是否有多余空格密文和 IV 的 base64 是否被截断环境是否 HTTPS/localhost浏览器是否支持 crypto.subtle是否有变量遮蔽 crypto参数AES-GCM 使用 256 位密钥IV 是否为 12 字节importKey 是否正确传 raw边界内容是不是 HTML是否需要转义密钥是否已泄露订单记录是否完整按这个顺序排查大多数问题都能在几分钟内定位。Onefile-unlock 这类项目真正有意思的地方在于它展示了一种“做减法”的思路你不需要一上来就搭账号系统、权限系统、数据库。先用一个加密文件和一把钥匙也可以把内容分发这件事跑起来。它当然有边界不能替代真正的 DRM不能防截图也不能在密钥泄露后自动收回权限。但反过来看正因为它的边界清晰你反而更容易判断它适合什么场景。如果你要发布的是教程、报告、付费文章而不是高度机密的商业资料那么这种“单文件 加密 密钥解锁”的模型可能恰好是把一个想法变成可用产品的最短路径。