墻:用Web Crypto實(shí)現(xiàn)HTML內(nèi)容加密與密鑰解鎖)
我最近把玩一個叫 Onefile-unlock 的小項(xiàng)目時第一反應(yīng)是都什么年代了還有人在用純 HTML 做付費(fèi)墻一個 HTML 文件里面又能裝多少東西但等我順著思路走完一遍以后我發(fā)現(xiàn)自己的判斷得改一改。Onefile-unlock 最核心的價值不是“用 HTML 做付費(fèi)墻”這個動作而是它在單文件里把內(nèi)容加密、密文存儲、前端解鎖這三件事做了收斂。對整個內(nèi)容分發(fā)流程來說它相當(dāng)于把傳統(tǒng)的“用戶系統(tǒng) 權(quán)限判斷 數(shù)據(jù)庫”壓縮成了“一個加密文本 一個密鑰驗(yàn)證”。如果你愿意花幾分鐘把加密、解鎖、瀏覽器兼容性和密鑰管理這幾個環(huán)節(jié)理一遍你會得到一個很實(shí)用的判斷這類方案適合獨(dú)立創(chuàng)作者、小團(tuán)隊(duì)、臨時內(nèi)容分發(fā)和靜態(tài)托管場景但它確實(shí)不是真正意義上的數(shù)字版權(quán)保護(hù)它解決的核心問題是“讓沒有密鑰的人看不到完整內(nèi)容”。1. 為什么有人會把付費(fèi)墻做成一個 HTML 文件1.1 傳統(tǒng)付費(fèi)墻的工程量到底重在哪一說“付費(fèi)墻”很多人腦子里會出現(xiàn)一套標(biāo)準(zhǔn)技術(shù)棧用戶表、訂單表、登錄態(tài)、支付回調(diào)、權(quán)限中間件、日志記錄再加上一個專門判斷“這個用戶能不能看這篇文章”的接口。這套東西對大型內(nèi)容平臺是合理的但對獨(dú)立創(chuàng)作者、小團(tuán)隊(duì)、或者只是想把一篇長文設(shè)置訪問門檻的人來說成本太高了。你得先有個數(shù)據(jù)庫再寫登錄注冊還要處理支付回調(diào)和會員過期最后前端還要接一套跟用戶狀態(tài)綁定的路由守衛(wèi)。如果只是發(fā)布一次性的付費(fèi)內(nèi)容比如一份教程、一個行業(yè)報告、一段私有筆記你并不需要一個完整的賬號系統(tǒng)。你真正需要的是用戶付過錢就能看到內(nèi)容沒付錢就看不到。至于“用戶是誰”你其實(shí)沒那么關(guān)心。Onefile-unlock 的思路就是從這里切進(jìn)去的。它把問題從“判斷用戶身份”變成了“判斷用戶是否持有正確密鑰”。你不需要數(shù)據(jù)庫記錄權(quán)限因?yàn)槊荑€本身就是權(quán)限。你不需要一套復(fù)雜的后端權(quán)限校驗(yàn)因?yàn)榧用苓壿嬕呀?jīng)決定了解密成功與否。1.2 單文件付費(fèi)墻的“輕”是有代價的單文件方案有三點(diǎn)很直接的收益第一它可以部署在任意靜態(tài)服務(wù)器、OSS、甚至本地文件系統(tǒng)上。不需要 Node 服務(wù)不需要 PHP 環(huán)境不需要數(shù)據(jù)庫。只要對方打開的是一個現(xiàn)代瀏覽器頁面就能工作。第二它的分發(fā)方式很靈活。你可以把整個 HTML 文件壓縮發(fā)出去也可以把它放在靜態(tài)托管上生成鏈接甚至拷貝給別人離線閱讀。對“不想維護(hù)在線系統(tǒng)”的場景這很合適。第三內(nèi)容安全邊界是靜態(tài)的。密文和頁面代碼是一個整體密鑰單獨(dú)交付發(fā)布者自己可控。但“輕”從來不是免費(fèi)的。單文件付費(fèi)墻的代價是用戶一旦解開內(nèi)容就能截圖、復(fù)制、另存。頁面沒法追蹤誰做了什么也沒法在密鑰泄露后立刻吊銷某個用戶的訪問。你只能通過更換密鑰、重新加密內(nèi)容來“作廢”舊的版本沒法像數(shù)據(jù)庫權(quán)限系統(tǒng)那樣即時封禁一個賬號。換句話說這類方案適合中等價值的、非實(shí)時性的內(nèi)容。它不適合商業(yè)機(jī)密不適合需要嚴(yán)格賬號審計(jì)的場景也不適合需要動態(tài)權(quán)限控制的會員系統(tǒng)。1.3 它和傳統(tǒng)付費(fèi)墻的本質(zhì)區(qū)別傳統(tǒng)付費(fèi)墻是在門口查身份證服務(wù)器知道你是誰知道你買了什么于是選擇放行或攔截。Onefile-unlock 更像是在保險箱外面發(fā)鑰匙。內(nèi)容已經(jīng)提前鎖好了文件本身不再判斷“你是誰”只判斷“你手里的鑰匙能不能打開這把鎖”。這個區(qū)別決定了整個系統(tǒng)的復(fù)雜度和安全模型。鑰匙可以一個人拿著也可以被用戶轉(zhuǎn)發(fā)。所以O(shè)nefile-unlock 真正考驗(yàn)的不是加密算法而是“密鑰如何生成、如何分發(fā)、如何回收”。加密本身是數(shù)學(xué)問題密鑰生命周期才是工程問題。2. 加密層為什么選擇 Web Crypto 而不是 CryptoJS2.1 瀏覽器已經(jīng)給你準(zhǔn)備了一把原生鑰匙在很多老教程里前端內(nèi)容加密第一反應(yīng)就是引入 CryptoJS然后調(diào)用 AES 或 MD5。但現(xiàn)在瀏覽器已經(jīng)內(nèi)置了 Web Crypto API大部分現(xiàn)代環(huán)境都支持crypto.subtle。而且社區(qū)里已經(jīng)出現(xiàn)了明確信號控制行里經(jīng)常能看到類似using cryptojs is deprecated. use global crypto object instead.的警告。繼續(xù)在新項(xiàng)目里依賴 CryptoJS不僅增加體積還可能因?yàn)榫S護(hù)狀態(tài)和瀏覽器環(huán)境不一致踩坑。Web Crypto API 的使用前提是安全上下文。也就是說頁面必須在 HTTPS 下運(yùn)行或者在本地開發(fā)時使用localhost。如果你的頁面放在某些不支持 HTTPS 的內(nèi)網(wǎng) IP 或非安全端口上crypto.subtle會是undefined功能直接失效。這個前提本身也是一個很好的篩選條件Onefile-unlock 這種方案更適合部署在支持 HTTPS 的靜態(tài)托管環(huán)境里。如果只是為了本地演示localhost沒問題如果要分享給外部用戶必須保證線上地址是 HTTPS。2.2 為什么主推 AES-GCM單文件付費(fèi)墻需要加密的內(nèi)容是一段完整文本不是流式音視頻。對于這種場景對稱加密就夠了性能好實(shí)現(xiàn)簡單瀏覽器原生支持。常見的對稱加密模式里我更推薦 AES-GCM。原因有三第一AES-GCM 是帶認(rèn)證的加密模式。它不僅能隱藏明文還能校驗(yàn)密文是否被篡改。如果有人把密文里的二進(jìn)制數(shù)據(jù)改了幾個字節(jié)解密時會直接失敗而不是輸出一串亂碼。第二AES-GCM 使用 256 位密鑰時安全強(qiáng)度足夠適合內(nèi)容保護(hù)。第三瀏覽器 Web Crypto API 原生支持 AES-GCM不需要額外安裝算法庫。使用 AES-GCM 時要注意 IV每條內(nèi)容盡量生成新的 12 字節(jié)隨機(jī) IV不要把 IV 固定寫死。IV 不需要保密但它必須不能重復(fù)使用。密文和 IV 可以拼在一起存進(jìn) HTML加密時每次隨機(jī)生成即可。2.3 發(fā)布端加密密鑰和密文必須分散存放Onefile-unlock 的思路是把密文放進(jìn) HTML但密鑰絕不放進(jìn) HTML。如果密鑰和密文放在同一個文件里那這個鎖對懂技術(shù)的人來說等于不存在。密文也好密鑰也好都在同一個文件里誰都能打開源碼直接找到。最終效果只是“一個普通用戶看不到明文”但防不了任何有基本開發(fā)能力的人。所以正常做法是發(fā)布者本地生成密鑰用密鑰加密內(nèi)容把密文和 IV 嵌進(jìn) HTML密鑰通過另一條通道分發(fā)給已付費(fèi)用戶。下面是一個很簡化的加密集合示例適合在瀏覽器控制臺或 Node 環(huán)境里運(yùn)行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生成了一個可導(dǎo)出的原始密鑰。你可以在發(fā)布前先把原始密鑰存成一個單獨(dú)文件加密時用它然后再把密鑰文件安全地分發(fā)給已付費(fèi)用戶。初次落地時我建議先不要直接自動生成密鑰就完事。你應(yīng)該先跑一次加密流程打印出密鑰、IV 和密文用一個小工具驗(yàn)證“能用原始密鑰解密回明文”確認(rèn)流程沒斷然后再批量處理多篇內(nèi)容。2.4 密文放進(jìn) HTML 的正確位置很多新手會直接把密文塞進(jìn)一個div甚至塞進(jìn) HTML 注釋。這樣并不理想因?yàn)槿绻芪睦锇祟愃苨cript的字符串或者被 HTML 解析器誤判可能導(dǎo)致頁面結(jié)構(gòu)錯亂。更穩(wěn)妥的方法是把密文放在一個typetext/plain的script標(biāo)簽里。瀏覽器不會把它當(dāng)作可執(zhí)行腳本也不會把它顯示在頁面上但它可以作為一個普通的文本節(jié)點(diǎn)被 JavaScript 讀取。script idpayloadData typetext/plain {iv:...,cipher:...} /script這樣的好處是內(nèi)容在頁面加載時不會被渲染但也保留了完整的文本結(jié)構(gòu)方便前端腳本解析 JSON。3. 解鎖層一個單文件 HTML 的最小結(jié)構(gòu)3.1 頁面骨架先搭起來Onefile-unlock 的單文件頁面通常包含三塊一個輸入框、一個按鈕、一個內(nèi)容展示區(qū)域。輸入框讓用戶輸入密鑰按鈕觸發(fā)解密內(nèi)容區(qū)域負(fù)責(zé)展示解密后的正文。下面是一個極簡的骨架!DOCTYPE html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 / title加密內(nèi)容解鎖頁/title /head body main h1內(nèi)容已加密/h1 p輸入解鎖密鑰后查看完整內(nèi)容。/p input idunlockKey typepassword placeholder請輸入密鑰 / button idunlockBtn解鎖/button div idcontentArea/div /main script idpayloadData typetext/plain {iv:...,cipher:...} /script script // 解鎖邏輯 /script /body /html密文數(shù)據(jù)放在payloadData腳本標(biāo)簽里而不是直接硬編碼進(jìn) JavaScript 變量這樣內(nèi)容更長時不容易出現(xiàn)轉(zhuǎn)義問題。如果你希望頁面更精致可以加一些 CSS比如未解鎖時顯示模糊封面解鎖后才顯示完整正文。CSS 不影響密鑰邏輯但會提升整個頁面的完成度。3.2 解密邏輯其實(shí)只有三步解密的核心邏輯可以壓縮成三步讀取密文結(jié)構(gòu)、導(dǎo)入密鑰、執(zhí)行解密。先寫一個 base64 轉(zhuǎn)換函數(shù)把用戶輸入的密鑰字符串轉(zhuǎn)成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; }然后寫一個解鎖函數(shù)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(解鎖失敗密鑰可能不正確或內(nèi)容被篡改。); } } document.getElementById(unlockBtn).addEventListener(click, unlockContent);這里最容易被忽略的點(diǎn)是crypto.subtle.decrypt如果密鑰錯誤、IV 不匹配、密文被篡改都會走catch分支。你不用在頁面上暴露具體的底層報錯避免給有心人提供太多猜測信息。只提示“解鎖失敗”就夠了。3.3 解鎖狀態(tài)可以做但別把它當(dāng)成安全措施有人會把“已解鎖”狀態(tài)存到localStorage這樣用戶刷新頁面后不需要重復(fù)輸入密鑰。從體驗(yàn)角度這是合理的優(yōu)化。但要知道這只是一個本地標(biāo)記。用戶只要復(fù)制了內(nèi)容甚至把整個 HTML 文件另存為一份就不需要再走解鎖流程。所以不要把這個狀態(tài)當(dāng)作授權(quán)憑據(jù)。真正有價值的做法是解鎖成功后把解密出來的內(nèi)容繼續(xù)放在頁面內(nèi)存里不要再從服務(wù)器請求同時把密鑰從輸入框清空降低密鑰在頁面上殘留的風(fēng)險。3.4 渲染內(nèi)容時的 XSS 風(fēng)險解密內(nèi)容可能包含 HTML 標(biāo)簽。如果直接用innerHTML插入內(nèi)容又是你自己寫的通常問題不大但如果這個 HTML 文件被設(shè)計(jì)成通用工具允許別人導(dǎo)入自己加密的內(nèi)容那解密結(jié)果就可能是用戶生成的 HTML。這種情況下XSS 風(fēng)險不可忽略。如果你只需要支持文本內(nèi)容最簡單可靠的做法是使用textContent而不是innerHTML。如果確實(shí)要支持標(biāo)題、列表、鏈接建議只允許固定的白名單標(biāo)簽把其他標(biāo)簽全部轉(zhuǎn)義。不要為了方便直接插入一整段不可信 HTML。4. 真實(shí)運(yùn)行中的坑和長期維護(hù)邊界4.1 熱詞里的 crypto 報錯到底是怎么回事開發(fā)這類項(xiàng)目時最容易碰到的兩個報錯恰好也出現(xiàn)在相關(guān)熱詞里。第一個是error when starting dev server: typeerror: crypto$2.getrandomvalues is not a這個報錯通常發(fā)生在非瀏覽器環(huán)境或者被某個依賴污染了crypto對象。比如在 Node 舊版本里crypto不是全局對象或者在一些打包工具里polyfill 沒有正確注入getRandomValues。排查方式很簡單先確認(rèn)頁面是在瀏覽器環(huán)境還是 Node 環(huán)境。檢查代碼里是否自定義了名為crypto的變量。確認(rèn)當(dāng)前頁面是否是localhost或 HTTPS。在代碼里使用更穩(wěn)妥的寫法const webCrypto globalThis.crypto避免被局部變量遮蔽。第二個是using cryptojs is deprecated. use global crypto object instead.這是很多依賴或控制臺腳本在提示你不要再用 CryptoJS。瀏覽器已經(jīng)內(nèi)置了全局crypto再安裝一個第三方加密庫不僅多余還會帶來體積和兼容性負(fù)擔(dān)。4.2 它防不住截圖也不該防截圖每次看到這類方案都會有人問能不能做成不能截圖、不能復(fù)制從技術(shù)上瀏覽器可以做一些限制比如禁止右鍵、禁止選中。但它們都是脆弱的而且只會降低正常用戶的體驗(yàn)。截圖、OCR、手機(jī)拍照、甚至直接查看頁面內(nèi)存都可以拿到已經(jīng)解密的內(nèi)容。真正鐵了心要復(fù)制內(nèi)容的人沒有任何純前端方案能攔住。所以 Onefile-unlock 這類項(xiàng)目更適合用來“提高門檻”而不是“絕對安全”。它能做到的是防止搜索引擎抓取全文防止普通訪客順手復(fù)制防止未付費(fèi)用戶直接看到正文。對大多數(shù)獨(dú)立內(nèi)容創(chuàng)作者來說這個門檻已經(jīng)夠了。如果你需要防技術(shù)用戶那必須把解密放在后端甚至用 DRM這已經(jīng)超出單文件 HTML 的范疇。4.3 密鑰管理和分發(fā)的現(xiàn)實(shí)問題單文件解決方案把安全性押在了密鑰分發(fā)的環(huán)節(jié)。密鑰一旦泄露整個內(nèi)容的保護(hù)就失效了。密鑰管理最簡單的起步辦法是人工分發(fā)用戶付費(fèi)后你把密鑰通過郵件或私聊發(fā)給對方。缺點(diǎn)是人工成本高但勝在可控。如果后續(xù)訂單變多可以考慮用一個極簡訂單系統(tǒng)來生成專屬密鑰。密鑰和訂單關(guān)聯(lián)后你至少能追蹤到“這個密鑰是哪筆訂單發(fā)出去的”。但要注意單文件頁面本身沒有辦法驗(yàn)證“這個用戶是不是這個密鑰的所有者”因?yàn)轫撁鏇]有用戶身份概念。你可以為同一個內(nèi)容生成多個不同密鑰每個密鑰對應(yīng)不同用戶。這樣當(dāng)你發(fā)現(xiàn)某個密鑰被公開傳播時可以定位到從哪一筆訂單泄露然后再重新生成內(nèi)容密文、更新 HTML 文件舊密鑰自然失效。所以如果做長期運(yùn)營你的核心資產(chǎn)其實(shí)是“內(nèi)容原文、密鑰清單、訂單記錄”。HTML 文件只是交付時的一個外殼。4.4 一個可復(fù)用的落地與排查框架把整個 Onefile-unlock 的經(jīng)驗(yàn)收斂一下可以沉淀成四步落地法和五層排查法。四步落地法準(zhǔn)備內(nèi)容文本先完成校驗(yàn)確認(rèn)最終版本不會再改。生成密鑰用 AES-GCM 加密內(nèi)容得到 IV 和密文。把 IV 和密文放進(jìn)script typetext/plain構(gòu)建解鎖頁面。部署到靜態(tài) HTTPS 托管將密鑰安全分發(fā)給已付費(fèi)用戶。五層排查法層級檢查點(diǎn)現(xiàn)象是頁面打不開、解鎖無反應(yīng)、解鎖報錯還是內(nèi)容顯示亂碼輸入密鑰是否完整拼寫是否有多余空格密文和 IV 的 base64 是否被截斷環(huán)境是否 HTTPS/localhost瀏覽器是否支持 crypto.subtle是否有變量遮蔽 crypto參數(shù)AES-GCM 使用 256 位密鑰IV 是否為 12 字節(jié)importKey 是否正確傳 raw邊界內(nèi)容是不是 HTML是否需要轉(zhuǎn)義密鑰是否已泄露訂單記錄是否完整按這個順序排查大多數(shù)問題都能在幾分鐘內(nèi)定位。Onefile-unlock 這類項(xiàng)目真正有意思的地方在于它展示了一種“做減法”的思路你不需要一上來就搭賬號系統(tǒng)、權(quán)限系統(tǒng)、數(shù)據(jù)庫。先用一個加密文件和一把鑰匙也可以把內(nèi)容分發(fā)這件事跑起來。它當(dāng)然有邊界不能替代真正的 DRM不能防截圖也不能在密鑰泄露后自動收回權(quán)限。但反過來看正因?yàn)樗倪吔缜逦惴炊菀着袛嗨m合什么場景。如果你要發(fā)布的是教程、報告、付費(fèi)文章而不是高度機(jī)密的商業(yè)資料那么這種“單文件 加密 密鑰解鎖”的模型可能恰好是把一個想法變成可用產(chǎn)品的最短路徑。