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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { defineBackground } from 'wxt/utils/define-background';
|
import { defineBackground } from 'wxt/utils/define-background';
|
||||||
import nacl from 'tweetnacl';
|
|
||||||
import type { AnalyzePayload, LexAIConfig, LexAIResponse } from '@lib/types';
|
import type { AnalyzePayload, LexAIConfig, LexAIResponse } from '@lib/types';
|
||||||
import { ACTIONS, ACTION_LABELS, CONTEXT_MENU_STYLES } from '@lib/actions';
|
import { ACTIONS, ACTION_LABELS, CONTEXT_MENU_STYLES } from '@lib/actions';
|
||||||
|
import { decryptApiKey } from '@lib/crypto';
|
||||||
|
|
||||||
// ─── Fetch with timeout ───────────────────────────────────────────────────────
|
// ─── Fetch with timeout ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -49,24 +49,12 @@ function getSystemPrompt(action: string, style?: string): string {
|
|||||||
return base + styleModifier;
|
return base + styleModifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Encryption helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function decryptApiKey(encKeyB64: string, apiKeyEncB64: string): Promise<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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the usable API key from stored config: prefer the encrypted path,
|
// 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.
|
// fall back to plaintext for backward compat. Returns null if none is set.
|
||||||
async function resolveApiKey(config: LexAIConfig): Promise<string | null> {
|
async function resolveApiKey(config: LexAIConfig): Promise<string | null> {
|
||||||
let apiKey = config.apiKey;
|
let apiKey = config.apiKey;
|
||||||
if (config.apiKeyEnc && config.encKey) {
|
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 (decrypted) apiKey = decrypted;
|
||||||
}
|
}
|
||||||
if (!apiKey || apiKey.trim() === '') return null;
|
if (!apiKey || apiKey.trim() === '') return null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import nacl from 'tweetnacl';
|
|
||||||
import { safeSendMessage, safeStorageGet, safeStorageSet } from '@lib/messaging';
|
import { safeSendMessage, safeStorageGet, safeStorageSet } from '@lib/messaging';
|
||||||
|
import { encryptApiKey, getOrCreateEncKey } from '@lib/crypto';
|
||||||
|
|
||||||
// ─── Provider config ──────────────────────────────────────────────────────────
|
// ─── Provider config ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -33,23 +33,6 @@ const PROVIDERS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Encryption helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function getOrCreateEncKey(): Promise<Uint8Array> {
|
|
||||||
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 ───────────────────────────────────────────────────────────────────
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
@@ -275,13 +258,7 @@ function OptionsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const key = await getOrCreateEncKey();
|
const key = await getOrCreateEncKey();
|
||||||
const nonce = nacl.randomBytes(24);
|
const apiKeyEncB64 = encryptApiKey(apiKey.trim(), key);
|
||||||
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));
|
|
||||||
|
|
||||||
safeStorageSet({ apiKeyEnc: apiKeyEncB64, provider, model }, () => {
|
safeStorageSet({ apiKeyEnc: apiKeyEncB64, provider, model }, () => {
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
|
|||||||
71
src/lib/crypto.ts
Normal file
71
src/lib/crypto.ts
Normal file
@@ -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<Uint8Array> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
83
tests/unit/crypto.test.ts
Normal file
83
tests/unit/crypto.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user