diff --git a/app/models/vault_item.py b/app/models/vault_item.py index e22ff7d..8abfd88 100644 --- a/app/models/vault_item.py +++ b/app/models/vault_item.py @@ -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, } diff --git a/app/routes/auth.py b/app/routes/auth.py index 49bd7d4..dd8b388 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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 diff --git a/app/routes/vault.py b/app/routes/vault.py index 6fc27ae..fa3642b 100644 --- a/app/routes/vault.py +++ b/app/routes/vault.py @@ -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( diff --git a/app/static/js/crypto.js b/app/static/js/crypto.js index 77b3cfd..ec359d6 100644 --- a/app/static/js/crypto.js +++ b/app/static/js/crypto.js @@ -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 }; })(); diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 88411cf..1aa6fa0 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -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 = ` +
+ 🔓 +
+
Checking for known breaches…
+
Querying HaveIBeenPwned (k-anonymity — your passwords are never sent).
+
+
`; + 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 = ` +
+ +
+
No known breaches
+
None of your passwords appeared in known data breaches (via HaveIBeenPwned).
+
+
`; + } else { + hibpSection.innerHTML = ` +
+ 🔓 +
+
Breached Passwords (${breached.length})
+
These passwords appeared in known data breaches. Change them immediately.
+
+
+ `; + 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") { diff --git a/extension/background.js b/extension/background.js index 06f3ab3..d3fb2bb 100644 --- a/extension/background.js +++ b/extension/background.js @@ -40,8 +40,8 @@ chrome.idle.onStateChanged.addListener(async (newState) => { if ((stored[IDLE_TIMEOUT_KEY] ?? DEFAULT_IDLE_LOCK_SECONDS) === 0) return; console.log("[PassKeeper] System", newState, "— locking vault."); + // session.clear() also removes vault_items_cs (moved from local to session storage). await chrome.storage.session.clear(); - await chrome.storage.local.remove("vault_items_cs"); const tabs = await chrome.tabs.query({}); tabs.forEach((tab) => { if (tab.id) chrome.action.setBadgeText({ text: "", tabId: tab.id }); diff --git a/extension/content/content.js b/extension/content/content.js index 8bfbc63..3f0200d 100644 --- a/extension/content/content.js +++ b/extension/content/content.js @@ -50,21 +50,71 @@ .filter(el => isVisible(el) && !el.disabled); } + /** + * Returns true only if the input field carries signals suggesting it + * collects a credential (username / email / phone) — not a generic + * text field such as a search box, full-name field, or address field. + * + * Scoring precedence: + * 1. autocomplete="username"|"email"|"tel" → definite YES + * 2. Non-credential autocomplete value → definite NO + * 3. name / id / placeholder / aria-label contain a credential keyword → YES + * 4. Otherwise → NO (do not decorate) + */ + function _isLikelyUsernameField(el) { + const CRED_HINTS = /user|email|mail|login|phone|tel|mobile|account/i; + const ac = (el.getAttribute('autocomplete') || '').toLowerCase().trim(); + + // Strongest positive signal. + if (['username', 'email', 'tel'].includes(ac)) return true; + + // Definite negative signals (Chrome's autocomplete token set). + const NON_CRED_AC = /^(name|given-name|family-name|additional-name|honorific-prefix|honorific-suffix|organization|street-address|address-line[123]|address-level[1234]|country|country-name|postal-code|cc-|transaction-|language|bday|sex|url|photo|search|new-password|current-password|one-time-code|off)$/i; + if (ac && NON_CRED_AC.test(ac)) return false; + + // Check name, id, placeholder, and aria-label for credential keywords. + const attrs = [ + el.getAttribute('name') || '', + el.getAttribute('id') || '', + el.getAttribute('placeholder') || '', + el.getAttribute('aria-label') || '', + ].join(' '); + + return CRED_HINTS.test(attrs); + } + function findUsernameField(pwField) { + // Helper: accept email/tel inputs unconditionally; text inputs only when + // they look like a genuine credential field. + function isCredentialType(el) { + if (el.type === 'email' || el.type === 'tel') return true; + if (el.type === 'text') return _isLikelyUsernameField(el); + return false; + } + // 1. Walk backwards through all inputs in DOM order. const all = Array.from(document.querySelectorAll('input')); const idx = all.indexOf(pwField); for (let i = idx - 1; i >= 0; i--) { const el = all[i]; if (!isVisible(el) || el.disabled) continue; - if (['email', 'text', 'tel'].includes(el.type)) return el; + if (isCredentialType(el)) return el; } - // 2. Fallback: search within the same form/ancestor container. + + // 2. Fallback: search within the same form / ancestor container. + // Prefer email inputs first, then scored text/tel inputs. const container = pwField.closest('form') || pwField.closest('[role="form"]') || pwField.parentElement; if (container) { - const candidate = container.querySelector('input[type="email"]:not([disabled]), input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])'); - if (candidate && isVisible(candidate)) return candidate; + const emailCandidate = container.querySelector('input[type="email"]:not([disabled])'); + if (emailCandidate && isVisible(emailCandidate)) return emailCandidate; + + const textTelInputs = Array.from( + container.querySelectorAll('input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])') + ); + const scored = textTelInputs.filter(el => isVisible(el) && _isLikelyUsernameField(el)); + if (scored.length) return scored[0]; } + return null; } @@ -127,7 +177,7 @@ var freshItems = _matchingItems; if (!freshItems.length) { try { - var result = await chrome.storage.local.get('vault_items_cs'); + var result = await chrome.storage.session.get('vault_items_cs'); var all = (result && result.vault_items_cs) || []; freshItems = _filterForHost(all); if (freshItems.length) _matchingItems = freshItems; @@ -599,11 +649,11 @@ } async function decorateFields() { - // Read from chrome.storage.local — reliable across all Chrome versions and - // does not depend on message delivery from the service worker. + // Read from chrome.storage.session — memory-only, cleared on browser close. + // Decrypted vault data must never be written to persistent (local) storage. var all = []; try { - var result = await chrome.storage.local.get('vault_items_cs'); + var result = await chrome.storage.session.get('vault_items_cs'); all = (result && result.vault_items_cs) || []; } catch (e) { } @@ -621,14 +671,33 @@ // ── Vault item helpers ──────────────────────────────────────────────────────── + /** + * Normalise a stored URL string so it is always parseable by `new URL()`. + * Handles bare domains ("github.com"), protocol-relative ("//github.com"), + * and fully-formed URLs ("https://github.com") identically. + */ + function _normaliseUrl(raw) { + if (!raw) return null; + var s = raw.trim(); + if (/^https?:\/\//i.test(s)) return s; // already has a scheme + if (s.startsWith('//')) return 'https:' + s; // protocol-relative + return 'https://' + s; // bare domain or path + } + function _filterForHost(items) { var host = location.hostname.replace(/^www\./, ''); return (items || []).filter(function (item) { if (item.item_type !== 'password' || !(item.plain && item.plain.url)) return false; try { - var h = new URL(item.plain.url).hostname.replace(/^www\./, ''); + var normalised = _normaliseUrl(item.plain.url); + if (!normalised) return false; + var h = new URL(normalised).hostname.replace(/^www\./, ''); + // Match exact domain or any subdomain relationship. return h === host || h.endsWith('.' + host) || host.endsWith('.' + h); - } catch (e) { return false; } + } catch (e) { + console.warn('[PassKeeper] _filterForHost: could not parse URL:', item.plain.url, e.message); + return false; + } }); } @@ -646,7 +715,7 @@ async function classifyCredentials(username, password) { var all = []; try { - var result = await chrome.storage.local.get('vault_items_cs'); + var result = await chrome.storage.session.get('vault_items_cs'); all = (result && result.vault_items_cs) || []; } catch (e) { return 'new'; } if (!all.length) return 'new'; @@ -816,7 +885,7 @@ // React instantly when the popup writes fresh vault data to local storage. // This fires in the same tick as the write — no message delivery required. chrome.storage.onChanged.addListener(function (changes, area) { - if (area === 'local' && changes.vault_items_cs) { + if (area === 'session' && changes.vault_items_cs) { var allItems = (changes.vault_items_cs.newValue) || []; _matchingItems = _filterForHost(allItems); console.log('[PassKeeper] storage.onChanged: matched=' + _matchingItems.length + ' of ' + allItems.length + ' items'); diff --git a/extension/popup/popup.js b/extension/popup/popup.js index 50c534d..005189e 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -87,11 +87,26 @@ function currentHostname() { try { return new URL(_currentUrl).hostname.replace(/^www\./, ''); } catch { return ''; } } +/** + * Normalise a stored URL so it is always parseable by `new URL()`. + * Handles bare domains ("github.com"), protocol-relative, and full URLs. + */ +function _normaliseUrl(raw) { + if (!raw) return null; + const s = raw.trim(); + if (/^https?:\/\//i.test(s)) return s; + if (s.startsWith('//')) return 'https:' + s; + return 'https://' + s; +} + function isMatch(item) { const host = currentHostname(); if (!host || item.item_type !== 'password' || !item.plain?.url) return false; try { - const h = new URL(item.plain.url).hostname.replace(/^www\./, ''); + const normalised = _normaliseUrl(item.plain.url); + if (!normalised) return false; + const h = new URL(normalised).hostname.replace(/^www\./, ''); + // Match exact domain or any subdomain relationship. return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`); } catch { return false; } } @@ -263,7 +278,8 @@ async function signOut() { } } catch { } await chrome.storage.session.clear(); - await chrome.storage.local.remove(['refresh_token', 'enc_key_salt', 'vault_items_cs']); + await chrome.storage.local.remove(['refresh_token', 'enc_key_salt']); + // vault_items_cs is now in session storage — cleared by the session.clear() call above. _vaultKey = null; _items = []; showView('login'); } @@ -312,23 +328,30 @@ async function fetchAndDecryptVault() { _items = await Promise.all(raw.map(async item => { try { const plain = await ExtCrypto.decryptItem(_vaultKey, 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 ExtCrypto.decryptName(_vaultKey, item.enc_name, item.iv_name); + if (decrypted) displayName = decrypted; + } + return { ...item, name: displayName, plain }; } catch { return { ...item, plain: null }; } })); // Write to session for the popup's own use (badge, rendering). await chrome.storage.session.set({ vault_items: _items }); - // Write a lightweight copy to local storage — this is what content scripts - // read, since chrome.storage.local works reliably across all Chrome versions - // and does not require message delivery from the background service worker. + // Write a lightweight copy to session storage for content scripts. + // session storage is memory-only (cleared on browser close) — decrypted + // vault data must never be persisted to disk via chrome.storage.local. const itemsForContentScript = _items.map(item => ({ id: item.id, name: item.name, item_type: item.item_type, plain: item.plain, })); - await chrome.storage.local.set({ vault_items_cs: itemsForContentScript }); + await chrome.storage.session.set({ vault_items_cs: itemsForContentScript }); // Notify background to refresh badges and forward to content scripts. chrome.runtime.sendMessage({ @@ -658,13 +681,14 @@ async function saveCredential(data) { const folderVal = $('save-folder').value; const folder_id = folderVal ? parseInt(folderVal, 10) : null; const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain); + const { enc_name, iv_name } = await ExtCrypto.encryptName(_vaultKey, name); try { const res = await apiFetch('/api/vault', { method: 'POST', - body: JSON.stringify({ name, item_type: 'password', folder_id, enc_data, iv }), + body: JSON.stringify({ name: 'password', item_type: 'password', folder_id, enc_data, iv, enc_name, iv_name }), }); if (res?.ok) { - console.log('[PassKeeper] Credential saved to vault, folder_id:', folder_id); + console.log('[PassKeeper] Credential saved to vault:', name, '(name encrypted), folder_id:', folder_id); await fetchAndDecryptVault(); renderList(); } @@ -680,6 +704,18 @@ const GEN_SETS = { symbols: '!@#$%^&*()-_=+[]{}|;:,.<>?', }; +/** + * Cryptographically secure random integer in [0, max). + * Uses crypto.getRandomValues exclusively — Math.random() is never called. + */ +function _cryptoRandInt(max) { + // Rejection sampling to eliminate modulo bias. + const limit = Math.floor(0x100000000 / max) * max; + const buf = new Uint32Array(1); + do { crypto.getRandomValues(buf); } while (buf[0] >= limit); + return buf[0] % max; +} + function generatePassword(length, useLower, useUpper, useNumbers, useSymbols) { // Always fall back to lower if nothing selected, preventing infinite loop. const pool = [ @@ -690,12 +726,13 @@ function generatePassword(length, useLower, useUpper, useNumbers, useSymbols) { ].join(''); if (!pool) return ''; - // Guarantee at least one character from each selected charset. + // Guarantee at least one character from each selected charset + // using cryptographically secure random selection. const required = []; - if (useLower) required.push(GEN_SETS.lower[Math.floor(Math.random() * GEN_SETS.lower.length)]); - if (useUpper) required.push(GEN_SETS.upper[Math.floor(Math.random() * GEN_SETS.upper.length)]); - if (useNumbers) required.push(GEN_SETS.numbers[Math.floor(Math.random() * GEN_SETS.numbers.length)]); - if (useSymbols) required.push(GEN_SETS.symbols[Math.floor(Math.random() * GEN_SETS.symbols.length)]); + if (useLower) required.push(GEN_SETS.lower[_cryptoRandInt(GEN_SETS.lower.length)]); + if (useUpper) required.push(GEN_SETS.upper[_cryptoRandInt(GEN_SETS.upper.length)]); + if (useNumbers) required.push(GEN_SETS.numbers[_cryptoRandInt(GEN_SETS.numbers.length)]); + if (useSymbols) required.push(GEN_SETS.symbols[_cryptoRandInt(GEN_SETS.symbols.length)]); const arr = new Uint32Array(length); crypto.getRandomValues(arr); @@ -704,9 +741,9 @@ function generatePassword(length, useLower, useUpper, useNumbers, useSymbols) { // Splice required chars into random positions and trim to length. const combined = [...rest]; required.forEach((ch, i) => { combined[i] = ch; }); - // Fisher-Yates shuffle for uniform distribution. + // Fisher-Yates shuffle — fully CSPRNG, no Math.random(). for (let i = combined.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); + const j = _cryptoRandInt(i + 1); [combined[i], combined[j]] = [combined[j], combined[i]]; } return combined.slice(0, length).join(''); @@ -894,16 +931,17 @@ async function addItemToVault() { try { const plain = { url, username, password, notes }; const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain); + const { enc_name, iv_name } = await ExtCrypto.encryptName(_vaultKey, name); const res = await apiFetch('/api/vault', { method: 'POST', - body: JSON.stringify({ name, item_type: 'password', folder_id, enc_data, iv }), + body: JSON.stringify({ name: 'password', item_type: 'password', folder_id, enc_data, iv, enc_name, iv_name }), }); if (!res?.ok) { const data = await res.json().catch(() => ({})); showError('add-error', data.error || 'Failed to save. Please try again.'); return; } - console.log('[PassKeeper] Item added to vault:', name); + console.log('[PassKeeper] Item added to vault:', name, '(name encrypted)'); // Refresh vault and go back. await fetchAndDecryptVault(); renderList(); diff --git a/extension/shared/crypto.js b/extension/shared/crypto.js index cd8c027..5cc2f70 100644 --- a/extension/shared/crypto.js +++ b/extension/shared/crypto.js @@ -95,5 +95,40 @@ const ExtCrypto = (() => { return bytesToBase64(crypto.getRandomValues(new Uint8Array(byteLength))); } - return { deriveAuthHash, deriveVaultKey, exportVaultKey, importVaultKey, encryptItem, decryptItem, generateSalt }; + /** + * Encrypt a plain name string with the vault key. + * Returns { enc_name: base64, iv_name: base64 } + */ + async function encryptName(vaultKey, nameStr) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(nameStr); + const ciphertext = await crypto.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 on failure (legacy item without enc_name). + */ + async function decryptName(vaultKey, enc_name, iv_name) { + try { + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBytes(iv_name) }, + vaultKey, + base64ToBytes(enc_name) + ); + return new TextDecoder().decode(plaintext); + } catch { + return null; + } + } + + return { deriveAuthHash, deriveVaultKey, exportVaultKey, importVaultKey, encryptItem, decryptItem, encryptName, decryptName, generateSalt }; })(); diff --git a/migrations/versions/c3d4e5f6a7b8_encrypt_vault_item_name.py b/migrations/versions/c3d4e5f6a7b8_encrypt_vault_item_name.py new file mode 100644 index 0000000..c249486 --- /dev/null +++ b/migrations/versions/c3d4e5f6a7b8_encrypt_vault_item_name.py @@ -0,0 +1,27 @@ +"""encrypt vault item name client-side + +Revision ID: c3d4e5f6a7b8 +Revises: a1b2c3d4e5f6 +Create Date: 2026-04-26 00:00:00.000000 + +Adds enc_name (AES-256-GCM ciphertext) and iv_name (nonce) columns to vault_items. +The plaintext name column is retained for backward-compatibility and audit logs only. +Clients populate enc_name/iv_name on create/update; the server never decrypts it. +""" +from alembic import op +import sqlalchemy as sa + +revision = 'c3d4e5f6a7b8' +down_revision = 'a1b2c3d4e5f6' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('vault_items', sa.Column('enc_name', sa.Text(), nullable=True)) + op.add_column('vault_items', sa.Column('iv_name', sa.String(length=64), nullable=True)) + + +def downgrade(): + op.drop_column('vault_items', 'iv_name') + op.drop_column('vault_items', 'enc_name')