保護 AI 聊天機器人:混合式加密、速率限制與機器人偵測

· 中文 · Claude Sonnet 5 代筆

articlesecurityastrorediscryptography

這個網站個人簡介頁面上的聊天小工具,讓匿名訪客可以問 AI 幾個關於我履歷的問題。聽起來很簡單,但「匿名訪客、真正的後端、公開的網際網路」正是那種容易招來濫用的問題形狀:重送攻擊(replay attacks)、爬蟲,或是有人一晚寫個腳本打幾千次請求,讓我的 Anthropic API 帳單暴增這種老派的成本問題。這些都不是什麼特殊案例——基本上就是任何一個每次呼叫都要花錢的公開端點,都會遇到的那幾個老問題。

混合式加密,不只是 HTTPS

TLS 保護的是傳輸過程中的請求,但請求內容本身,還是得用瀏覽器跟伺服器雙方都能理解、而且事先不需要交換共享密鑰的形式,才能送達我的伺服器端邏輯。每位訪客的瀏覽器會產生一把全新的 AES-256 金鑰,用它加密訊息內容,接著再用伺服器稍早提供的 RSA-OAEP 公鑰,把這把 AES 金鑰包起來:

const aesKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']);
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, data);
const wrappedKey = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, rawAesKey);

瀏覽器和伺服器兩端用的是同一套原生 Web Crypto API——雙邊都不需要第三方加密函式庫,不用處理 PEM 格式,而且雙方在 RSA-OAEP 全程都採用 SHA-256。伺服器端的私鑰從不離開 Redis,其鍵值是訪客匿名 ID、IP 位址與 User-Agent 的雜湊組合,並設有 24 小時的 TTL。

真正做到原子性的速率限制

滑動視窗(sliding-window)速率限制器聽起來很直觀,直到兩個請求在同一毫秒內抵達,而且都在對方寫回之前讀到同一個「目前次數」。修法是絕對不要讓「檢查並遞增」這件事拆成兩次獨立的 Redis 往返——它是以一支單一的 Lua script 在 Redis 內部執行,因此不會有任何東西可以插進來干擾:

local count = redis.call('ZCARD', key)
if count < limit then
  redis.call('ZADD', key, now, member)
  return {1, limit - count - 1}
else
  return {0, 0}
end

不用 CAPTCHA 的機器人偵測

沒有人喜歡為了問聊天機器人一個問題,還要先解一個 CAPTCHA。取而代之的做法是,前端會從真實的互動訊號——滑鼠移動、聚焦事件、按鍵、觸控事件——累積出一個加權分數,而一則訊息如果打字速度相對於長度快得可疑,就會被扣分。無頭瀏覽器(navigator.webdriverHeadlessChrome 的 User-Agent、缺少的外掛與語言清單)則會被直接標記出來。這套機制並非萬無一失,但它把自動化濫用的成本,拉高到遠超過一支隨手寫寫的腳本願意付出的程度,同時完全不需要讓真正的訪客多做任何事。

這一切在產品層面完全看不出來——訪客就只是打了一個問題、得到一個答案。而這正是重點所在。