feat: LEXAI-13 API encryption, LEXAI-11 context menu, LEXAI-12 tone analysis

- LEXAI-13: TweetNaCl secretbox encryption for API keys in Options + background
  - getOrCreateEncKey() generates per-device 32-byte key stored in chrome.storage.local
  - Keys saved as nonce+ciphertext (base64) under apiKeyEnc
  - Backward compat: falls back to plaintext apiKey if no encrypted key found
  - Lock icon 🔒 shown in Options label when key is encrypted
- LEXAI-11: Right-click context menu with Fix, Rephrase, Shorten, Expand, Tone
  - Registered via onInstalled in background service worker
  - Sends lexai-context-menu message to content script
  - Content script listener added at bottom of main()
- LEXAI-12: Tone analysis button added to floating toolbar (teal #2dd4bf)
  - Analysis-only: showModal called with null originalText = no Replace button
  - Tone system prompt updated to pure analysis (no rewrite)
This commit is contained in:
Forge
2026-03-06 15:06:43 +08:00
parent 004819472d
commit 0273356f4d
3 changed files with 192 additions and 29 deletions

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import nacl from 'tweetnacl';
// ─── Provider config ──────────────────────────────────────────────────────────
@@ -34,6 +35,23 @@ 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 ───────────────────────────────────────────────────────────────────
const styles = {
@@ -183,13 +201,22 @@ function OptionsPage() {
const [model, setModel] = useState('gpt-4o-mini');
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [showKey, setShowKey] = useState(false);
const [isEncrypted, setIsEncrypted] = useState(false);
const apiKeyRef = useRef<HTMLInputElement>(null);
useEffect(() => {
safeStorageGet(["provider", "apiKey", "model"], (result) => {
safeStorageGet(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'], (result) => {
if (result.provider) setProvider(result.provider);
if (result.apiKey) setApiKey(result.apiKey);
if (result.model) setModel(result.model);
// Show placeholder if encrypted key exists
if (result.apiKeyEnc && result.encKey) {
setIsEncrypted(true);
setApiKey(''); // don't show encrypted blob — show empty for re-entry or leave as is
} else if (result.apiKey) {
setApiKey(result.apiKey);
setIsEncrypted(false);
}
});
}, []);
@@ -201,21 +228,57 @@ function OptionsPage() {
if (p) setModel(p.models[0]);
};
const handleSave = () => {
if (!apiKey.trim()) {
const handleSave = async () => {
// If key is blank and we already have an encrypted key, don't overwrite
if (!apiKey.trim() && !isEncrypted) {
apiKeyRef.current?.focus();
return;
}
if (!apiKey.trim() && isEncrypted) {
// Only saving provider/model changes — preserve existing encrypted key
setSaveStatus('saving');
safeStorageSet({ provider, model }, () => {
if (chrome.runtime.lastError) {
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000);
} else {
setSaveStatus('saved');
setTimeout(() => setSaveStatus('idle'), 2500);
}
});
return;
}
setSaveStatus('saving');
safeStorageSet({ provider, apiKey: apiKey.trim(), model }, () => {
if (chrome.runtime.lastError) {
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000);
} else {
setSaveStatus('saved');
setTimeout(() => setSaveStatus('idle'), 2500);
}
});
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));
safeStorageSet({ apiKeyEnc: apiKeyEncB64, provider, model }, () => {
if (chrome.runtime.lastError) {
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000);
} else {
// Remove plaintext key if it existed
chrome.storage.local.remove('apiKey');
setIsEncrypted(true);
setApiKey('');
setSaveStatus('saved');
setTimeout(() => setSaveStatus('idle'), 2500);
}
});
} catch (err) {
console.error('LexAI: Encryption failed', err);
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000);
}
};
const saveBtnStyle: React.CSSProperties = {
@@ -237,7 +300,7 @@ function OptionsPage() {
<h1 style={styles.title}>LexAI Settings</h1>
</div>
<p style={styles.subtitle}>
Configure your LLM provider and API key. Your key is stored locally and never shared.
Configure your LLM provider and API key. Your key is encrypted and stored locally.
</p>
<div style={styles.divider} />
@@ -272,14 +335,27 @@ function OptionsPage() {
{/* API Key */}
<div style={styles.formGroup}>
<label style={styles.label}>API Key</label>
<label style={styles.label}>
API Key{' '}
{isEncrypted && (
<span
title="API key is encrypted with TweetNaCl secretbox"
style={{ fontSize: '14px', marginLeft: '4px', verticalAlign: 'middle' }}
>
🔒
</span>
)}
</label>
<div style={{ position: 'relative' }}>
<input
ref={apiKeyRef}
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={currentProvider.placeholder}
onChange={(e) => {
setApiKey(e.target.value);
if (isEncrypted && e.target.value) setIsEncrypted(false);
}}
placeholder={isEncrypted ? '••••••• (encrypted — enter new key to change)' : currentProvider.placeholder}
style={{ ...styles.input, paddingRight: '42px' }}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
/>
@@ -303,7 +379,7 @@ function OptionsPage() {
</button>
</div>
<div style={styles.hint}>
<span>🔒 Stored only on your device.</span>
<span>🔒 Encrypted with TweetNaCl on your device.</span>
<span>·</span>
<a
href={currentProvider.docsUrl}
@@ -334,7 +410,7 @@ function OptionsPage() {
<strong style={{ color: '#a6adc8' }}>How to use LexAI:</strong>
<ol style={{ margin: '8px 0 0 16px', padding: 0 }}>
<li>Select any text on a webpage</li>
<li>Click Fix, Rephrase, Shorten, or Expand</li>
<li>Click Fix, Rephrase, Shorten, Expand, or Tone</li>
<li>Accept or replace the suggestion</li>
</ol>
</div>