diff --git a/public/src/config.js b/public/src/config.js index 7375b18..bb4d2bc 100644 --- a/public/src/config.js +++ b/public/src/config.js @@ -190,14 +190,29 @@ export const VLLM_ENDPOINTS = [ }, ]; +// Helper to get vault value or localStorage fallback +function getVaultOrStorage(key, defaultValue = null) { + // Try vault first if available + if (typeof window !== 'undefined' && window.UncloseVault && window.UncloseVault.isUnlocked()) { + return window.UncloseVault.get(key, defaultValue); + } + // Fallback to localStorage + const stored = localStorage.getItem(key); + if (stored === null) return defaultValue; + // Parse booleans + if (stored === 'true') return true; + if (stored === 'false') return false; + return stored; +} + // Function to get API configuration (custom or default) export async function getAPIConfig() { try { - const useCustomAPI = localStorage.getItem("useCustomAPI") === "true"; - + const useCustomAPI = getVaultOrStorage("useCustomAPI", false) === true; + if (useCustomAPI) { - const customBaseURL = localStorage.getItem("customBaseURL"); - const customAPIKey = localStorage.getItem("customAPIKey"); + const customBaseURL = getVaultOrStorage("customBaseURL", ""); + const customAPIKey = getVaultOrStorage("customAPIKey", ""); // Import model functions to get the currently selected model const { getSelectedModel } = await import("./models.js"); diff --git a/public/src/languages/en.js b/public/src/languages/en.js index 7030d6e..1a02ef7 100644 --- a/public/src/languages/en.js +++ b/public/src/languages/en.js @@ -117,4 +117,35 @@ export const en = { useUnsandbox: "Use unsandbox.com API keys for code execution.", unsandboxPublicKey: "Public Key (unsb-pk-...)", unsandboxSecretKey: "Secret Key (unsb-sk-...)", + + // Vault settings + vaultLoggedIn: "Logged in", + vaultLocked: "Settings Locked", + vaultUnlocked: "Settings Unlocked", + vaultCreate: "Save Settings", + vaultUnlock: "Unlock", + vaultLock: "Lock", + vaultLockTooltip: "Lock your settings (log out)", + vaultPassword: "Password", + vaultPasswordPlaceholder: "Choose a password...", + vaultConfirmPassword: "Confirm password", + vaultConfirmPlaceholder: "Confirm your password...", + vaultWrongPassword: "That password didn't work. Please try again.", + vaultPasswordMismatch: "Passwords don't match.", + vaultPasswordTooShort: "Password needs at least 8 characters.", + vaultCreated: "Settings saved!", + vaultUnlockedMsg: "Welcome back!", + vaultLockedMsg: "Settings locked.", + vaultRequired: "Enter your password to access settings.", + vaultWelcome: "Your settings (model, voice, API keys) are saved safely behind a password of your choice. This keeps them private on this device.", + vaultWelcomeBack: "Welcome back! Enter your password to access your saved settings.", + vaultCreateInfo: "Choose a password to protect your settings. Your preferences and API keys will be encrypted and stored locally.", + vaultSessionInfo: "You'll stay logged in for {days} days.", + vaultChangePassword: "Change Password", + vaultDeleteVault: "Delete Settings", + vaultDeleteConfirm: "Are you sure? This will remove all your saved settings from this device.", + vaultCurrentPassword: "Current password", + vaultNewPassword: "New password", + vaultPasswordChanged: "Password changed!", + vaultDeleted: "Settings deleted.", }; \ No newline at end of file diff --git a/public/src/un.js b/public/src/un.js index b891f1b..a2384c1 100644 --- a/public/src/un.js +++ b/public/src/un.js @@ -10,9 +10,24 @@ const API_BASE = "https://api.unsandbox.com"; const POLL_DELAYS_MS = [300, 450, 700, 900, 650, 1600, 2000]; /** - * Get credentials from localStorage + * Get credentials from vault (preferred) or localStorage (fallback) */ export function getCredentials() { + // Try to get from vault first + if (typeof window !== 'undefined' && window.UncloseVault && window.UncloseVault.isUnlocked()) { + const useUnsandbox = window.UncloseVault.get("useUnsandbox", false); + if (!useUnsandbox) return null; + + const publicKey = window.UncloseVault.get("unsandboxPublicKey"); + const secretKey = window.UncloseVault.get("unsandboxSecretKey"); + + if (publicKey && secretKey) { + return { publicKey, secretKey }; + } + return null; + } + + // Fallback to localStorage (for migration or if vault not available) const useUnsandbox = localStorage.getItem("useUnsandbox") === "true"; if (!useUnsandbox) return null; diff --git a/public/src/uncloseai-embed-modal.js b/public/src/uncloseai-embed-modal.js index 036c692..f0c69e9 100644 --- a/public/src/uncloseai-embed-modal.js +++ b/public/src/uncloseai-embed-modal.js @@ -15,6 +15,23 @@ import { setUserLanguagePreference, } from "./ui-translations.js"; import { NATIVE_LANGUAGE_NAMES } from "./translation.js"; +import { UncloseVault } from "./vault.js"; + +// Load CryptoJS if not already loaded +async function ensureCryptoJS() { + if (typeof CryptoJS !== 'undefined') return true; + + return new Promise((resolve) => { + const script = document.createElement('script'); + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js'; + script.onload = () => resolve(true); + script.onerror = () => { + console.warn('Failed to load CryptoJS - vault features disabled'); + resolve(false); + }; + document.head.appendChild(script); + }); +} // Get USE_CUSTOM_STYLING from window or default const USE_CUSTOM_STYLING = window.UNCLOSEAI_CUSTOM_STYLING !== false; @@ -179,6 +196,12 @@ function refreshModalUI() { async function openUncloseaiEmbeddedModalNew() { try { + // Load CryptoJS and initialize vault + await ensureCryptoJS(); + if (UncloseVault.isAvailable()) { + UncloseVault.init(); + } + // Detect if PicoCSS is actually present on the page const hasPicoCSS = document.querySelector('link[href*="pico"]') !== null; @@ -277,6 +300,311 @@ async function openUncloseaiEmbeddedModalNew() { const settingsPanel = document.createElement("div"); settingsPanel.className = "uncloseai-modal-settings"; + // Vault UI section + const vaultSection = document.createElement("div"); + vaultSection.className = "uncloseai-section uncloseai-vault-section"; + vaultSection.style.gridColumn = "1 / -1"; // Full width + vaultSection.style.marginBottom = "16px"; + vaultSection.style.padding = "12px"; + vaultSection.style.borderRadius = "8px"; + vaultSection.style.border = "1px solid var(--uncloseai-border-color, #ccc)"; + + // Container for the rest of settings (will be hidden when vault locked) + const settingsContent = document.createElement("div"); + settingsContent.className = "uncloseai-settings-content"; + + // Helper to get vault value or localStorage fallback (for migration) + function getVaultOrStorage(key, defaultValue = null) { + if (UncloseVault.isAvailable() && UncloseVault.isUnlocked()) { + return UncloseVault.get(key, defaultValue); + } + // Fallback for when vault not available + const stored = localStorage.getItem(key); + return stored !== null ? stored : defaultValue; + } + + // Helper to set vault value + function setVaultValue(key, value) { + if (UncloseVault.isAvailable() && UncloseVault.isUnlocked()) { + UncloseVault.set(key, value); + } else { + // If vault not unlocked, store temporarily (will be lost) + console.warn('Vault locked - setting not persisted:', key); + } + } + + // Build vault UI based on current state + function buildVaultUI() { + vaultSection.innerHTML = ''; + + if (!UncloseVault.isAvailable()) { + // CryptoJS not loaded - settings work but won't persist across sessions + vaultSection.style.display = 'none'; + settingsContent.style.display = 'block'; + return; + } + + if (UncloseVault.isUnlocked()) { + // Vault unlocked - show simple "Logged in" status with lock button + vaultSection.style.display = 'block'; + const unlockedUI = document.createElement("div"); + unlockedUI.innerHTML = ` +
+
+ ✓ ${getUIText("vaultLoggedIn")} +
+
+ + +
+
+ `; + + const lockBtn = unlockedUI.querySelector('.vault-lock-btn'); + const settingsBtn = unlockedUI.querySelector('.vault-settings-btn'); + + lockBtn.onclick = () => { + UncloseVault.lock(); + buildVaultUI(); + // Close settings panel after locking + settingsOpen = false; + settingsPanel.classList.remove("open"); + }; + + settingsBtn.onclick = () => { + showVaultSettings(); + }; + + vaultSection.appendChild(unlockedUI); + settingsContent.style.display = 'block'; // Show settings when unlocked + } else { + // Vault locked or doesn't exist - show prompt + vaultSection.style.display = 'block'; + const hasVault = UncloseVault.hasVault(); + + const promptUI = document.createElement("div"); + promptUI.innerHTML = ` +
+
+ ${hasVault ? getUIText("vaultWelcomeBack") : getUIText("vaultWelcome")} +
+ + ${!hasVault ? `` : ''} + + +
+ `; + + const passwordInput = promptUI.querySelector('.vault-password'); + const confirmInput = promptUI.querySelector('.vault-confirm'); + const submitBtn = promptUI.querySelector('.vault-submit-btn'); + const errorDiv = promptUI.querySelector('.vault-error'); + + submitBtn.onclick = () => { + const password = passwordInput.value; + + if (hasVault) { + // Unlock existing vault + const result = UncloseVault.unlock(password); + if (result.success) { + buildVaultUI(); + refreshSettingsFromVault(); + } else { + errorDiv.textContent = getUIText("vaultWrongPassword"); + errorDiv.style.display = 'block'; + passwordInput.value = ''; + passwordInput.focus(); + } + } else { + // Create new vault + const confirm = confirmInput ? confirmInput.value : ''; + + if (password.length < 8) { + errorDiv.textContent = getUIText("vaultPasswordTooShort"); + errorDiv.style.display = 'block'; + return; + } + + if (password !== confirm) { + errorDiv.textContent = getUIText("vaultPasswordMismatch"); + errorDiv.style.display = 'block'; + return; + } + + const result = UncloseVault.create(password); + if (result.success) { + migrateSettingsToVault(); + buildVaultUI(); + } else { + errorDiv.textContent = result.error; + errorDiv.style.display = 'block'; + } + } + }; + + // Allow Enter key to submit + const lastInput = confirmInput || passwordInput; + lastInput.onkeydown = (e) => { + if (e.key === 'Enter') submitBtn.click(); + }; + + vaultSection.appendChild(promptUI); + settingsContent.style.display = 'none'; // Hide settings until unlocked + } + } + + // Show vault settings dialog (change password, delete) + function showVaultSettings() { + const existingDialog = document.getElementById('vault-settings-dialog'); + if (existingDialog) existingDialog.remove(); + + const dialog = document.createElement('dialog'); + dialog.id = 'vault-settings-dialog'; + dialog.style.cssText = 'padding: 20px; border-radius: 8px; max-width: 400px; border: 1px solid var(--uncloseai-border-color, #ccc);'; + dialog.innerHTML = ` +

