04/26 Enhanced app and extension security
This commit is contained in:
+39
-1
@@ -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
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user