04/26 Enhanced app and extension security

This commit is contained in:
2026-04-26 09:25:13 -04:00
parent ac91ea1fcc
commit cdda47872a
10 changed files with 378 additions and 37 deletions
+39 -1
View File
@@ -131,7 +131,45 @@ const Crypto = (() => {
return bytesToBase64(window.crypto.getRandomValues(new Uint8Array(byteLength)));
}
// ── Encrypt / Decrypt name string ───────────────────────────────────────
/**
* Encrypt a plain string (the item name) with the vault key.
* Uses a fresh random IV each call — same scheme as encryptItem.
* Returns { enc_name: base64, iv_name: base64 }
*/
async function encryptName(vaultKey, nameStr) {
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const plaintext = strToBytes(nameStr);
const ciphertext = await subtle.encrypt(
{ name: 'AES-GCM', iv },
vaultKey,
plaintext
);
return {
enc_name: bytesToBase64(new Uint8Array(ciphertext)),
iv_name: bytesToBase64(iv),
};
}
/**
* Decrypt an enc_name blob back to a plain string.
* Returns null if decryption fails (e.g. legacy item without enc_name).
*/
async function decryptName(vaultKey, enc_name, iv_name) {
try {
const plaintext = await subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(iv_name) },
vaultKey,
base64ToBytes(enc_name)
);
return new TextDecoder().decode(plaintext);
} catch {
return null;
}
}
// ── Public API ───────────────────────────────────────────────────────────
return { deriveAuthHash, deriveVaultKey, encryptItem, decryptItem, generateSalt };
return { deriveAuthHash, deriveVaultKey, encryptItem, decryptItem, encryptName, decryptName, generateSalt };
})();
+112 -4
View File
@@ -161,7 +161,14 @@ const Vault = (() => {
item.enc_data,
item.iv,
);
return { ...item, plain };
// Decrypt the item name if an encrypted version exists.
// Fall back to the server-stored plaintext name for legacy items.
let displayName = item.name;
if (item.enc_name && item.iv_name) {
const decrypted = await Crypto.decryptName(vaultKey, item.enc_name, item.iv_name);
if (decrypted) displayName = decrypted;
}
return { ...item, name: displayName, plain };
} catch {
return { ...item, plain: null, decryptError: true };
}
@@ -475,7 +482,35 @@ const Vault = (() => {
// ── Security Dashboard ────────────────────────────────────────────────────
function renderSecurityDashboard() {
/**
* Check a password against the HaveIBeenPwned Pwned Passwords API
* using k-anonymity (only the first 5 hex chars of SHA-1 are sent).
* Returns the breach count (0 = not found in any known breach).
*/
async function checkHibp(password) {
try {
const msgBuffer = new TextEncoder().encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-1', msgBuffer);
const hashHex = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
.toUpperCase();
const prefix = hashHex.slice(0, 5);
const suffix = hashHex.slice(5);
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
headers: { 'Add-Padding': 'true' },
});
if (!res.ok) return 0;
const text = await res.text();
const line = text.split('\n').find(l => l.startsWith(suffix));
if (!line) return 0;
return parseInt(line.split(':')[1], 10) || 0;
} catch {
return 0; // network error or API unavailable — fail safe (do not block UI)
}
}
async function renderSecurityDashboard() {
const summaryEl = document.getElementById("security-summary");
const sectionsEl = document.getElementById("security-sections");
if (!summaryEl || !sectionsEl) return;
@@ -597,6 +632,66 @@ const Vault = (() => {
"Same password used on multiple sites.",
);
makeSection("Old Passwords", "🕐", old, "Not changed in over 180 days.");
// ── HaveIBeenPwned breach check ──────────────────────────────────────────
// Run after the synchronous sections are rendered so the UI is immediately
// useful. The HIBP API is queried in parallel for all passwords.
const hibpSection = document.createElement("div");
hibpSection.className = "sec-section";
hibpSection.innerHTML = `
<div class="sec-section-header">
<span class="sec-section-icon">🔓</span>
<div>
<div class="sec-section-title">Checking for known breaches…</div>
<div class="sec-section-desc">Querying HaveIBeenPwned (k-anonymity — your passwords are never sent).</div>
</div>
</div>`;
sectionsEl.appendChild(hibpSection);
// Run all HIBP checks in parallel — k-anonymity: only 5-char SHA-1 prefix sent.
const hibpResults = await Promise.all(
pwItems.map(async (item) => ({
item,
count: await checkHibp(item.plain.password),
}))
);
const breached = hibpResults.filter(r => r.count > 0).map(r => r.item);
if (!breached.length) {
hibpSection.innerHTML = `
<div class="sec-section-header">
<span class="sec-section-icon">✅</span>
<div>
<div class="sec-section-title">No known breaches</div>
<div class="sec-section-desc">None of your passwords appeared in known data breaches (via HaveIBeenPwned).</div>
</div>
</div>`;
} else {
hibpSection.innerHTML = `
<div class="sec-section-header">
<span class="sec-section-icon">🔓</span>
<div>
<div class="sec-section-title">Breached Passwords (${breached.length})</div>
<div class="sec-section-desc">These passwords appeared in known data breaches. Change them immediately.</div>
</div>
</div>
<ul class="sec-item-list">
${breached.map(i => {
const count = hibpResults.find(r => r.item.id === i.id)?.count || 0;
return `<li class="sec-item">
<span class="sec-item-name">${escHtml(i.name)}</span>
<span class="sec-item-sub">${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? 's' : ''} in breaches</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button>
</li>`;
}).join("")}
</ul>`;
hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => {
btn.addEventListener("click", () => {
const item = _items.find((it) => it.id === parseInt(btn.dataset.secEdit));
if (item) { switchView("vault"); openModal("edit", item); }
});
});
}
}
// ── Sharing View ──────────────────────────────────────────────────────────
@@ -1711,7 +1806,16 @@ const Vault = (() => {
item.iv,
);
const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain);
reEncrypted.push({ id: item.id, enc_data, iv });
// Re-encrypt the name if it was previously encrypted.
let encNamePayload = {};
if (item.enc_name && item.iv_name) {
const plainName = await Crypto.decryptName(vaultKey, item.enc_name, item.iv_name);
if (plainName) {
const { enc_name, iv_name } = await Crypto.encryptName(newVaultKey, plainName);
encNamePayload = { enc_name, iv_name };
}
}
reEncrypted.push({ id: item.id, enc_data, iv, ...encNamePayload });
}
// Submit atomic password change
@@ -2222,13 +2326,17 @@ const Vault = (() => {
vaultKey,
buildPlainData(itemType),
);
// Encrypt the item name client-side so it is never stored in plaintext.
const { enc_name, iv_name } = await Crypto.encryptName(vaultKey, name);
const folderVal = getVal("field-folder");
const payload = {
name,
name: itemType, // non-sensitive server-side label — real name is in enc_name
item_type: itemType,
folder_id: folderVal ? parseInt(folderVal) : null,
enc_data,
iv,
enc_name,
iv_name,
};
if (mode === "add") {