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
+7
View File
@@ -29,6 +29,11 @@ class VaultItem(db.Model):
name = db.Column(db.String(255), nullable=False)
enc_data = db.Column(db.Text, nullable=False) # base64-encoded AES-256-GCM ciphertext
iv = db.Column(db.String(64), nullable=False) # base64-encoded 12-byte GCM nonce
# enc_name / iv_name: AES-256-GCM encrypted item name (client-side, same vault key).
# Nullable for backward-compatibility with existing rows — clients fall back to
# the plaintext 'name' column when enc_name is absent.
enc_name = db.Column(db.Text, nullable=True)
iv_name = db.Column(db.String(64), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@@ -44,6 +49,8 @@ class VaultItem(db.Model):
'folder_id': self.folder_id,
'enc_data': self.enc_data,
'iv': self.iv,
'enc_name': self.enc_name,
'iv_name': self.iv_name,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
+10
View File
@@ -389,6 +389,11 @@ def change_password():
if vault_item:
vault_item.enc_data = enc_data
vault_item.iv = iv
# Re-encrypt the name ciphertext if the client sent updated enc_name/iv_name.
if item_data.get('enc_name'):
vault_item.enc_name = item_data['enc_name']
if item_data.get('iv_name'):
vault_item.iv_name = item_data['iv_name']
# Update credentials
user.master_hash = hash_auth_token(new_auth_hash)
@@ -584,6 +589,11 @@ def recover_account():
if vault_item:
vault_item.enc_data = enc_data
vault_item.iv = iv
# Re-encrypt the name ciphertext if the client sent updated enc_name/iv_name.
if item_data.get('enc_name'):
vault_item.enc_name = item_data['enc_name']
if item_data.get('iv_name'):
vault_item.iv_name = item_data['iv_name']
user.master_hash = hash_auth_token(new_auth_hash)
user.enc_key_salt = new_enc_key_salt
+9
View File
@@ -33,6 +33,9 @@ def create_item():
iv = data.get('iv', '')
folder_id = data.get('folder_id')
enc_name = data.get('enc_name', '')
iv_name = data.get('iv_name', '')
if not name:
return jsonify({'error': 'name is required'}), 400
if item_type not in VALID_TYPES:
@@ -47,6 +50,8 @@ def create_item():
name=name,
enc_data=enc_data,
iv=iv,
enc_name=enc_name or None,
iv_name=iv_name or None,
)
try:
db.session.add(item)
@@ -94,6 +99,10 @@ def update_item(item_id):
item.enc_data = data['enc_data']
if 'iv' in data:
item.iv = data['iv']
if 'enc_name' in data:
item.enc_name = data['enc_name'] or None
if 'iv_name' in data:
item.iv_name = data['iv_name'] or None
db.session.flush()
AuditLog.log(
+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") {