From c4634d49650c9b3bd813ede61011e69e02b1a69f Mon Sep 17 00:00:00 2001 From: john kevin asprec Date: Tue, 14 Jul 2026 21:38:42 +0800 Subject: [PATCH] refactor: consolidate API-key crypto into src/lib/crypto with compat tests Encrypt (Options) and decrypt (background) now share one module; wire format unchanged. Tests include fixtures proving values encrypted by the old inline Options code still decrypt, plus tamper/wrong-key cases. The module documents the honest threat model (key co-located with ciphertext = obfuscation, not encryption). Co-Authored-By: Claude Fable 5 --- entrypoints/background.ts | 16 +------ entrypoints/options/Options.tsx | 27 +---------- src/lib/crypto.ts | 71 ++++++++++++++++++++++++++++ tests/unit/crypto.test.ts | 83 +++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 39 deletions(-) create mode 100644 src/lib/crypto.ts create mode 100644 tests/unit/crypto.test.ts diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 83cb32a..2043dd5 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -1,7 +1,7 @@ import { defineBackground } from 'wxt/utils/define-background'; -import nacl from 'tweetnacl'; import type { AnalyzePayload, LexAIConfig, LexAIResponse } from '@lib/types'; import { ACTIONS, ACTION_LABELS, CONTEXT_MENU_STYLES } from '@lib/actions'; +import { decryptApiKey } from '@lib/crypto'; // ─── Fetch with timeout ─────────────────────────────────────────────────────── @@ -49,24 +49,12 @@ function getSystemPrompt(action: string, style?: string): string { return base + styleModifier; } -// ─── Encryption helpers ─────────────────────────────────────────────────────── - -async function decryptApiKey(encKeyB64: string, apiKeyEncB64: string): Promise { - const key = Uint8Array.from(atob(encKeyB64), c => c.charCodeAt(0)); - const combined = Uint8Array.from(atob(apiKeyEncB64), c => c.charCodeAt(0)); - const nonce = combined.slice(0, 24); - const cipher = combined.slice(24); - const decrypted = nacl.secretbox.open(cipher, nonce, key); - if (!decrypted) return null; - return new TextDecoder().decode(decrypted); -} - // Resolve the usable API key from stored config: prefer the encrypted path, // fall back to plaintext for backward compat. Returns null if none is set. async function resolveApiKey(config: LexAIConfig): Promise { let apiKey = config.apiKey; if (config.apiKeyEnc && config.encKey) { - const decrypted = await decryptApiKey(config.encKey, config.apiKeyEnc); + const decrypted = decryptApiKey(config.encKey, config.apiKeyEnc); if (decrypted) apiKey = decrypted; } if (!apiKey || apiKey.trim() === '') return null; diff --git a/entrypoints/options/Options.tsx b/entrypoints/options/Options.tsx index d5feb3e..df48016 100644 --- a/entrypoints/options/Options.tsx +++ b/entrypoints/options/Options.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import nacl from 'tweetnacl'; import { safeSendMessage, safeStorageGet, safeStorageSet } from '@lib/messaging'; +import { encryptApiKey, getOrCreateEncKey } from '@lib/crypto'; // ─── Provider config ────────────────────────────────────────────────────────── @@ -33,23 +33,6 @@ const PROVIDERS = [ }, ]; -// ─── Encryption helpers ─────────────────────────────────────────────────────── - -async function getOrCreateEncKey(): Promise { - return new Promise((resolve) => { - chrome.storage.local.get(['encKey'], (result) => { - if (result.encKey) { - resolve(Uint8Array.from(atob(result.encKey as string), c => c.charCodeAt(0))); - } else { - const key = nacl.randomBytes(32); - const keyB64 = btoa(String.fromCharCode(...key)); - chrome.storage.local.set({ encKey: keyB64 }); - resolve(key); - } - }); - }); -} - // ─── Styles ─────────────────────────────────────────────────────────────────── const styles = { @@ -275,13 +258,7 @@ function OptionsPage() { try { const key = await getOrCreateEncKey(); - const nonce = nacl.randomBytes(24); - const encoded = new TextEncoder().encode(apiKey.trim()); - const encrypted = nacl.secretbox(encoded, nonce, key); - const combined = new Uint8Array(nonce.length + encrypted.length); - combined.set(nonce); - combined.set(encrypted, nonce.length); - const apiKeyEncB64 = btoa(String.fromCharCode(...combined)); + const apiKeyEncB64 = encryptApiKey(apiKey.trim(), key); safeStorageSet({ apiKeyEnc: apiKeyEncB64, provider, model }, () => { if (chrome.runtime.lastError) { diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..8808de2 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,71 @@ +// API-key at-rest obfuscation, shared by Options (encrypt) and the background +// worker (decrypt). Consolidates the previously split implementations; the +// wire format is unchanged so keys stored by older builds still decrypt: +// +// encKey = base64(32-byte secretbox key) +// apiKeyEnc = base64(24-byte nonce || secretbox ciphertext) +// +// THREAT MODEL (be honest about it): the secretbox key lives in the same +// chrome.storage.local as the ciphertext, so this protects against casual +// inspection only — anyone who can read the extension's storage (profile +// disk access, another privileged process) can also read the key and +// decrypt. Without a backend or a user passphrase there is no stronger +// at-rest story for a browser extension; UI copy must not over-promise. + +import nacl from 'tweetnacl'; + +const NONCE_LENGTH = 24; + +export function bytesToBase64(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} + +export function base64ToBytes(b64: string): Uint8Array { + return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); +} + +export function generateEncKey(): Uint8Array { + return nacl.randomBytes(32); +} + +// Returns the stored secretbox key, creating and persisting one on first use. +export async function getOrCreateEncKey(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(['encKey'], (result) => { + if (result.encKey) { + resolve(base64ToBytes(result.encKey as string)); + } else { + const key = generateEncKey(); + chrome.storage.local.set({ encKey: bytesToBase64(key) }); + resolve(key); + } + }); + }); +} + +// Encrypts a plaintext API key -> base64(nonce || ciphertext). +export function encryptApiKey(plaintext: string, key: Uint8Array): string { + const nonce = nacl.randomBytes(NONCE_LENGTH); + // Uint8Array.from re-wraps in the current realm — TextEncoder can hand back a + // foreign-realm array (e.g. under jsdom) that fails tweetnacl's instanceof check. + const encoded = Uint8Array.from(new TextEncoder().encode(plaintext)); + const encrypted = nacl.secretbox(encoded, nonce, key); + const combined = new Uint8Array(nonce.length + encrypted.length); + combined.set(nonce); + combined.set(encrypted, nonce.length); + return bytesToBase64(combined); +} + +// Decrypts base64(nonce || ciphertext) with the base64 key. +// Returns null on any tamper/mismatch (secretbox authentication failure). +export function decryptApiKey(encKeyB64: string, apiKeyEncB64: string): string | null { + const key = base64ToBytes(encKeyB64); + const combined = base64ToBytes(apiKeyEncB64); + const nonce = combined.slice(0, NONCE_LENGTH); + const cipher = combined.slice(NONCE_LENGTH); + const decrypted = nacl.secretbox.open(cipher, nonce, key); + if (!decrypted) return null; + return new TextDecoder().decode(decrypted); +} diff --git a/tests/unit/crypto.test.ts b/tests/unit/crypto.test.ts new file mode 100644 index 0000000..203dd05 --- /dev/null +++ b/tests/unit/crypto.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import nacl from 'tweetnacl'; +import { + bytesToBase64, + base64ToBytes, + generateEncKey, + encryptApiKey, + decryptApiKey, +} from '@lib/crypto'; + +// Reproduces the ORIGINAL inline implementations verbatim (Options.tsx encrypt, +// background.ts decrypt) to prove the extracted module is wire-compatible with +// keys already sitting in users' storage. +function legacyEncrypt(plaintext: string, key: Uint8Array): { encKeyB64: string; apiKeyEncB64: string } { + const nonce = nacl.randomBytes(24); + const encoded = Uint8Array.from(new TextEncoder().encode(plaintext)); + const encrypted = nacl.secretbox(encoded, nonce, key); + const combined = new Uint8Array(nonce.length + encrypted.length); + combined.set(nonce); + combined.set(encrypted, nonce.length); + return { + encKeyB64: btoa(String.fromCharCode(...key)), + apiKeyEncB64: btoa(String.fromCharCode(...combined)), + }; +} + +function legacyDecrypt(encKeyB64: string, apiKeyEncB64: string): string | null { + const key = Uint8Array.from(atob(encKeyB64), (c) => c.charCodeAt(0)); + const combined = Uint8Array.from(atob(apiKeyEncB64), (c) => c.charCodeAt(0)); + const nonce = combined.slice(0, 24); + const cipher = combined.slice(24); + const decrypted = nacl.secretbox.open(cipher, nonce, key); + if (!decrypted) return null; + return new TextDecoder().decode(decrypted); +} + +describe('base64 helpers', () => { + it('roundtrips arbitrary bytes', () => { + const bytes = nacl.randomBytes(64); + expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes); + }); +}); + +describe('encrypt/decrypt roundtrip', () => { + it('decrypts what it encrypted', () => { + const key = generateEncKey(); + const enc = encryptApiKey('sk-test-1234567890', key); + expect(decryptApiKey(bytesToBase64(key), enc)).toBe('sk-test-1234567890'); + }); + + it('handles unicode and long keys', () => { + const key = generateEncKey(); + const secret = 'sk-or-v1-' + 'a'.repeat(128) + '-héllo-👋'; + expect(decryptApiKey(bytesToBase64(key), encryptApiKey(secret, key))).toBe(secret); + }); + + it('returns null when the ciphertext is tampered', () => { + const key = generateEncKey(); + const enc = encryptApiKey('sk-test', key); + const bytes = base64ToBytes(enc); + bytes[bytes.length - 1] ^= 0xff; + expect(decryptApiKey(bytesToBase64(key), bytesToBase64(bytes))).toBeNull(); + }); + + it('returns null with the wrong key', () => { + const enc = encryptApiKey('sk-test', generateEncKey()); + expect(decryptApiKey(bytesToBase64(generateEncKey()), enc)).toBeNull(); + }); +}); + +describe('backward compatibility with the pre-extraction inline code', () => { + it('decrypts a value encrypted by the OLD Options.tsx code path', () => { + const key = nacl.randomBytes(32); + const { encKeyB64, apiKeyEncB64 } = legacyEncrypt('sk-legacy-user-key', key); + expect(decryptApiKey(encKeyB64, apiKeyEncB64)).toBe('sk-legacy-user-key'); + }); + + it('produces output the OLD background.ts decrypt understands', () => { + const key = generateEncKey(); + const enc = encryptApiKey('sk-new-key', key); + expect(legacyDecrypt(bytesToBase64(key), enc)).toBe('sk-new-key'); + }); +});