← 返回首頁
Daniel Nguyen
Daniel Nguyen
@daniel_nguyenx
49🔁 3
𝕏 (Twitter)🔥

我找到了在網路上保護 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 是端對端加密的,因此將加密資料同步到雲端及其他裝置是安全的。

在網路上,這就不那麼簡單了。沒有等效的安全儲存。讓我們探討一下選項:

  1. 最佳 UX。我們將 API keys 解密並以 plaintext 形式儲存在 local storage 中。這不是很安全,但非常方便,因為使用者不必在每次聊天會話中重新輸入他們的密碼短語。

  2. 最安全。在每次聊天會話中,我們提示使用者輸入密碼短語,解密 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 網路測試版。

祝您開發愉快 ✌️