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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user