04/26 Enhanced app and extension security
This commit is contained in:
@@ -29,6 +29,11 @@ class VaultItem(db.Model):
|
|||||||
name = db.Column(db.String(255), nullable=False)
|
name = db.Column(db.String(255), nullable=False)
|
||||||
enc_data = db.Column(db.Text, nullable=False) # base64-encoded AES-256-GCM ciphertext
|
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
|
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)
|
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)
|
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,
|
'folder_id': self.folder_id,
|
||||||
'enc_data': self.enc_data,
|
'enc_data': self.enc_data,
|
||||||
'iv': self.iv,
|
'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,
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -389,6 +389,11 @@ def change_password():
|
|||||||
if vault_item:
|
if vault_item:
|
||||||
vault_item.enc_data = enc_data
|
vault_item.enc_data = enc_data
|
||||||
vault_item.iv = iv
|
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
|
# Update credentials
|
||||||
user.master_hash = hash_auth_token(new_auth_hash)
|
user.master_hash = hash_auth_token(new_auth_hash)
|
||||||
@@ -584,6 +589,11 @@ def recover_account():
|
|||||||
if vault_item:
|
if vault_item:
|
||||||
vault_item.enc_data = enc_data
|
vault_item.enc_data = enc_data
|
||||||
vault_item.iv = iv
|
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.master_hash = hash_auth_token(new_auth_hash)
|
||||||
user.enc_key_salt = new_enc_key_salt
|
user.enc_key_salt = new_enc_key_salt
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ def create_item():
|
|||||||
iv = data.get('iv', '')
|
iv = data.get('iv', '')
|
||||||
folder_id = data.get('folder_id')
|
folder_id = data.get('folder_id')
|
||||||
|
|
||||||
|
enc_name = data.get('enc_name', '')
|
||||||
|
iv_name = data.get('iv_name', '')
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
return jsonify({'error': 'name is required'}), 400
|
return jsonify({'error': 'name is required'}), 400
|
||||||
if item_type not in VALID_TYPES:
|
if item_type not in VALID_TYPES:
|
||||||
@@ -47,6 +50,8 @@ def create_item():
|
|||||||
name=name,
|
name=name,
|
||||||
enc_data=enc_data,
|
enc_data=enc_data,
|
||||||
iv=iv,
|
iv=iv,
|
||||||
|
enc_name=enc_name or None,
|
||||||
|
iv_name=iv_name or None,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
db.session.add(item)
|
db.session.add(item)
|
||||||
@@ -94,6 +99,10 @@ def update_item(item_id):
|
|||||||
item.enc_data = data['enc_data']
|
item.enc_data = data['enc_data']
|
||||||
if 'iv' in data:
|
if 'iv' in data:
|
||||||
item.iv = data['iv']
|
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()
|
db.session.flush()
|
||||||
AuditLog.log(
|
AuditLog.log(
|
||||||
|
|||||||
+39
-1
@@ -131,7 +131,45 @@ const Crypto = (() => {
|
|||||||
return bytesToBase64(window.crypto.getRandomValues(new Uint8Array(byteLength)));
|
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 ───────────────────────────────────────────────────────────
|
// ── 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.enc_data,
|
||||||
item.iv,
|
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 {
|
} catch {
|
||||||
return { ...item, plain: null, decryptError: true };
|
return { ...item, plain: null, decryptError: true };
|
||||||
}
|
}
|
||||||
@@ -475,7 +482,35 @@ const Vault = (() => {
|
|||||||
|
|
||||||
// ── Security Dashboard ────────────────────────────────────────────────────
|
// ── 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 summaryEl = document.getElementById("security-summary");
|
||||||
const sectionsEl = document.getElementById("security-sections");
|
const sectionsEl = document.getElementById("security-sections");
|
||||||
if (!summaryEl || !sectionsEl) return;
|
if (!summaryEl || !sectionsEl) return;
|
||||||
@@ -597,6 +632,66 @@ const Vault = (() => {
|
|||||||
"Same password used on multiple sites.",
|
"Same password used on multiple sites.",
|
||||||
);
|
);
|
||||||
makeSection("Old Passwords", "🕐", old, "Not changed in over 180 days.");
|
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 ──────────────────────────────────────────────────────────
|
// ── Sharing View ──────────────────────────────────────────────────────────
|
||||||
@@ -1711,7 +1806,16 @@ const Vault = (() => {
|
|||||||
item.iv,
|
item.iv,
|
||||||
);
|
);
|
||||||
const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain);
|
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
|
// Submit atomic password change
|
||||||
@@ -2222,13 +2326,17 @@ const Vault = (() => {
|
|||||||
vaultKey,
|
vaultKey,
|
||||||
buildPlainData(itemType),
|
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 folderVal = getVal("field-folder");
|
||||||
const payload = {
|
const payload = {
|
||||||
name,
|
name: itemType, // non-sensitive server-side label — real name is in enc_name
|
||||||
item_type: itemType,
|
item_type: itemType,
|
||||||
folder_id: folderVal ? parseInt(folderVal) : null,
|
folder_id: folderVal ? parseInt(folderVal) : null,
|
||||||
enc_data,
|
enc_data,
|
||||||
iv,
|
iv,
|
||||||
|
enc_name,
|
||||||
|
iv_name,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === "add") {
|
if (mode === "add") {
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ chrome.idle.onStateChanged.addListener(async (newState) => {
|
|||||||
if ((stored[IDLE_TIMEOUT_KEY] ?? DEFAULT_IDLE_LOCK_SECONDS) === 0) return;
|
if ((stored[IDLE_TIMEOUT_KEY] ?? DEFAULT_IDLE_LOCK_SECONDS) === 0) return;
|
||||||
|
|
||||||
console.log("[PassKeeper] System", newState, "— locking vault.");
|
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.session.clear();
|
||||||
await chrome.storage.local.remove("vault_items_cs");
|
|
||||||
const tabs = await chrome.tabs.query({});
|
const tabs = await chrome.tabs.query({});
|
||||||
tabs.forEach((tab) => {
|
tabs.forEach((tab) => {
|
||||||
if (tab.id) chrome.action.setBadgeText({ text: "", tabId: tab.id });
|
if (tab.id) chrome.action.setBadgeText({ text: "", tabId: tab.id });
|
||||||
|
|||||||
@@ -50,21 +50,71 @@
|
|||||||
.filter(el => isVisible(el) && !el.disabled);
|
.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) {
|
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.
|
// 1. Walk backwards through all inputs in DOM order.
|
||||||
const all = Array.from(document.querySelectorAll('input'));
|
const all = Array.from(document.querySelectorAll('input'));
|
||||||
const idx = all.indexOf(pwField);
|
const idx = all.indexOf(pwField);
|
||||||
for (let i = idx - 1; i >= 0; i--) {
|
for (let i = idx - 1; i >= 0; i--) {
|
||||||
const el = all[i];
|
const el = all[i];
|
||||||
if (!isVisible(el) || el.disabled) continue;
|
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;
|
const container = pwField.closest('form') || pwField.closest('[role="form"]') || pwField.parentElement;
|
||||||
if (container) {
|
if (container) {
|
||||||
const candidate = container.querySelector('input[type="email"]:not([disabled]), input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])');
|
const emailCandidate = container.querySelector('input[type="email"]:not([disabled])');
|
||||||
if (candidate && isVisible(candidate)) return candidate;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +177,7 @@
|
|||||||
var freshItems = _matchingItems;
|
var freshItems = _matchingItems;
|
||||||
if (!freshItems.length) {
|
if (!freshItems.length) {
|
||||||
try {
|
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) || [];
|
var all = (result && result.vault_items_cs) || [];
|
||||||
freshItems = _filterForHost(all);
|
freshItems = _filterForHost(all);
|
||||||
if (freshItems.length) _matchingItems = freshItems;
|
if (freshItems.length) _matchingItems = freshItems;
|
||||||
@@ -599,11 +649,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function decorateFields() {
|
async function decorateFields() {
|
||||||
// Read from chrome.storage.local — reliable across all Chrome versions and
|
// Read from chrome.storage.session — memory-only, cleared on browser close.
|
||||||
// does not depend on message delivery from the service worker.
|
// Decrypted vault data must never be written to persistent (local) storage.
|
||||||
var all = [];
|
var all = [];
|
||||||
try {
|
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) || [];
|
all = (result && result.vault_items_cs) || [];
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
|
|
||||||
@@ -621,14 +671,33 @@
|
|||||||
|
|
||||||
// ── Vault item helpers ────────────────────────────────────────────────────────
|
// ── 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) {
|
function _filterForHost(items) {
|
||||||
var host = location.hostname.replace(/^www\./, '');
|
var host = location.hostname.replace(/^www\./, '');
|
||||||
return (items || []).filter(function (item) {
|
return (items || []).filter(function (item) {
|
||||||
if (item.item_type !== 'password' || !(item.plain && item.plain.url)) return false;
|
if (item.item_type !== 'password' || !(item.plain && item.plain.url)) return false;
|
||||||
try {
|
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);
|
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) {
|
async function classifyCredentials(username, password) {
|
||||||
var all = [];
|
var all = [];
|
||||||
try {
|
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) || [];
|
all = (result && result.vault_items_cs) || [];
|
||||||
} catch (e) { return 'new'; }
|
} catch (e) { return 'new'; }
|
||||||
if (!all.length) return 'new';
|
if (!all.length) return 'new';
|
||||||
@@ -816,7 +885,7 @@
|
|||||||
// React instantly when the popup writes fresh vault data to local storage.
|
// 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.
|
// This fires in the same tick as the write — no message delivery required.
|
||||||
chrome.storage.onChanged.addListener(function (changes, area) {
|
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) || [];
|
var allItems = (changes.vault_items_cs.newValue) || [];
|
||||||
_matchingItems = _filterForHost(allItems);
|
_matchingItems = _filterForHost(allItems);
|
||||||
console.log('[PassKeeper] storage.onChanged: matched=' + _matchingItems.length + ' of ' + allItems.length + ' items');
|
console.log('[PassKeeper] storage.onChanged: matched=' + _matchingItems.length + ' of ' + allItems.length + ' items');
|
||||||
|
|||||||
+56
-18
@@ -87,11 +87,26 @@ function currentHostname() {
|
|||||||
try { return new URL(_currentUrl).hostname.replace(/^www\./, ''); } catch { return ''; }
|
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) {
|
function isMatch(item) {
|
||||||
const host = currentHostname();
|
const host = currentHostname();
|
||||||
if (!host || item.item_type !== 'password' || !item.plain?.url) return false;
|
if (!host || item.item_type !== 'password' || !item.plain?.url) return false;
|
||||||
try {
|
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}`);
|
return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`);
|
||||||
} catch { return false; }
|
} catch { return false; }
|
||||||
}
|
}
|
||||||
@@ -263,7 +278,8 @@ async function signOut() {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
await chrome.storage.session.clear();
|
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 = [];
|
_vaultKey = null; _items = [];
|
||||||
showView('login');
|
showView('login');
|
||||||
}
|
}
|
||||||
@@ -312,23 +328,30 @@ async function fetchAndDecryptVault() {
|
|||||||
_items = await Promise.all(raw.map(async item => {
|
_items = await Promise.all(raw.map(async item => {
|
||||||
try {
|
try {
|
||||||
const plain = await ExtCrypto.decryptItem(_vaultKey, item.enc_data, item.iv);
|
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 }; }
|
} catch { return { ...item, plain: null }; }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Write to session for the popup's own use (badge, rendering).
|
// Write to session for the popup's own use (badge, rendering).
|
||||||
await chrome.storage.session.set({ vault_items: _items });
|
await chrome.storage.session.set({ vault_items: _items });
|
||||||
|
|
||||||
// Write a lightweight copy to local storage — this is what content scripts
|
// Write a lightweight copy to session storage for content scripts.
|
||||||
// read, since chrome.storage.local works reliably across all Chrome versions
|
// session storage is memory-only (cleared on browser close) — decrypted
|
||||||
// and does not require message delivery from the background service worker.
|
// vault data must never be persisted to disk via chrome.storage.local.
|
||||||
const itemsForContentScript = _items.map(item => ({
|
const itemsForContentScript = _items.map(item => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
item_type: item.item_type,
|
item_type: item.item_type,
|
||||||
plain: item.plain,
|
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.
|
// Notify background to refresh badges and forward to content scripts.
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
@@ -658,13 +681,14 @@ async function saveCredential(data) {
|
|||||||
const folderVal = $('save-folder').value;
|
const folderVal = $('save-folder').value;
|
||||||
const folder_id = folderVal ? parseInt(folderVal, 10) : null;
|
const folder_id = folderVal ? parseInt(folderVal, 10) : null;
|
||||||
const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain);
|
const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain);
|
||||||
|
const { enc_name, iv_name } = await ExtCrypto.encryptName(_vaultKey, name);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/vault', {
|
const res = await apiFetch('/api/vault', {
|
||||||
method: 'POST',
|
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) {
|
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();
|
await fetchAndDecryptVault();
|
||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
@@ -680,6 +704,18 @@ const GEN_SETS = {
|
|||||||
symbols: '!@#$%^&*()-_=+[]{}|;:,.<>?',
|
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) {
|
function generatePassword(length, useLower, useUpper, useNumbers, useSymbols) {
|
||||||
// Always fall back to lower if nothing selected, preventing infinite loop.
|
// Always fall back to lower if nothing selected, preventing infinite loop.
|
||||||
const pool = [
|
const pool = [
|
||||||
@@ -690,12 +726,13 @@ function generatePassword(length, useLower, useUpper, useNumbers, useSymbols) {
|
|||||||
].join('');
|
].join('');
|
||||||
if (!pool) return '';
|
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 = [];
|
const required = [];
|
||||||
if (useLower) required.push(GEN_SETS.lower[Math.floor(Math.random() * GEN_SETS.lower.length)]);
|
if (useLower) required.push(GEN_SETS.lower[_cryptoRandInt(GEN_SETS.lower.length)]);
|
||||||
if (useUpper) required.push(GEN_SETS.upper[Math.floor(Math.random() * GEN_SETS.upper.length)]);
|
if (useUpper) required.push(GEN_SETS.upper[_cryptoRandInt(GEN_SETS.upper.length)]);
|
||||||
if (useNumbers) required.push(GEN_SETS.numbers[Math.floor(Math.random() * GEN_SETS.numbers.length)]);
|
if (useNumbers) required.push(GEN_SETS.numbers[_cryptoRandInt(GEN_SETS.numbers.length)]);
|
||||||
if (useSymbols) required.push(GEN_SETS.symbols[Math.floor(Math.random() * GEN_SETS.symbols.length)]);
|
if (useSymbols) required.push(GEN_SETS.symbols[_cryptoRandInt(GEN_SETS.symbols.length)]);
|
||||||
|
|
||||||
const arr = new Uint32Array(length);
|
const arr = new Uint32Array(length);
|
||||||
crypto.getRandomValues(arr);
|
crypto.getRandomValues(arr);
|
||||||
@@ -704,9 +741,9 @@ function generatePassword(length, useLower, useUpper, useNumbers, useSymbols) {
|
|||||||
// Splice required chars into random positions and trim to length.
|
// Splice required chars into random positions and trim to length.
|
||||||
const combined = [...rest];
|
const combined = [...rest];
|
||||||
required.forEach((ch, i) => { combined[i] = ch; });
|
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--) {
|
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]];
|
[combined[i], combined[j]] = [combined[j], combined[i]];
|
||||||
}
|
}
|
||||||
return combined.slice(0, length).join('');
|
return combined.slice(0, length).join('');
|
||||||
@@ -894,16 +931,17 @@ async function addItemToVault() {
|
|||||||
try {
|
try {
|
||||||
const plain = { url, username, password, notes };
|
const plain = { url, username, password, notes };
|
||||||
const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain);
|
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', {
|
const res = await apiFetch('/api/vault', {
|
||||||
method: 'POST',
|
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) {
|
if (!res?.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
showError('add-error', data.error || 'Failed to save. Please try again.');
|
showError('add-error', data.error || 'Failed to save. Please try again.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('[PassKeeper] Item added to vault:', name);
|
console.log('[PassKeeper] Item added to vault:', name, '(name encrypted)');
|
||||||
// Refresh vault and go back.
|
// Refresh vault and go back.
|
||||||
await fetchAndDecryptVault();
|
await fetchAndDecryptVault();
|
||||||
renderList();
|
renderList();
|
||||||
|
|||||||
@@ -95,5 +95,40 @@ const ExtCrypto = (() => {
|
|||||||
return bytesToBase64(crypto.getRandomValues(new Uint8Array(byteLength)));
|
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 };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user