🔑 ${getUIText("vaultChangePassword")}

+ + + + + +
+ +
+ ${getUIText("vaultDeleteVault")} +
+

${getUIText("vaultDeleteConfirm")}

+ + +
+
+ + + + + `; + + const changeBtn = dialog.querySelector('.change-password-btn'); + const deleteBtn = dialog.querySelector('.delete-vault-btn'); + const closeBtn = dialog.querySelector('.close-dialog-btn'); + const errorDiv = dialog.querySelector('.vault-dialog-error'); + + changeBtn.onclick = () => { + const current = dialog.querySelector('.current-password').value; + const newPass = dialog.querySelector('.new-password').value; + const confirm = dialog.querySelector('.confirm-password').value; + + if (newPass.length < 8) { + errorDiv.textContent = getUIText("vaultPasswordTooShort"); + errorDiv.style.display = 'block'; + return; + } + + if (newPass !== confirm) { + errorDiv.textContent = getUIText("vaultPasswordMismatch"); + errorDiv.style.display = 'block'; + return; + } + + const result = UncloseVault.changePassword(current, newPass); + if (result.success) { + alert(getUIText("vaultPasswordChanged")); + dialog.close(); + dialog.remove(); + } else { + errorDiv.textContent = result.error; + errorDiv.style.display = 'block'; + } + }; + + deleteBtn.onclick = () => { + const password = dialog.querySelector('.delete-password').value; + const result = UncloseVault.deleteVault(password); + + if (result.success) { + alert(getUIText("vaultDeleted")); + dialog.close(); + dialog.remove(); + buildVaultUI(); + } else { + errorDiv.textContent = result.error; + errorDiv.style.display = 'block'; + } + }; + + closeBtn.onclick = () => { + dialog.close(); + dialog.remove(); + }; + + document.body.appendChild(dialog); + dialog.showModal(); + } + + // Migrate existing localStorage settings to vault + function migrateSettingsToVault() { + if (!UncloseVault.isUnlocked()) return; + + const keysToMigrate = [ + 'selectedModel', 'selectedEndpoint', 'selectedVoice', + 'useCustomAPI', 'customBaseURL', 'customAPIKey', + 'useUnsandbox', 'unsandboxPublicKey', 'unsandboxSecretKey', + 'hermesSettingsOpen', 'uncloseai_language' + ]; + + keysToMigrate.forEach(key => { + const value = localStorage.getItem(key); + if (value !== null) { + // Parse booleans + let parsedValue = value; + if (value === 'true') parsedValue = true; + else if (value === 'false') parsedValue = false; + + UncloseVault.set(key, parsedValue); + // Remove from plain localStorage after migration + localStorage.removeItem(key); + } + }); + + console.log('Settings migrated to vault'); + } + + // Refresh UI inputs from vault values + function refreshSettingsFromVault() { + // Model selection will be loaded from vault when loadModels is called + // Voice selection will be loaded from vault when loadVoices is called + // Language will be loaded from vault + + // Refresh API config + if (customAPICheckbox) { + customAPICheckbox.checked = getVaultOrStorage('useCustomAPI') === true || getVaultOrStorage('useCustomAPI') === 'true'; + apiConfigInputs.style.display = customAPICheckbox.checked ? 'block' : 'none'; + } + if (baseURLInput) { + baseURLInput.value = getVaultOrStorage('customBaseURL', ''); + } + if (apiKeyInput) { + apiKeyInput.value = getVaultOrStorage('customAPIKey', ''); + } + + // Refresh unsandbox config + if (unsandboxCheckbox) { + unsandboxCheckbox.checked = getVaultOrStorage('useUnsandbox') === true || getVaultOrStorage('useUnsandbox') === 'true'; + unsandboxInputs.style.display = unsandboxCheckbox.checked ? 'block' : 'none'; + } + if (unsandboxPublicKeyInput) { + unsandboxPublicKeyInput.value = getVaultOrStorage('unsandboxPublicKey', ''); + } + if (unsandboxSecretKeyInput) { + unsandboxSecretKeyInput.value = getVaultOrStorage('unsandboxSecretKey', ''); + } + } + + // Initialize vault UI + buildVaultUI(); + // Model selection const modelSection = document.createElement("div"); modelSection.className = "uncloseai-section"; @@ -306,9 +634,9 @@ async function openUncloseaiEmbeddedModalNew() { modelSelect.appendChild(option); }); - // Load saved model from localStorage or use current selection - const savedModel = localStorage.getItem("selectedModel"); - const savedEndpoint = localStorage.getItem("selectedEndpoint"); + // Load saved model from vault or use current selection + const savedModel = getVaultOrStorage("selectedModel"); + const savedEndpoint = getVaultOrStorage("selectedEndpoint"); const currentModel = getSelectedModel(); if (savedModel && savedEndpoint) { @@ -356,9 +684,9 @@ async function openUncloseaiEmbeddedModalNew() { (model) => model.uniqueId === selectedUniqueId, ); if (selectedModel) { - // Update the global model selection - localStorage.setItem("selectedModel", selectedModel.modelName); - localStorage.setItem("selectedEndpoint", selectedModel.endpointId); + // Update the model selection in vault + setVaultValue("selectedModel", selectedModel.modelName); + setVaultValue("selectedEndpoint", selectedModel.endpointId); console.log("Model changed to:", selectedModel); } }; @@ -416,8 +744,8 @@ async function openUncloseaiEmbeddedModalNew() { voiceSelect.appendChild(optgroup); }); - // Load saved voice from localStorage or default to first voice - const savedVoice = localStorage.getItem("selectedVoice"); + // Load saved voice from vault or default to first voice + const savedVoice = getVaultOrStorage("selectedVoice"); if (savedVoice && voiceSelect.querySelector(`option[value="${savedVoice}"]`)) { voiceSelect.value = savedVoice; } @@ -425,9 +753,9 @@ async function openUncloseaiEmbeddedModalNew() { console.log("Voices loaded:", loadedVoicesCount, "voices from", models.length, "models"); }; - // Save voice selection to localStorage when changed + // Save voice selection to vault when changed voiceSelect.onchange = () => { - localStorage.setItem("selectedVoice", voiceSelect.value); + setVaultValue("selectedVoice", voiceSelect.value); console.log("Voice changed to:", voiceSelect.value); }; @@ -511,7 +839,7 @@ async function openUncloseaiEmbeddedModalNew() { const customAPICheckbox = document.createElement("input"); customAPICheckbox.type = "checkbox"; customAPICheckbox.id = "custom-api-toggle"; - customAPICheckbox.checked = localStorage.getItem("useCustomAPI") === "true"; + customAPICheckbox.checked = getVaultOrStorage("useCustomAPI") === true || getVaultOrStorage("useCustomAPI") === "true"; const customAPIToggleLabel = document.createElement("label"); customAPIToggleLabel.htmlFor = "custom-api-toggle"; @@ -528,7 +856,7 @@ async function openUncloseaiEmbeddedModalNew() { const baseURLInput = document.createElement("input"); baseURLInput.type = "text"; baseURLInput.placeholder = "https://api.openai.com/v1"; - baseURLInput.value = localStorage.getItem("customBaseURL") || ""; + baseURLInput.value = getVaultOrStorage("customBaseURL", ""); baseURLInput.className = "uncloseai-input"; baseURLContainer.appendChild(baseURLInput); @@ -540,7 +868,7 @@ async function openUncloseaiEmbeddedModalNew() { const apiKeyInput = document.createElement("input"); apiKeyInput.type = "password"; apiKeyInput.placeholder = "API Key"; - apiKeyInput.value = localStorage.getItem("customAPIKey") || ""; + apiKeyInput.value = getVaultOrStorage("customAPIKey", ""); apiKeyInput.className = "uncloseai-input"; apiKeyContainer.appendChild(apiKeyInput); @@ -556,16 +884,16 @@ async function openUncloseaiEmbeddedModalNew() { // Event handlers customAPICheckbox.onchange = () => { const useCustom = customAPICheckbox.checked; - localStorage.setItem("useCustomAPI", useCustom.toString()); + setVaultValue("useCustomAPI", useCustom); apiConfigInputs.style.display = useCustom ? "block" : "none"; }; baseURLInput.onchange = () => { - localStorage.setItem("customBaseURL", baseURLInput.value); + setVaultValue("customBaseURL", baseURLInput.value); }; apiKeyInput.onchange = () => { - localStorage.setItem("customAPIKey", apiKeyInput.value); + setVaultValue("customAPIKey", apiKeyInput.value); }; @@ -583,7 +911,7 @@ async function openUncloseaiEmbeddedModalNew() { const unsandboxCheckbox = document.createElement("input"); unsandboxCheckbox.type = "checkbox"; unsandboxCheckbox.id = "unsandbox-toggle"; - unsandboxCheckbox.checked = localStorage.getItem("useUnsandbox") === "true"; + unsandboxCheckbox.checked = getVaultOrStorage("useUnsandbox") === true || getVaultOrStorage("useUnsandbox") === "true"; const unsandboxToggleLabel = document.createElement("label"); unsandboxToggleLabel.htmlFor = "unsandbox-toggle"; @@ -600,7 +928,7 @@ async function openUncloseaiEmbeddedModalNew() { const unsandboxPublicKeyInput = document.createElement("input"); unsandboxPublicKeyInput.type = "text"; unsandboxPublicKeyInput.placeholder = getUIText("unsandboxPublicKey"); - unsandboxPublicKeyInput.value = localStorage.getItem("unsandboxPublicKey") || ""; + unsandboxPublicKeyInput.value = getVaultOrStorage("unsandboxPublicKey", ""); unsandboxPublicKeyInput.className = "uncloseai-input"; unsandboxPublicKeyContainer.appendChild(unsandboxPublicKeyInput); @@ -612,7 +940,7 @@ async function openUncloseaiEmbeddedModalNew() { const unsandboxSecretKeyInput = document.createElement("input"); unsandboxSecretKeyInput.type = "password"; unsandboxSecretKeyInput.placeholder = getUIText("unsandboxSecretKey"); - unsandboxSecretKeyInput.value = localStorage.getItem("unsandboxSecretKey") || ""; + unsandboxSecretKeyInput.value = getVaultOrStorage("unsandboxSecretKey", ""); unsandboxSecretKeyInput.className = "uncloseai-input"; unsandboxSecretKeyContainer.appendChild(unsandboxSecretKeyInput); @@ -627,16 +955,16 @@ async function openUncloseaiEmbeddedModalNew() { // Event handlers for unsandbox unsandboxCheckbox.onchange = () => { const useUnsandbox = unsandboxCheckbox.checked; - localStorage.setItem("useUnsandbox", useUnsandbox.toString()); + setVaultValue("useUnsandbox", useUnsandbox); unsandboxInputs.style.display = useUnsandbox ? "block" : "none"; }; unsandboxPublicKeyInput.onchange = () => { - localStorage.setItem("unsandboxPublicKey", unsandboxPublicKeyInput.value); + setVaultValue("unsandboxPublicKey", unsandboxPublicKeyInput.value); }; unsandboxSecretKeyInput.onchange = () => { - localStorage.setItem("unsandboxSecretKey", unsandboxSecretKeyInput.value); + setVaultValue("unsandboxSecretKey", unsandboxSecretKeyInput.value); }; unsandboxSection.appendChild(unsandboxToggle); @@ -725,12 +1053,20 @@ async function openUncloseaiEmbeddedModalNew() { // Quick actions span both columns actionsSection.className = "uncloseai-section uncloseai-actions-full-width"; - settingsPanel.appendChild(actionsSection); - settingsPanel.appendChild(leftColumn); - settingsPanel.appendChild(rightColumn); + // Wrap settings in settingsContent container (shown/hidden based on vault state) + settingsContent.appendChild(actionsSection); + settingsContent.appendChild(leftColumn); + settingsContent.appendChild(rightColumn); + settingsContent.style.display = 'grid'; + settingsContent.style.gridTemplateColumns = 'repeat(auto-fit, minmax(200px, 1fr))'; + settingsContent.style.gap = '16px'; + + // Add vault section first, then settings content + settingsPanel.appendChild(vaultSection); + settingsPanel.appendChild(settingsContent); // Load and save settings panel state - let settingsOpen = localStorage.getItem("hermesSettingsOpen") === "true"; + let settingsOpen = getVaultOrStorage("hermesSettingsOpen") === true || getVaultOrStorage("hermesSettingsOpen") === "true"; if (settingsOpen) { settingsPanel.classList.add("open"); } @@ -738,7 +1074,7 @@ async function openUncloseaiEmbeddedModalNew() { menuBtn.onclick = () => { settingsOpen = !settingsOpen; settingsPanel.classList.toggle("open", settingsOpen); - localStorage.setItem("hermesSettingsOpen", settingsOpen.toString()); + setVaultValue("hermesSettingsOpen", settingsOpen); }; // Set close button handler now that settings panel is available @@ -747,10 +1083,10 @@ async function openUncloseaiEmbeddedModalNew() { if (settingsOpen) { settingsOpen = false; settingsPanel.classList.toggle("open", settingsOpen); - localStorage.setItem("hermesSettingsOpen", settingsOpen.toString()); + setVaultValue("hermesSettingsOpen", settingsOpen); return; // Don't close modal yet } - + // Settings closed or not open, close the modal modal.close(); document.body.removeChild(modal); diff --git a/public/src/vault.js b/public/src/vault.js new file mode 100644 index 0000000..428735e --- /dev/null +++ b/public/src/vault.js @@ -0,0 +1,527 @@ +/** + * UncloseAI Vault - Encrypted Settings Storage + * + * All settings are encrypted with a user-chosen password using AES-256. + * Session persistence allows staying unlocked across page navigations. + * + * Requires CryptoJS library. + * + * Flow: + * 1. User creates vault with password (first time) + * 2. Settings encrypted with password, stored in localStorage + * 3. Session key derived and stored (encrypted) for auto-unlock + * 4. On page load, session key auto-unlocks if not expired + * 5. User can explicitly lock or session expires after TTL + */ + +const UncloseVault = { + // localStorage keys + VAULT_KEY: 'uncloseai_vault', + SALT_KEY: 'uncloseai_device_salt', + SESSION_KEY: 'uncloseai_session', + + // Session TTL (7 days in milliseconds) + SESSION_TTL_MS: 7 * 24 * 60 * 60 * 1000, + + // In-memory state + _settings: null, + _password: null, + + /** + * Check if CryptoJS is available + */ + isAvailable() { + return typeof CryptoJS !== 'undefined'; + }, + + /** + * Get or create device-specific salt (32 bytes) + */ + getDeviceSalt() { + let salt = localStorage.getItem(this.SALT_KEY); + if (!salt) { + if (!this.isAvailable()) return null; + const randomBytes = CryptoJS.lib.WordArray.random(32); + salt = randomBytes.toString(); + localStorage.setItem(this.SALT_KEY, salt); + } + return salt; + }, + + /** + * Generate vault ID from password (deterministic per device) + */ + getVaultId(password) { + if (!this.isAvailable()) return null; + const salt = this.getDeviceSalt(); + return CryptoJS.SHA256(password + salt).toString(); + }, + + /** + * Encrypt data with key + */ + encrypt(data, key) { + if (!this.isAvailable()) return null; + try { + return CryptoJS.AES.encrypt(JSON.stringify(data), key).toString(); + } catch (e) { + console.error('Vault encryption failed:', e); + return null; + } + }, + + /** + * Decrypt data with key + */ + decrypt(encryptedData, key) { + if (!this.isAvailable()) return null; + try { + const bytes = CryptoJS.AES.decrypt(encryptedData, key); + const decryptedStr = bytes.toString(CryptoJS.enc.Utf8); + if (!decryptedStr) return null; + return JSON.parse(decryptedStr); + } catch (e) { + return null; + } + }, + + /** + * Check if a vault exists + */ + hasVault() { + const vaultData = localStorage.getItem(this.VAULT_KEY); + if (!vaultData) return false; + try { + const vault = JSON.parse(vaultData); + return !!vault.encrypted_settings; + } catch (e) { + return false; + } + }, + + /** + * Check if vault is currently unlocked (in memory) + */ + isUnlocked() { + return this._settings !== null; + }, + + /** + * Get vault data from localStorage + */ + getVault() { + try { + const vaultJson = localStorage.getItem(this.VAULT_KEY); + return vaultJson ? JSON.parse(vaultJson) : null; + } catch (e) { + return null; + } + }, + + /** + * Save vault data to localStorage + */ + saveVault(vault) { + localStorage.setItem(this.VAULT_KEY, JSON.stringify(vault)); + }, + + /** + * Create session for auto-unlock persistence + * Stores the password encrypted with a random session key + */ + createSession(password) { + if (!this.isAvailable()) return; + + try { + // Generate random session key + const sessionKey = CryptoJS.lib.WordArray.random(32).toString(); + + // Encrypt password with session key + const encryptedPassword = CryptoJS.AES.encrypt(password, sessionKey).toString(); + + // Store session data + const sessionData = { + key: sessionKey, + data: encryptedPassword, + expires: Date.now() + this.SESSION_TTL_MS, + created: Date.now() + }; + + localStorage.setItem(this.SESSION_KEY, JSON.stringify(sessionData)); + } catch (e) { + console.error('Failed to create session:', e); + } + }, + + /** + * Try to restore session (auto-unlock) + * Returns true if session was valid and vault unlocked + */ + tryRestoreSession() { + if (!this.isAvailable()) return false; + if (this.isUnlocked()) return true; // Already unlocked + + try { + const sessionJson = localStorage.getItem(this.SESSION_KEY); + if (!sessionJson) return false; + + const session = JSON.parse(sessionJson); + + // Check expiry + if (Date.now() > session.expires) { + this.clearSession(); + return false; + } + + // Decrypt password from session + const bytes = CryptoJS.AES.decrypt(session.data, session.key); + const password = bytes.toString(CryptoJS.enc.Utf8); + + if (!password) { + this.clearSession(); + return false; + } + + // Try to unlock with recovered password + const result = this._unlockWithPassword(password, false); // Don't recreate session + + if (result.success) { + // Refresh session expiry on successful restore + session.expires = Date.now() + this.SESSION_TTL_MS; + localStorage.setItem(this.SESSION_KEY, JSON.stringify(session)); + return true; + } + + this.clearSession(); + return false; + } catch (e) { + console.error('Session restore failed:', e); + this.clearSession(); + return false; + } + }, + + /** + * Clear session data + */ + clearSession() { + localStorage.removeItem(this.SESSION_KEY); + }, + + /** + * Get session info (for UI display) + */ + getSessionInfo() { + try { + const sessionJson = localStorage.getItem(this.SESSION_KEY); + if (!sessionJson) return null; + + const session = JSON.parse(sessionJson); + const now = Date.now(); + + if (now > session.expires) return null; + + return { + expiresAt: new Date(session.expires), + expiresIn: session.expires - now, + createdAt: new Date(session.created) + }; + } catch (e) { + return null; + } + }, + + /** + * Internal unlock with password + */ + _unlockWithPassword(password, createNewSession = true) { + if (!this.isAvailable()) { + return { success: false, error: 'CryptoJS not available' }; + } + + const vault = this.getVault(); + if (!vault || !vault.encrypted_settings) { + return { success: false, error: 'No vault found' }; + } + + const decrypted = this.decrypt(vault.encrypted_settings, password); + if (!decrypted) { + return { success: false, error: 'Wrong password' }; + } + + // Store in memory + this._settings = decrypted; + this._password = password; + + // Create session for persistence + if (createNewSession) { + this.createSession(password); + } + + // Dispatch event + window.dispatchEvent(new CustomEvent('uncloseai-vault-unlocked')); + + return { success: true }; + }, + + /** + * Create a new vault with password + */ + create(password) { + if (!this.isAvailable()) { + return { success: false, error: 'CryptoJS not available. Include crypto-js library.' }; + } + if (!password || password.length < 8) { + return { success: false, error: 'Password must be at least 8 characters' }; + } + + if (this.hasVault()) { + return { success: false, error: 'Vault already exists. Use unlock() instead.' }; + } + + // Create with default settings + const defaultSettings = this.getDefaultSettings(); + const encrypted = this.encrypt(defaultSettings, password); + + if (!encrypted) { + return { success: false, error: 'Encryption failed' }; + } + + const vault = { + encrypted_settings: encrypted, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }; + + this.saveVault(vault); + + // Unlock in memory + this._settings = defaultSettings; + this._password = password; + + // Create session + this.createSession(password); + + window.dispatchEvent(new CustomEvent('uncloseai-vault-created')); + window.dispatchEvent(new CustomEvent('uncloseai-vault-unlocked')); + + return { success: true }; + }, + + /** + * Unlock vault with password + */ + unlock(password) { + if (!password) { + return { success: false, error: 'Password required' }; + } + return this._unlockWithPassword(password, true); + }, + + /** + * Lock the vault (clear memory and session) + */ + lock() { + this._settings = null; + this._password = null; + this.clearSession(); + window.dispatchEvent(new CustomEvent('uncloseai-vault-locked')); + }, + + /** + * Save current settings to vault + */ + save() { + if (!this.isUnlocked()) { + console.error('Cannot save: vault not unlocked'); + return false; + } + + const vault = this.getVault() || {}; + const encrypted = this.encrypt(this._settings, this._password); + + if (!encrypted) { + console.error('Failed to encrypt settings'); + return false; + } + + vault.encrypted_settings = encrypted; + vault.updated_at = new Date().toISOString(); + this.saveVault(vault); + + return true; + }, + + /** + * Get a setting value (returns default if vault locked) + */ + get(key, defaultValue = null) { + if (!this.isUnlocked()) { + // Return from defaults if not unlocked + const defaults = this.getDefaultSettings(); + return defaults[key] !== undefined ? defaults[key] : defaultValue; + } + return this._settings[key] !== undefined ? this._settings[key] : defaultValue; + }, + + /** + * Set a setting value (requires unlocked vault) + */ + set(key, value) { + if (!this.isUnlocked()) { + console.warn('Vault locked - setting not saved'); + return false; + } + this._settings[key] = value; + return this.save(); + }, + + /** + * Set multiple settings at once + */ + setMultiple(settingsObj) { + if (!this.isUnlocked()) { + console.warn('Vault locked - settings not saved'); + return false; + } + Object.assign(this._settings, settingsObj); + return this.save(); + }, + + /** + * Get all settings + */ + getAll() { + if (!this.isUnlocked()) return this.getDefaultSettings(); + return { ...this._settings }; + }, + + /** + * Default settings (used when vault doesn't exist or is locked) + */ + getDefaultSettings() { + return { + // Language + language: 'en', + + // Model selection + selectedModel: null, + selectedEndpoint: null, + selectedVoice: 'tts-1:onyx', + + // Custom API + useCustomAPI: false, + customBaseURL: '', + customAPIKey: '', + + // Unsandbox code execution + useUnsandbox: false, + unsandboxPublicKey: '', + unsandboxSecretKey: '', + + // UI preferences + settingsOpen: false + }; + }, + + /** + * Delete vault entirely (requires password confirmation) + */ + deleteVault(password) { + if (!this.isAvailable()) { + return { success: false, error: 'CryptoJS not available' }; + } + + const vault = this.getVault(); + if (!vault) { + return { success: false, error: 'No vault exists' }; + } + + // Verify password + const decrypted = this.decrypt(vault.encrypted_settings, password); + if (!decrypted) { + return { success: false, error: 'Wrong password' }; + } + + // Clear everything + localStorage.removeItem(this.VAULT_KEY); + this.clearSession(); + this._settings = null; + this._password = null; + + window.dispatchEvent(new CustomEvent('uncloseai-vault-deleted')); + + return { success: true }; + }, + + /** + * Change vault password + */ + changePassword(currentPassword, newPassword) { + if (!this.isAvailable()) { + return { success: false, error: 'CryptoJS not available' }; + } + if (!newPassword || newPassword.length < 8) { + return { success: false, error: 'New password must be at least 8 characters' }; + } + + const vault = this.getVault(); + if (!vault) { + return { success: false, error: 'No vault exists' }; + } + + // Decrypt with current password + const decrypted = this.decrypt(vault.encrypted_settings, currentPassword); + if (!decrypted) { + return { success: false, error: 'Wrong current password' }; + } + + // Re-encrypt with new password + const encrypted = this.encrypt(decrypted, newPassword); + if (!encrypted) { + return { success: false, error: 'Re-encryption failed' }; + } + + vault.encrypted_settings = encrypted; + vault.updated_at = new Date().toISOString(); + this.saveVault(vault); + + // Update in-memory and session + this._password = newPassword; + this.createSession(newPassword); + + return { success: true }; + }, + + /** + * Initialize - try to restore session on page load + * Call this when the page loads + */ + init() { + if (!this.isAvailable()) { + console.warn('UncloseVault: CryptoJS not available'); + return false; + } + + if (this.hasVault()) { + // Try auto-unlock from session + if (this.tryRestoreSession()) { + console.log('UncloseVault: Session restored, vault unlocked'); + return true; + } + console.log('UncloseVault: Vault exists but locked (no valid session)'); + return false; + } + + console.log('UncloseVault: No vault exists yet'); + return false; + } +}; + +// Export for ES modules +export { UncloseVault }; + +// Also make available globally +if (typeof window !== 'undefined') { + window.UncloseVault = UncloseVault; +}