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
+1 -1
View File
@@ -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 });
+81 -12
View File
@@ -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');
+56 -18
View File
@@ -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();
+36 -1
View File
@@ -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 };
})();