Add vault system for encrypted settings storage

- Add vault.js: encrypted settings with AES-256 + 7-day session persistence
- Integrate vault UI in settings modal with friendly messaging
- Update config.js and un.js to use vault for credentials
- Add translation strings for vault UI (en.js)
- Show "Logged in" status with Lock button instead of session info
- Vault password only asked when opening settings
This commit is contained in:
russell@unturf.com 2026-01-23 09:46:56 -05:00
parent 637064bf99
commit 3d07909854
5 changed files with 958 additions and 34 deletions

View file

@ -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");

View file

@ -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.",
};

View file

@ -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;

View file

@ -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 = `
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="color: #28a745;"> ${getUIText("vaultLoggedIn")}</span>
</div>
<div style="display: flex; gap: 8px;">
<button class="vault-lock-btn uncloseai-btn-small" title="${getUIText("vaultLockTooltip")}">🔒 ${getUIText("vaultLock")}</button>
<button class="vault-settings-btn uncloseai-btn-small" title="${getUIText("vaultChangePassword")}"></button>
</div>
</div>
`;
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 = `
<div style="text-align: center; padding: 8px 0;">
<div style="margin-bottom: 12px; font-size: 0.95em;">
${hasVault ? getUIText("vaultWelcomeBack") : getUIText("vaultWelcome")}
</div>
<input type="password" class="vault-password uncloseai-input" placeholder="${getUIText("vaultPasswordPlaceholder")}" style="margin-bottom: 8px; width: 100%;">
${!hasVault ? `<input type="password" class="vault-confirm uncloseai-input" placeholder="${getUIText("vaultConfirmPlaceholder")}" style="margin-bottom: 8px; width: 100%;">` : ''}
<button class="vault-submit-btn uncloseai-btn-control" style="width: 100%;">
${hasVault ? getUIText("vaultUnlock") : getUIText("vaultCreate")}
</button>
<div class="vault-error" style="color: #dc3545; margin-top: 8px; display: none;"></div>
</div>
`;
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 = `
<h3 style="margin-top: 0;">🔑 ${getUIText("vaultChangePassword")}</h3>
<input type="password" class="current-password uncloseai-input" placeholder="${getUIText("vaultCurrentPassword")}" style="margin-bottom: 8px; width: 100%;">
<input type="password" class="new-password uncloseai-input" placeholder="${getUIText("vaultNewPassword")}" style="margin-bottom: 8px; width: 100%;">
<input type="password" class="confirm-password uncloseai-input" placeholder="${getUIText("vaultConfirmPlaceholder")}" style="margin-bottom: 12px; width: 100%;">
<button class="change-password-btn uncloseai-btn-control" style="width: 100%; margin-bottom: 16px;">${getUIText("vaultChangePassword")}</button>
<hr style="margin: 16px 0; border: none; border-top: 1px solid #ccc;">
<details style="margin-bottom: 12px;">
<summary style="cursor: pointer; color: #6c757d; font-size: 0.9em;">${getUIText("vaultDeleteVault")}</summary>
<div style="margin-top: 12px;">
<p style="font-size: 0.9em; opacity: 0.8;">${getUIText("vaultDeleteConfirm")}</p>
<input type="password" class="delete-password uncloseai-input" placeholder="${getUIText("vaultCurrentPassword")}" style="margin-bottom: 8px; width: 100%;">
<button class="delete-vault-btn uncloseai-btn-control" style="width: 100%; background: #dc3545; border-color: #dc3545;">${getUIText("vaultDeleteVault")}</button>
</div>
</details>
<div class="vault-dialog-error" style="color: #dc3545; margin-top: 12px; display: none;"></div>
<button class="close-dialog-btn uncloseai-btn-control" style="width: 100%; margin-top: 8px;">Close</button>
`;
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);

527
public/src/vault.js Normal file
View file

@ -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;
}