我找到了在網路上保護 API keys 的完美方法
我最近將 BoltAI 移植到網路平台,並立即遇到一個技術挑戰:如何安全地儲存 API keys?
如果您不熟悉我的工作,BoltAI 是一個 BYOK (bring-your-own-key) 的 AI 聊天應用程式。在尊重使用者隱私並維持良好 UX 的同時,安全地儲存使用者的 API keys 至關重要。
對於原生應用程式 (mac/行動裝置) 來說,這相當直接,但對於網路應用程式來說,卻出乎意料地具有挑戰性。
在這篇短文中,我將分享我最終採用的方法,以及為什麼我認為它是目前最佳的實用解決方案。
TL;DR:我使用 WebAuthn passkeys 作為本地解密閘門,而不是作為登入方法。這為 BoltAI 提供了一個實用的 BYOK 模型,其中加密 key 留在使用者的裝置上,並且請求直接發送給 AI 供應商。
問題
對於像 BoltAI 這樣的 BYOK AI 聊天客戶端,安全地儲存使用者的 API keys 至關重要,同時要尊重您的隱私並維持良好的 UX。這表示:
沒有代理伺服器。客戶端必須與 AI 供應商的推論伺服器保持直接連線。
這些機密只能由 BoltAI 應用程式存取。任何來自其他應用程式的嘗試都會立即觸發管理員密碼對話框。
良好的 UX。使用者不必每次都重新輸入機密。
解決方案
對於原生應用程式來說,這相當直接。我們可以將機密儲存在 Apple 的 Keychain (Mac/iOS) 或 Android 的 KeyStore 系統 (Android 應用程式) 中。
我使用使用者的密碼短語加密他們的 API keys,並將 DEK (data encryption key) 儲存在安全儲存中。這是一個既安全又方便的絕佳設定。由於 keys 是端對端加密的,因此將加密資料同步到雲端及其他裝置是安全的。

在網路上,這就不那麼簡單了。沒有等效的安全儲存。讓我們探討一下選項:
最佳 UX。我們將 API keys 解密並以 plaintext 形式儲存在 local storage 中。這不是很安全,但非常方便,因為使用者不必在每次聊天會話中重新輸入他們的密碼短語。
最安全。在每次聊天會話中,我們提示使用者輸入密碼短語,解密 API keys 並僅儲存在記憶體中。安全,但可能相當惱人。
在我看來,完美的解決方案應該介於兩者之間:既安全又方便使用者。但這樣的解決方案存在嗎?
是的!WebAuthn 來拯救了。
Passkey/WebAuthn 通常用於身份驗證,並且需要後端。但在這種情況下,我們只需要使用 WebAuthn PRF 擴充功能作為本地密碼學原語:它衍生出一個與 credential 綁定的 secret,BoltAI for Web 使用它來解鎖加密的 API keys,而無需將 passkey 視為登入方法。
運作方式
這是心智模型。

在網路上,BoltAI 儲存一個加密的 envelope,其中包含:
加密的 API key payload
一個基於密碼短語的 DEK wrap
(可選) 一個基於 passkey(PRF) 的 DEK wrap
伺服器只看到 ciphertext。unwrap keys 保持在本地。
然後,當 BoltAI 需要使用 API key 時:
unlock time
-----------
load encrypted envelope
|
+--> try passkey PRF wrap
| |
| +--> WebAuthn returns credential-bound secret
| +--> recover DEK
|
+--> else ask for passphrase
|
+--> derive key with scrypt
+--> recover DEK
DEK → 在記憶體中解密 API key → 直接向供應商發送請求
程式碼片段
這是 WebAuthn 註冊流程的簡化版本。
const prfSalt = await randomBytes(32);
const credential = await navigator.credentials.create({
publicKey: {
challenge: toArrayBuffer(await randomBytes(32)),
rp: {
name: "BoltAI",
id: location.hostname,
},
user: {
id: toArrayBuffer(stringToBytes(`boltai:${userId}`)),
name: `boltai-local-unlock-${userId}`,
displayName: "BoltAI Passkey Unlock",
},
userVerification: "required",
extensions: {
prf: {
eval: {
first: toArrayBuffer(prfSalt),
},
},
},
},
});
const result = credential.getClientExtensionResults?.();
const passkeyKey = new Uint8Array(result?.prf?.results?.first!);
該 passkeyKey 並非直接用於加密 API key payload。相反地,它 wrap 了 DEK:
const dek = await randomKey();
const encryptedPayload = encryptAESGCM(
dek,
payloadNonce,
stringToBytes(apiKey),
AAD.PAYLOAD
);
const passWrap = await buildPassWrap(dek, passphrase);
const passkeyWrappedDEK = encryptAESGCM(
passkeyKey,
passkeyNonce,
dek,
AAD.WRAP_PASSKEY
);
envelope.wraps = {
pass: passWrap,
passkey: {
credentialId,
prfSalt,
nonce: bytesToBase64url(passkeyNonce),
ct: bytesToBase64url(passkeyWrappedDEK),
},
};
當解鎖時,BoltAI 會依序嘗試可用的 unwrap 路徑:
let dek: Uint8Array | null = null;
// try passkey PRF output first
if (activePasskeyWrap && envelope.wraps.passkey) {
dek = decryptAESGCM(
activePasskeyWrap.key,
passkeyNonce,
passkeyCiphertext,
AAD.WRAP_PASSKEY
);
}
// fall back to the passphrase
if (!dek && passphrase) {
dek = decryptPassphraseWrap(envelope, passphrase);
}
if (!dek) {
throw new Error("Failed to unwrap DEK");
}
結果就是我想要的完美 UX:
使用者不必在每個會話中輸入他們的密碼短語
BoltAI 仍然直接與 AI 供應商通訊
我的伺服器永遠不會成為機密代理
密碼短語仍然是備用/復原路徑
我對這個解決方案非常滿意。
如果您是 BoltAI 客戶,請在 https://chat.boltai.com 試用 BoltAI 網路測試版。
祝您開發愉快 ✌️
