/**
* Encrypted Vault Backup Module (.stvault)
*
* Provides AES-256-GCM encrypted export/import of all localStorage data.
* Uses Web Crypto API (primary) with forge.js fallback for file:// protocol.
*
* Binary format (56-byte header + ciphertext):
* 0-6 : "STVAULT" magic bytes
* 7 : format version (0x01)
* 8-11 : PBKDF2 iterations (uint32 big-endian)
* 12-43 : 32-byte random salt
* 44-55 : 12-byte random IV/nonce
* 56+ : AES-256-GCM ciphertext (includes 16-byte auth tag)
*/
// =============================================================================
// CONSTANTS
// =============================================================================
const VAULT_MAGIC = new Uint8Array([0x53, 0x54, 0x56, 0x41, 0x55, 0x4C, 0x54]); // "STVAULT"
const VAULT_VERSION = 0x01;
const VAULT_HEADER_SIZE = 56;
const VAULT_PBKDF2_ITERATIONS = 100000;
const VAULT_MIN_PASSWORD_LENGTH = 8;
const VAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
// =============================================================================
// CRYPTO ABSTRACTION LAYER
// =============================================================================
/**
* Detect available crypto backend.
* @returns {'native'|'forge'|null}
*/
function getCryptoBackend() {
try {
if (
typeof crypto !== "undefined" &&
crypto.subtle &&
typeof crypto.subtle.importKey === "function"
) {
return "native";
}
} catch (_) {
/* ignore */
}
try {
if (typeof forge !== "undefined" && forge.cipher && forge.pkcs5) {
return "forge";
}
} catch (_) {
/* ignore */
}
return null;
}
/**
* Generate cryptographically random bytes.
* @param {number} length
* @returns {Uint8Array}
*/
function vaultRandomBytes(length) {
const backend = getCryptoBackend();
if (backend === "native") {
return crypto.getRandomValues(new Uint8Array(length));
}
if (backend === "forge") {
const bytes = forge.random.getBytesSync(length);
return new Uint8Array(
bytes.split("").map(function (c) {
return c.charCodeAt(0);
}),
);
}
throw new Error("No crypto backend available");
}
/**
* Derive AES-256 key from password using PBKDF2.
* @param {string} password
* @param {Uint8Array} salt - 32-byte salt
* @param {number} iterations
* @returns {Promise<CryptoKey|string>} Native CryptoKey or forge key bytes
*/
async function vaultDeriveKey(password, salt, iterations) {
const backend = getCryptoBackend();
if (backend === "native") {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: iterations,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
if (backend === "forge") {
var saltStr = String.fromCharCode.apply(null, salt);
var key = forge.pkcs5.pbkdf2(password, saltStr, iterations, 32, "sha256");
return key;
}
throw new Error("No crypto backend available");
}
/**
* Encrypt plaintext with AES-256-GCM.
* @param {Uint8Array} plaintext
* @param {CryptoKey|string} key
* @param {Uint8Array} iv - 12-byte nonce
* @returns {Promise<Uint8Array>} ciphertext + 16-byte auth tag
*/
async function vaultEncrypt(plaintext, key, iv) {
var backend = getCryptoBackend();
if (backend === "native") {
var result = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv },
key,
plaintext,
);
return new Uint8Array(result);
}
if (backend === "forge") {
var cipher = forge.cipher.createCipher(
"AES-GCM",
key,
);
var ivStr = String.fromCharCode.apply(null, iv);
cipher.start({ iv: ivStr, tagLength: 128 });
cipher.update(
forge.util.createBuffer(String.fromCharCode.apply(null, plaintext)),
);
cipher.finish();
var encrypted = cipher.output.getBytes();
var tag = cipher.mode.tag.getBytes();
var combined = new Uint8Array(encrypted.length + tag.length);
for (var i = 0; i < encrypted.length; i++) {
combined[i] = encrypted.charCodeAt(i);
}
for (var j = 0; j < tag.length; j++) {
combined[encrypted.length + j] = tag.charCodeAt(j);
}
return combined;
}
throw new Error("No crypto backend available");
}
/**
* Decrypt ciphertext with AES-256-GCM.
* @param {Uint8Array} ciphertext - ciphertext + 16-byte auth tag
* @param {CryptoKey|string} key
* @param {Uint8Array} iv - 12-byte nonce
* @returns {Promise<Uint8Array>} plaintext
* @throws {Error} On wrong password or corrupted data (GCM auth tag mismatch)
*/
async function vaultDecrypt(ciphertext, key, iv) {
var backend = getCryptoBackend();
if (backend === "native") {
try {
var result = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: iv },
key,
ciphertext,
);
return new Uint8Array(result);
} catch (_) {
throw new Error("Incorrect password or corrupted file.");
}
}
if (backend === "forge") {
// Split ciphertext and tag (last 16 bytes)
var tagLength = 16;
if (ciphertext.length < tagLength) {
throw new Error("Incorrect password or corrupted file.");
}
var encBytes = ciphertext.slice(0, ciphertext.length - tagLength);
var tagBytes = ciphertext.slice(ciphertext.length - tagLength);
var encStr = String.fromCharCode.apply(null, encBytes);
var tagStr = String.fromCharCode.apply(null, tagBytes);
var ivStr = String.fromCharCode.apply(null, iv);
var decipher = forge.cipher.createDecipher("AES-GCM", key);
decipher.start({
iv: ivStr,
tagLength: 128,
tag: forge.util.createBuffer(tagStr),
});
decipher.update(forge.util.createBuffer(encStr));
var pass = decipher.finish();
if (!pass) {
throw new Error("Incorrect password or corrupted file.");
}
var output = decipher.output.getBytes();
return new Uint8Array(
output.split("").map(function (c) {
return c.charCodeAt(0);
}),
);
}
throw new Error("No crypto backend available");
}
// =============================================================================
// BINARY FORMAT
// =============================================================================
/**
* Serialize vault header + ciphertext into a single binary blob.
* @param {Uint8Array} salt - 32 bytes
* @param {Uint8Array} iv - 12 bytes
* @param {number} iterations
* @param {Uint8Array} ciphertext
* @returns {Uint8Array}
*/
function serializeVaultFile(salt, iv, iterations, ciphertext) {
var file = new Uint8Array(VAULT_HEADER_SIZE + ciphertext.length);
// Magic bytes
file.set(VAULT_MAGIC, 0);
// Version
file[7] = VAULT_VERSION;
// Iterations (uint32 big-endian)
file[8] = (iterations >>> 24) & 0xff;
file[9] = (iterations >>> 16) & 0xff;
file[10] = (iterations >>> 8) & 0xff;
file[11] = iterations & 0xff;
// Salt
file.set(salt, 12);
// IV
file.set(iv, 44);
// Ciphertext
file.set(ciphertext, VAULT_HEADER_SIZE);
return file;
}
/**
* Parse a .stvault binary file into its components.
* @param {Uint8Array} fileBytes
* @returns {{salt: Uint8Array, iv: Uint8Array, iterations: number, ciphertext: Uint8Array}}
* @throws {Error} On invalid format
*/
function parseVaultFile(fileBytes) {
if (fileBytes.length < VAULT_HEADER_SIZE + 16) {
throw new Error("Not a valid .stvault file.");
}
// Check magic bytes
for (var i = 0; i < VAULT_MAGIC.length; i++) {
if (fileBytes[i] !== VAULT_MAGIC[i]) {
throw new Error("Not a valid .stvault file.");
}
}
// Check version
var version = fileBytes[7];
if (version > VAULT_VERSION) {
throw new Error(
"Created by a newer StakTrakr version. Please update.",
);
}
// Parse iterations
var iterations =
(fileBytes[8] << 24) |
(fileBytes[9] << 16) |
(fileBytes[10] << 8) |
fileBytes[11];
iterations = iterations >>> 0; // ensure unsigned
var salt = fileBytes.slice(12, 44);
var iv = fileBytes.slice(44, 56);
var ciphertext = fileBytes.slice(VAULT_HEADER_SIZE);
return {
salt: salt,
iv: iv,
iterations: iterations,
ciphertext: ciphertext,
};
}
// =============================================================================
// DATA COLLECTION / RESTORATION
// =============================================================================
/**
* Collect localStorage data for vault export.
* @param {string} [scope='full'] - 'full' collects all ALLOWED_STORAGE_KEYS;
* 'sync' collects only SYNC_SCOPE_KEYS (inventory + display prefs, no API keys or tokens)
* @returns {object|null} Payload object or null if empty
*/
function collectVaultData(scope) {
scope = scope || 'full';
var keysToCollect = scope === 'sync' && typeof SYNC_SCOPE_KEYS !== 'undefined'
? SYNC_SCOPE_KEYS
: ALLOWED_STORAGE_KEYS;
var payload = {
_meta: {
appVersion: typeof APP_VERSION !== "undefined" ? APP_VERSION : "unknown",
exportTimestamp: new Date().toISOString(),
scope: scope,
},
data: {},
};
var hasData = false;
for (var i = 0; i < keysToCollect.length; i++) {
var key = keysToCollect[i];
try {
var val = localStorage.getItem(key);
if (val !== null) {
payload.data[key] = val;
hasData = true;
}
} catch (e) {
debugLog("Vault: could not read key", key, e);
}
}
if (!hasData) return null;
// Compute checksum of the data section
var dataJson = JSON.stringify(payload.data);
payload._meta.checksum = simpleHash(dataJson);
return payload;
}
/**
* Simple hash for integrity check (not cryptographic — just detects corruption).
* @param {string} str
* @returns {string}
*/
function simpleHash(str) {
var hash = 0;
for (var i = 0; i < str.length; i++) {
var ch = str.charCodeAt(i);
hash = ((hash << 5) - hash + ch) | 0;
}
return "sh:" + (hash >>> 0).toString(16);
}
/**
* Restore vault data into localStorage and refresh UI.
* @param {object} payload - Decrypted vault payload
*/
function restoreVaultData(payload) {
var data = payload.data;
if (!data || typeof data !== "object") {
throw new Error("Vault file appears corrupted.");
}
// Write each key to localStorage
var keys = Object.keys(data);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
// Only restore recognized keys
if (ALLOWED_STORAGE_KEYS.indexOf(key) !== -1) {
try {
localStorage.setItem(key, data[key]);
} catch (e) {
debugLog("Vault: could not write key", key, e);
}
}
}
// Refresh the full UI
try {
if (typeof loadItemTags === "function") loadItemTags();
if (typeof loadInventory === "function") loadInventory();
if (typeof renderTable === "function") renderTable();
if (typeof renderActiveFilters === "function") renderActiveFilters();
if (typeof loadSpotHistory === "function") loadSpotHistory();
if (typeof fetchSpotPrice === "function") fetchSpotPrice();
} catch (e) {
debugLog("Vault: UI refresh error", e);
}
}
// =============================================================================
// PASSWORD STRENGTH
// =============================================================================
/**
* Evaluate password strength.
* @param {string} password
* @returns {{score: number, label: string, color: string}}
*/
function getPasswordStrength(password) {
if (!password || password.length < VAULT_MIN_PASSWORD_LENGTH) {
return { score: 0, label: "Too short", color: "var(--danger)" };
}
var score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^a-zA-Z0-9]/.test(password)) score++;
// Cap at 4
if (score > 4) score = 4;
var labels = ["Weak", "Fair", "Good", "Strong", "Very Strong"];
var colors = [
"var(--danger)",
"var(--warning)",
"var(--info)",
"var(--success)",
"var(--success)",
];
return {
score: score,
label: labels[score],
color: colors[score],
};
}
// =============================================================================
// SHARED ENCRYPT / DECRYPT HELPERS
// =============================================================================
/**
* Encrypt inventory data with the given password and return raw vault bytes.
* @param {string} password
* @returns {Promise<Uint8Array>} serialized vault file bytes
*/
async function vaultEncryptToBytes(password) {
var payload = collectVaultData('full');
if (!payload) throw new Error("No data to export.");
var plaintext = new TextEncoder().encode(JSON.stringify(payload));
var salt = vaultRandomBytes(32);
var iv = vaultRandomBytes(12);
var key = await vaultDeriveKey(password, salt, VAULT_PBKDF2_ITERATIONS);
var ciphertext = await vaultEncrypt(plaintext, key, iv);
return serializeVaultFile(salt, iv, VAULT_PBKDF2_ITERATIONS, ciphertext);
}
/**
* Encrypt sync-scoped data (inventory + display prefs only) and return raw vault bytes.
* Used by cloud auto-sync to avoid pushing API keys or cloud tokens to remote storage.
* @param {string} password
* @returns {Promise<Uint8Array>} serialized vault file bytes
*/
async function vaultEncryptToBytesScoped(password) {
var payload = collectVaultData('sync');
if (!payload) throw new Error("No inventory data to sync.");
var plaintext = new TextEncoder().encode(JSON.stringify(payload));
var salt = vaultRandomBytes(32);
var iv = vaultRandomBytes(12);
var key = await vaultDeriveKey(password, salt, VAULT_PBKDF2_ITERATIONS);
var ciphertext = await vaultEncrypt(plaintext, key, iv);
return serializeVaultFile(salt, iv, VAULT_PBKDF2_ITERATIONS, ciphertext);
}
/**
* Decrypt raw vault bytes with the given password and restore data.
* @param {Uint8Array|ArrayBuffer} fileBytes
* @param {string} password
* @returns {Promise<void>}
*/
async function vaultDecryptAndRestore(fileBytes, password) {
var parsed = parseVaultFile(new Uint8Array(fileBytes));
var key = await vaultDeriveKey(password, parsed.salt, parsed.iterations);
var plainBytes = await vaultDecrypt(parsed.ciphertext, key, parsed.iv);
var payload = JSON.parse(new TextDecoder().decode(plainBytes));
if (!payload || !payload.data) throw new Error("Vault file appears corrupted.");
restoreVaultData(payload);
}
// =============================================================================
// EXPORT FLOW
// =============================================================================
/**
* Export an encrypted vault backup.
* @param {string} password
* @returns {Promise<void>}
*/
async function exportEncryptedBackup(password) {
var backend = getCryptoBackend();
if (!backend) {
throw new Error(
"Encryption not available. Use Chrome/Safari/Edge or serve via HTTP.",
);
}
debugLog("Vault: exporting with", backend, "backend");
var fileBytes = await vaultEncryptToBytes(password);
// Download via Blob + anchor
var blob = new Blob([fileBytes], { type: "application/octet-stream" });
var url = URL.createObjectURL(blob);
var timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
var a = document.createElement("a");
a.href = url;
a.download = "staktrakr_backup_" + timestamp + VAULT_FILE_EXTENSION;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
debugLog("Vault: export complete,", fileBytes.length, "bytes");
}
// =============================================================================
// IMPORT FLOW
// =============================================================================
/**
* Import and decrypt a vault backup.
* @param {Uint8Array} fileBytes
* @param {string} password
* @returns {Promise<void>}
*/
async function importEncryptedBackup(fileBytes, password) {
var backend = getCryptoBackend();
if (!backend) {
throw new Error(
"Encryption not available. Use Chrome/Safari/Edge or serve via HTTP.",
);
}
if (fileBytes.length > VAULT_MAX_FILE_SIZE) {
throw new Error("File exceeds 50MB limit.");
}
debugLog("Vault: importing with", backend, "backend");
await vaultDecryptAndRestore(fileBytes, password);
debugLog("Vault: import complete");
}
// =============================================================================
// MODAL MANAGEMENT
// =============================================================================
/** @type {Uint8Array|null} Pending file bytes for import */
var _vaultPendingFile = null;
/** @type {object|null} Cloud context for cloud-export/cloud-import modes */
var _cloudContext = null;
/**
* Open the vault modal in export, import, cloud-export, or cloud-import mode.
* @param {'export'|'import'|'cloud-export'|'cloud-import'} mode
* @param {File|object} [fileOrOpts] - File for import, or { provider, fileBytes, filename, size } for cloud-import
*/
function openVaultModal(mode, fileOrOpts) {
var file = null;
var modal = safeGetElement("vaultModal");
if (!modal) return;
var titleEl = safeGetElement("vaultModalTitle");
var passwordEl = safeGetElement("vaultPassword");
var confirmRow = safeGetElement("vaultConfirmRow");
var confirmEl = safeGetElement("vaultConfirmPassword");
var strengthRow = safeGetElement("vaultStrengthRow");
var fileInfoEl = safeGetElement("vaultFileInfo");
var statusEl = safeGetElement("vaultStatus");
var actionBtn = safeGetElement("vaultActionBtn");
// Reset state
if (passwordEl) passwordEl.value = "";
if (confirmEl) confirmEl.value = "";
if (statusEl) {
statusEl.style.display = "none";
statusEl.className = "encryption-status";
statusEl.innerHTML = "";
}
// Update strength bar
updateStrengthBar("");
// Update match indicator
updateMatchIndicator("", "");
// Resolve effective mode for UI layout
var effectiveMode = mode;
_cloudContext = null;
if (mode === 'cloud-export') {
effectiveMode = 'export';
_cloudContext = { provider: fileOrOpts && fileOrOpts.provider ? fileOrOpts.provider : 'dropbox' };
} else if (mode === 'cloud-import') {
effectiveMode = 'import';
if (fileOrOpts && fileOrOpts.fileBytes) {
_cloudContext = {
provider: fileOrOpts.provider || 'dropbox',
fileBytes: fileOrOpts.fileBytes,
filename: fileOrOpts.filename || 'cloud-backup.stvault',
size: fileOrOpts.size || fileOrOpts.fileBytes.length,
};
_vaultPendingFile = fileOrOpts.fileBytes;
}
} else if (mode === 'import' && fileOrOpts instanceof File) {
file = fileOrOpts;
} else if (mode === 'import') {
file = fileOrOpts;
}
modal.setAttribute("data-vault-mode", mode);
if (effectiveMode === "export") {
var exportTitle = _cloudContext ? "Cloud Backup — Enter Password" : "Export Encrypted Backup";
if (titleEl) titleEl.textContent = exportTitle;
if (confirmRow) confirmRow.style.display = "";
if (strengthRow) strengthRow.style.display = "";
if (fileInfoEl) fileInfoEl.style.display = "none";
if (actionBtn) {
actionBtn.textContent = _cloudContext ? "Encrypt & Upload" : "Export";
actionBtn.className = "btn";
}
_vaultPendingFile = null;
} else {
var importTitle = _cloudContext ? "Cloud Restore — Enter Password" : "Import Encrypted Backup";
if (titleEl) titleEl.textContent = importTitle;
if (confirmRow) confirmRow.style.display = "none";
if (strengthRow) strengthRow.style.display = "none";
if (fileInfoEl) {
fileInfoEl.style.display = "";
var nameSpan = safeGetElement("vaultFileName");
var sizeSpan = safeGetElement("vaultFileSize");
if (_cloudContext) {
if (nameSpan) nameSpan.textContent = _cloudContext.filename;
if (sizeSpan) sizeSpan.textContent = formatFileSize(_cloudContext.size || 0);
} else if (file) {
if (nameSpan) nameSpan.textContent = file.name;
if (sizeSpan) sizeSpan.textContent = formatFileSize(file.size);
}
}
if (actionBtn) {
actionBtn.textContent = _cloudContext ? "Decrypt & Restore" : "Import";
actionBtn.className = "btn info";
}
// Read file bytes (local file import only — cloud sets _vaultPendingFile above)
if (file && !_cloudContext) {
var reader = new FileReader();
reader.onload = function (e) {
_vaultPendingFile = new Uint8Array(e.target.result);
};
reader.readAsArrayBuffer(file);
}
}
openModalById("vaultModal");
}
/**
* Close the vault modal and reset state.
*/
function closeVaultModal() {
_vaultPendingFile = null;
_cloudContext = null;
closeModalById("vaultModal");
}
/**
* Handle the vault modal action button (export or import).
*/
async function handleVaultAction() {
var modal = safeGetElement("vaultModal");
if (!modal) return;
var mode = modal.getAttribute("data-vault-mode");
var passwordEl = safeGetElement("vaultPassword");
var confirmEl = safeGetElement("vaultConfirmPassword");
var statusEl = safeGetElement("vaultStatus");
var actionBtn = safeGetElement("vaultActionBtn");
var password = passwordEl ? passwordEl.value : "";
// Validate password length
if (password.length < VAULT_MIN_PASSWORD_LENGTH) {
showVaultStatus(
"error",
"Password must be at least " + VAULT_MIN_PASSWORD_LENGTH + " characters.",
);
return;
}
// Determine effective mode
var isCloudExport = mode === "cloud-export";
var isCloudImport = mode === "cloud-import";
var effectiveMode = (isCloudExport) ? "export" : (isCloudImport) ? "import" : mode;
if (effectiveMode === "export") {
var confirm = confirmEl ? confirmEl.value : "";
if (password !== confirm) {
showVaultStatus("error", "Passwords do not match.");
return;
}
if (!getCryptoBackend()) {
showVaultStatus(
"error",
"Encryption not available. Use Chrome/Safari/Edge or serve via HTTP.",
);
return;
}
if (actionBtn) actionBtn.disabled = true;
showVaultStatus("info", "Encrypting\u2026");
try {
if (isCloudExport && _cloudContext) {
// Cloud export: encrypt then upload
var fileBytes = await vaultEncryptToBytes(password);
showVaultStatus("info", "Uploading\u2026");
await cloudUploadVault(_cloudContext.provider, fileBytes);
showVaultStatus("success", "Backup uploaded successfully.");
// Cache password for this browser session
if (typeof cloudCachePassword === 'function') {
cloudCachePassword(_cloudContext.provider, password);
}
if (typeof showKrakenToastIfFirst === 'function') showKrakenToastIfFirst();
} else {
await exportEncryptedBackup(password);
showVaultStatus("success", "Backup exported successfully.");
}
} catch (err) {
showVaultStatus("error", err.message || "Export failed.");
} finally {
if (actionBtn) actionBtn.disabled = false;
}
} else {
// Import mode
if (!_vaultPendingFile) {
showVaultStatus("error", "No file loaded.");
return;
}
if (!getCryptoBackend()) {
showVaultStatus(
"error",
"Encryption not available. Use Chrome/Safari/Edge or serve via HTTP.",
);
return;
}
if (actionBtn) actionBtn.disabled = true;
showVaultStatus("info", "Decrypting\u2026");
try {
await importEncryptedBackup(_vaultPendingFile, password);
// Cache password for this browser session
if (isCloudImport && _cloudContext && typeof cloudCachePassword === 'function') {
cloudCachePassword(_cloudContext.provider, password);
}
showVaultStatus("success", "Data restored successfully. Reloading\u2026");
setTimeout(function () { location.reload(); }, 1200);
} catch (err) {
showVaultStatus("error", err.message || "Import failed.");
} finally {
if (actionBtn) actionBtn.disabled = false;
}
}
}
// =============================================================================
// MODAL HELPERS
// =============================================================================
/**
* Show status message in the vault modal.
* @param {'success'|'error'|'info'|'warning'} type
* @param {string} message
*/
function showVaultStatus(type, message) {
var statusEl = safeGetElement("vaultStatus");
if (!statusEl) return;
statusEl.style.display = "";
statusEl.className = "encryption-status";
var dotClass = "status-" + type;
var isAnimated = type === "info";
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
statusEl.innerHTML =
'<div class="status-indicator ' + dotClass + '">' +
'<span class="status-dot' + (isAnimated ? " vault-dot-pulse" : "") + '"></span>' +
'<span class="status-text">' + escapeHtml(message) + "</span>" +
"</div>";
}
/**
* Format file size in human-readable form.
* @param {number} bytes
* @returns {string}
*/
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
/**
* Update the password strength bar.
* @param {string} password
*/
function updateStrengthBar(password) {
var fillEl = safeGetElement("vaultStrengthFill");
var textEl = safeGetElement("vaultStrengthText");
if (!fillEl || !textEl) return;
if (!password) {
fillEl.style.width = "0%";
fillEl.style.background = "transparent";
textEl.textContent = "";
return;
}
var strength = getPasswordStrength(password);
var percent = ((strength.score + 1) / 5) * 100;
if (strength.score === 0 && password.length < VAULT_MIN_PASSWORD_LENGTH) {
percent = (password.length / VAULT_MIN_PASSWORD_LENGTH) * 20;
}
fillEl.style.width = percent + "%";
fillEl.style.background = strength.color;
textEl.textContent = strength.label;
textEl.style.color = strength.color;
}
/**
* Update the password match indicator.
* @param {string} password
* @param {string} confirm
*/
function updateMatchIndicator(password, confirm) {
var matchEl = safeGetElement("vaultMatchIndicator");
if (!matchEl) return;
if (!confirm) {
matchEl.textContent = "";
matchEl.style.color = "";
return;
}
if (password === confirm) {
matchEl.textContent = "Passwords match";
matchEl.style.color = "var(--success)";
} else {
matchEl.textContent = "Passwords do not match";
matchEl.style.color = "var(--danger)";
}
}
/**
* Toggle password visibility for a field.
* @param {string} inputId
* @param {HTMLElement} toggleBtn
*/
function toggleVaultPasswordVisibility(inputId, toggleBtn) {
var input = safeGetElement(inputId);
if (!input) return;
if (input.type === "password") {
input.type = "text";
if (toggleBtn) toggleBtn.textContent = "\u25C9"; // ◉
} else {
input.type = "password";
if (toggleBtn) toggleBtn.textContent = "\u25CE"; // ◎
}
}
// =============================================================================
// WINDOW EXPORTS
// =============================================================================
window.openVaultModal = openVaultModal;
window.closeVaultModal = closeVaultModal;
window.vaultEncryptToBytes = vaultEncryptToBytes;
window.vaultEncryptToBytesScoped = vaultEncryptToBytesScoped;
window.vaultDecryptAndRestore = vaultDecryptAndRestore;
window.collectVaultData = collectVaultData;