1659 lines
56 KiB
JavaScript
1659 lines
56 KiB
JavaScript
/**
|
||
* extension/popup/popup.js — PassKeeper popup UI
|
||
*
|
||
* Storage layout:
|
||
* chrome.storage.session — access_token, vault_key_jwk, vault_items (cleared on browser close)
|
||
* chrome.storage.local — refresh_token, enc_key_salt (persists across restarts)
|
||
*
|
||
* SSO flow (web app → extension):
|
||
* bridge.js syncs tokens to background → background writes to chrome.storage.
|
||
* On popup open: if session has no vault_key but local has enc_key_salt + refresh_token,
|
||
* we refresh the access_token and show the unlock-only view (master password only).
|
||
*/
|
||
|
||
const API_BASE = "https://pwkeeper.ngodanguyen.tech";
|
||
const VAULT_URL = "https://pwkeeper.ngodanguyen.tech/vault";
|
||
const ALERTS_URL = "https://pwkeeper.ngodanguyen.tech/vault#security";
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
|
||
let _vaultKey = null;
|
||
let _items = [];
|
||
let _folders = []; // fetched alongside vault items for folder grouping
|
||
let _mfaToken = null;
|
||
let _currentUrl = "";
|
||
let _activeTab = "relevant";
|
||
const _collapsedFolders = new Set(); // persists collapsed state across re-renders
|
||
|
||
// ── DOM helpers ───────────────────────────────────────────────────────────────
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
function showView(name) {
|
||
["login", "mfa", "unlock", "vault", "generator", "account", "add"].forEach(
|
||
(v) => $(`view-${v}`).classList.toggle("hidden", v !== name),
|
||
);
|
||
// Bottom nav visible only in authenticated views.
|
||
const navViews = ["vault", "generator", "account"];
|
||
$("main-bottom-nav").classList.toggle("hidden", !navViews.includes(name));
|
||
// Sync active state on nav buttons.
|
||
["nav-vault", "nav-generator", "nav-account"].forEach((id) => {
|
||
const btn = $(id);
|
||
if (!btn) return;
|
||
const active =
|
||
(id === "nav-vault" && name === "vault") ||
|
||
(id === "nav-generator" && name === "generator") ||
|
||
(id === "nav-account" && name === "account");
|
||
btn.classList.toggle("active", active);
|
||
});
|
||
}
|
||
|
||
function showError(elId, msg) {
|
||
const el = $(elId);
|
||
el.textContent = msg;
|
||
el.classList.remove("hidden");
|
||
}
|
||
|
||
function hideError(elId) {
|
||
$(elId).classList.add("hidden");
|
||
}
|
||
|
||
function escHtml(str) {
|
||
return String(str ?? "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
// Auto-clear clipboard 30 s after a sensitive copy.
|
||
let _clipTimer = null;
|
||
function _copyWithAutoClear(text) {
|
||
navigator.clipboard.writeText(text).catch(() => {});
|
||
if (_clipTimer) clearTimeout(_clipTimer);
|
||
_clipTimer = setTimeout(() => {
|
||
navigator.clipboard.writeText("").catch(() => {});
|
||
_clipTimer = null;
|
||
}, 30_000);
|
||
}
|
||
|
||
/**
|
||
* Show an in-popup master-password re-prompt overlay.
|
||
* Resolves true if the entered password matches the current vault key,
|
||
* false if the user cancels or enters a wrong password.
|
||
*/
|
||
async function _repromptMasterPassword() {
|
||
return new Promise((resolve) => {
|
||
// Build the overlay element.
|
||
const overlay = document.createElement("div");
|
||
overlay.id = "pk-reprompt-overlay";
|
||
overlay.className = "reprompt-overlay";
|
||
overlay.innerHTML = `
|
||
<div class="reprompt-card">
|
||
<div class="reprompt-header">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||
<rect x="3" y="10" width="18" height="12" rx="2" stroke="#c0392b" stroke-width="1.8"/>
|
||
<path d="M8 10V7a4 4 0 018 0v3" stroke="#c0392b" stroke-width="1.8" stroke-linecap="round"/>
|
||
</svg>
|
||
<span>Master password required</span>
|
||
</div>
|
||
<p class="reprompt-desc">This item is protected. Enter your master password to continue.</p>
|
||
<div class="form-group">
|
||
<label for="reprompt-pw">Master Password</label>
|
||
<input type="password" id="reprompt-pw" placeholder="Master password" autocomplete="current-password"/>
|
||
</div>
|
||
<p id="reprompt-error" class="pk-error hidden">Incorrect password.</p>
|
||
<div class="reprompt-actions">
|
||
<button id="reprompt-confirm" class="btn-primary btn-sm">Confirm</button>
|
||
<button id="reprompt-cancel" class="btn-ghost btn-sm">Cancel</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
document.getElementById("app").appendChild(overlay);
|
||
const input = overlay.querySelector("#reprompt-pw");
|
||
const errEl = overlay.querySelector("#reprompt-error");
|
||
input.focus();
|
||
|
||
async function attempt() {
|
||
errEl.classList.add("hidden");
|
||
const pw = input.value;
|
||
if (!pw) { errEl.textContent = "Enter your master password."; errEl.classList.remove("hidden"); return; }
|
||
try {
|
||
let { enc_key_salt } = await chrome.storage.session.get("enc_key_salt");
|
||
if (!enc_key_salt) {
|
||
const local = await chrome.storage.local.get("enc_key_salt");
|
||
enc_key_salt = local.enc_key_salt;
|
||
}
|
||
if (!enc_key_salt) { errEl.textContent = "Session expired. Please log in again."; errEl.classList.remove("hidden"); return; }
|
||
// Derive a candidate key and compare its JWK to the stored key.
|
||
const candidate = await ExtCrypto.deriveVaultKey(pw, enc_key_salt);
|
||
const candidateJwk = await ExtCrypto.exportVaultKey(candidate);
|
||
const { vault_key_jwk } = await chrome.storage.session.get("vault_key_jwk");
|
||
if (!vault_key_jwk || JSON.stringify(candidateJwk) !== JSON.stringify(vault_key_jwk)) {
|
||
throw new Error("mismatch");
|
||
}
|
||
cleanup(true);
|
||
} catch {
|
||
errEl.textContent = "Incorrect password.";
|
||
errEl.classList.remove("hidden");
|
||
input.value = "";
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
function cleanup(result) {
|
||
overlay.remove();
|
||
resolve(result);
|
||
}
|
||
|
||
overlay.querySelector("#reprompt-confirm").addEventListener("click", attempt);
|
||
overlay.querySelector("#reprompt-cancel").addEventListener("click", () => cleanup(false));
|
||
input.addEventListener("keydown", (e) => { if (e.key === "Enter") attempt(); if (e.key === "Escape") cleanup(false); });
|
||
});
|
||
}
|
||
|
||
// ── Avatar helpers ────────────────────────────────────────────────────────────
|
||
|
||
const AVATAR_COLORS = [
|
||
"",
|
||
"color-red",
|
||
"color-green",
|
||
"color-purple",
|
||
"color-teal",
|
||
];
|
||
|
||
function avatarColor(str) {
|
||
let h = 0;
|
||
for (const c of str) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||
return AVATAR_COLORS[h % AVATAR_COLORS.length];
|
||
}
|
||
|
||
function itemEmoji(type) {
|
||
return (
|
||
{
|
||
password: "🔑",
|
||
note: "📝",
|
||
card: "💳",
|
||
bank: "🏦",
|
||
address: "🏠",
|
||
ssn: "🪪",
|
||
passkey: "🔐",
|
||
}[type] || "🔑"
|
||
);
|
||
}
|
||
|
||
function siteLabel(item) {
|
||
if (item.plain?.url) {
|
||
try {
|
||
return new URL(item.plain.url).hostname.replace(/^www\./, "");
|
||
} catch {}
|
||
}
|
||
return item.name;
|
||
}
|
||
|
||
function folderName(id) {
|
||
if (!id) return "(none)";
|
||
return _folders.find((f) => f.id === id)?.name || "(none)";
|
||
}
|
||
|
||
// ── Domain matching ───────────────────────────────────────────────────────────
|
||
|
||
function currentHostname() {
|
||
if (!_currentUrl) 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) {
|
||
const host = currentHostname();
|
||
if (!host || item.item_type !== "password" || !item.plain?.url) return false;
|
||
try {
|
||
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;
|
||
}
|
||
}
|
||
|
||
// ── API helpers ───────────────────────────────────────────────────────────────
|
||
|
||
// Singleton promise for the in-flight token refresh.
|
||
// When a 401 triggers a refresh, all concurrent requests that also receive a
|
||
// 401 await this same promise instead of starting their own — preventing the
|
||
// second refresh from using an already-rotated (and therefore blacklisted)
|
||
// refresh token, which would cause an unexpected sign-out.
|
||
let _refreshPromise = null;
|
||
|
||
async function apiFetch(path, options = {}) {
|
||
const { access_token } = await chrome.storage.session.get("access_token");
|
||
const headers = {
|
||
"Content-Type": "application/json",
|
||
...(options.headers || {}),
|
||
};
|
||
if (access_token) headers["Authorization"] = `Bearer ${access_token}`;
|
||
|
||
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||
|
||
if (res.status === 401) {
|
||
// Coalesce concurrent 401 retries onto a single refresh attempt.
|
||
if (!_refreshPromise) {
|
||
_refreshPromise = tryRefreshToken().finally(() => {
|
||
_refreshPromise = null;
|
||
});
|
||
}
|
||
const refreshed = await _refreshPromise;
|
||
if (!refreshed) {
|
||
signOut();
|
||
return null;
|
||
}
|
||
const { access_token: tok } =
|
||
await chrome.storage.session.get("access_token");
|
||
headers["Authorization"] = `Bearer ${tok}`;
|
||
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||
}
|
||
return res;
|
||
}
|
||
|
||
async function tryRefreshToken() {
|
||
const { refresh_token } = await chrome.storage.local.get("refresh_token");
|
||
if (!refresh_token) return false;
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/refresh`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ refresh_token }),
|
||
});
|
||
if (!res.ok) return false;
|
||
const data = await res.json();
|
||
await chrome.storage.session.set({ access_token: data.access_token });
|
||
if (data.refresh_token)
|
||
await chrome.storage.local.set({ refresh_token: data.refresh_token });
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||
|
||
async function handleLogin() {
|
||
const email = $("login-email").value.trim().toLowerCase();
|
||
const password = $("login-password").value;
|
||
hideError("login-error");
|
||
if (!email || !password) {
|
||
showError("login-error", "Email and master password are required.");
|
||
return;
|
||
}
|
||
|
||
const btn = $("btn-login");
|
||
btn.disabled = true;
|
||
btn.textContent = "Unlocking…";
|
||
try {
|
||
const authHash = await ExtCrypto.deriveAuthHash(password, email);
|
||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ email, auth_hash: authHash }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
showError("login-error", data.error || "Login failed.");
|
||
return;
|
||
}
|
||
|
||
if (data.mfa_required) {
|
||
_mfaToken = data.mfa_token;
|
||
await chrome.storage.session.set({
|
||
_pending_enc_key_salt: data.enc_key_salt,
|
||
});
|
||
handleMfaStage(password);
|
||
// Reset MFA view to TOTP mode each time it's shown
|
||
$("mfa-totp-section").classList.remove("hidden");
|
||
$("mfa-backup-section").classList.add("hidden");
|
||
$("btn-mfa-use-backup").classList.remove("hidden");
|
||
$("btn-mfa-use-totp").classList.add("hidden");
|
||
$("mfa-hint").textContent = "Enter your 6-digit authenticator code.";
|
||
$("mfa-code").value = "";
|
||
$("mfa-backup-code").value = "";
|
||
showView("mfa");
|
||
$("mfa-code").focus();
|
||
return;
|
||
}
|
||
await completeLogin(data, password);
|
||
} catch (err) {
|
||
showError("login-error", "Network error: " + err.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = "Unlock Vault";
|
||
}
|
||
}
|
||
|
||
function handleMfaStage(password) {
|
||
$("btn-mfa-verify").onclick = async () => {
|
||
hideError("mfa-error");
|
||
const usingBackup = !$("mfa-backup-section").classList.contains("hidden");
|
||
const body = { mfa_token: _mfaToken };
|
||
|
||
if (usingBackup) {
|
||
const code = $("mfa-backup-code")
|
||
.value.trim()
|
||
.toLowerCase()
|
||
.replace(/[\s-]/g, "");
|
||
if (!code) {
|
||
showError("mfa-error", "Enter your backup code.");
|
||
return;
|
||
}
|
||
body.backup_code = code;
|
||
} else {
|
||
const code = $("mfa-code").value.trim();
|
||
if (code.length !== 6) {
|
||
showError("mfa-error", "Enter the 6-digit code.");
|
||
return;
|
||
}
|
||
body.totp_code = code;
|
||
}
|
||
|
||
$("btn-mfa-verify").disabled = true;
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/auth/mfa/verify`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
showError("mfa-error", data.error || "Verification failed.");
|
||
return;
|
||
}
|
||
_mfaToken = null;
|
||
const { _pending_enc_key_salt } = await chrome.storage.session.get(
|
||
"_pending_enc_key_salt",
|
||
);
|
||
await completeLogin(
|
||
{ ...data, enc_key_salt: _pending_enc_key_salt },
|
||
password,
|
||
);
|
||
} catch (err) {
|
||
showError("mfa-error", "Error: " + err.message);
|
||
} finally {
|
||
$("btn-mfa-verify").disabled = false;
|
||
}
|
||
};
|
||
|
||
$("btn-mfa-use-backup").onclick = () => {
|
||
$("mfa-totp-section").classList.add("hidden");
|
||
$("mfa-backup-section").classList.remove("hidden");
|
||
$("btn-mfa-use-backup").classList.add("hidden");
|
||
$("btn-mfa-use-totp").classList.remove("hidden");
|
||
$("mfa-hint").textContent = "Enter one of your saved backup codes.";
|
||
$("mfa-backup-code").focus();
|
||
};
|
||
|
||
$("btn-mfa-use-totp").onclick = () => {
|
||
$("mfa-backup-section").classList.add("hidden");
|
||
$("mfa-totp-section").classList.remove("hidden");
|
||
$("btn-mfa-use-totp").classList.add("hidden");
|
||
$("btn-mfa-use-backup").classList.remove("hidden");
|
||
$("mfa-hint").textContent = "Enter your 6-digit authenticator code.";
|
||
$("mfa-code").focus();
|
||
};
|
||
}
|
||
|
||
async function completeLogin(data, masterPassword) {
|
||
await chrome.storage.session.set({
|
||
access_token: data.access_token,
|
||
enc_key_salt: data.enc_key_salt,
|
||
});
|
||
// enc_key_salt also persisted locally — not sensitive without the master password,
|
||
// needed to show unlock-only view after browser restart.
|
||
await chrome.storage.local.set({
|
||
refresh_token: data.refresh_token,
|
||
enc_key_salt: data.enc_key_salt,
|
||
});
|
||
|
||
_vaultKey = await ExtCrypto.deriveVaultKey(masterPassword, data.enc_key_salt);
|
||
const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey);
|
||
await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk });
|
||
|
||
// Push session to any open web app tabs so they don't need to re-login
|
||
chrome.runtime
|
||
.sendMessage({
|
||
type: "EXT_SESSION_SYNC",
|
||
access_token: data.access_token,
|
||
refresh_token: data.refresh_token,
|
||
enc_key_salt: data.enc_key_salt,
|
||
})
|
||
.catch(() => {});
|
||
|
||
showView("vault");
|
||
await checkPendingSave();
|
||
await fetchAndDecryptVault();
|
||
renderList();
|
||
}
|
||
|
||
// ── Unlock-only (tokens from web app, just need master password) ──────────────
|
||
|
||
async function handleUnlockOnly() {
|
||
const password = $("unlock-password").value;
|
||
hideError("unlock-error");
|
||
if (!password) {
|
||
showError("unlock-error", "Enter your master password.");
|
||
return;
|
||
}
|
||
|
||
const btn = $("btn-unlock");
|
||
btn.disabled = true;
|
||
btn.textContent = "Unlocking…";
|
||
try {
|
||
// enc_key_salt may be in session (fresh) or local (after browser restart)
|
||
let { enc_key_salt } = await chrome.storage.session.get("enc_key_salt");
|
||
if (!enc_key_salt) {
|
||
const local = await chrome.storage.local.get("enc_key_salt");
|
||
enc_key_salt = local.enc_key_salt;
|
||
}
|
||
if (!enc_key_salt) {
|
||
showError("unlock-error", "Session expired. Please log in again.");
|
||
return;
|
||
}
|
||
|
||
_vaultKey = await ExtCrypto.deriveVaultKey(password, enc_key_salt);
|
||
const vaultKeyJwk = await ExtCrypto.exportVaultKey(_vaultKey);
|
||
await chrome.storage.session.set({ vault_key_jwk: vaultKeyJwk });
|
||
|
||
showView("vault");
|
||
await checkPendingSave();
|
||
await fetchAndDecryptVault();
|
||
renderList();
|
||
} catch {
|
||
showError("unlock-error", "Incorrect password or session expired.");
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = "Unlock";
|
||
}
|
||
}
|
||
|
||
async function signOut() {
|
||
try {
|
||
const { access_token } = await chrome.storage.session.get("access_token");
|
||
const { refresh_token } = await chrome.storage.local.get("refresh_token");
|
||
if (access_token) {
|
||
fetch(`${API_BASE}/api/auth/logout`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${access_token}`,
|
||
},
|
||
body: JSON.stringify({ refresh_token }),
|
||
}).catch(() => {});
|
||
}
|
||
} catch {}
|
||
await chrome.storage.session.clear();
|
||
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");
|
||
}
|
||
|
||
// ── Vault loading ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Returns:
|
||
* 'vault' — fully unlocked session restored
|
||
* 'unlock' — tokens exist (from web app SSO) but vault key needs deriving
|
||
* false — no session, show login
|
||
*/
|
||
async function restoreSessionIfAvailable() {
|
||
const data = await chrome.storage.session.get([
|
||
"access_token",
|
||
"vault_key_jwk",
|
||
"vault_items",
|
||
"enc_key_salt",
|
||
]);
|
||
|
||
if (data.access_token) {
|
||
if (data.vault_key_jwk) {
|
||
_vaultKey = await ExtCrypto.importVaultKey(data.vault_key_jwk);
|
||
_items = data.vault_items || [];
|
||
return "vault";
|
||
}
|
||
// Tokens exist (synced from web app) but vault key not yet derived
|
||
if (data.enc_key_salt) return "unlock";
|
||
}
|
||
|
||
// Session cleared (e.g. browser restart) — try refreshing with persisted local tokens
|
||
const local = await chrome.storage.local.get([
|
||
"refresh_token",
|
||
"enc_key_salt",
|
||
]);
|
||
if (local.refresh_token && local.enc_key_salt) {
|
||
const refreshed = await tryRefreshToken();
|
||
if (refreshed) {
|
||
await chrome.storage.session.set({ enc_key_salt: local.enc_key_salt });
|
||
return "unlock";
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async function fetchAndDecryptVault() {
|
||
$("vault-spinner").classList.remove("hidden");
|
||
try {
|
||
const [res, foldersRes] = await Promise.all([
|
||
apiFetch("/api/vault"),
|
||
apiFetch("/api/folders"),
|
||
]);
|
||
if (!res) return;
|
||
const raw = await res.json();
|
||
try {
|
||
if (foldersRes?.ok) _folders = await foldersRes.json();
|
||
} catch (e) {
|
||
_folders = [];
|
||
}
|
||
|
||
_items = await Promise.all(
|
||
raw.map(async (item) => {
|
||
try {
|
||
const plain = await ExtCrypto.decryptItem(
|
||
_vaultKey,
|
||
item.enc_data,
|
||
item.iv,
|
||
);
|
||
// 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 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.session.set({ vault_items_cs: itemsForContentScript });
|
||
|
||
// Notify background to refresh badges and forward to content scripts.
|
||
chrome.runtime
|
||
.sendMessage({
|
||
type: "VAULT_UPDATED",
|
||
vault_items: itemsForContentScript,
|
||
})
|
||
.catch(() => {});
|
||
} catch (err) {
|
||
console.error("fetchAndDecryptVault:", err);
|
||
} finally {
|
||
$("vault-spinner").classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
// ── TOTP engine (RFC 6238) — pure Web Crypto, no library ─────────────────────
|
||
|
||
function _extractTotpSecret(uri) {
|
||
if (!uri) return null;
|
||
uri = uri.trim();
|
||
if (uri.startsWith("otpauth://")) {
|
||
try {
|
||
const secret = new URL(uri).searchParams.get("secret");
|
||
return secret ? secret.toUpperCase().replace(/\s+/g, "") : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
return uri.toUpperCase().replace(/[\s-]/g, "") || null;
|
||
}
|
||
|
||
function _base32ToBytes(b32) {
|
||
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||
b32 = b32.replace(/=+$/, "");
|
||
let bits = 0,
|
||
val = 0;
|
||
const out = [];
|
||
for (const c of b32) {
|
||
const idx = CHARS.indexOf(c);
|
||
if (idx === -1) continue;
|
||
val = (val << 5) | idx;
|
||
bits += 5;
|
||
if (bits >= 8) {
|
||
bits -= 8;
|
||
out.push((val >> bits) & 0xff);
|
||
}
|
||
}
|
||
return new Uint8Array(out);
|
||
}
|
||
|
||
async function getTotpCode(totpUri) {
|
||
const secret = _extractTotpSecret(totpUri);
|
||
if (!secret) return null;
|
||
const keyBytes = _base32ToBytes(secret);
|
||
if (!keyBytes.length) return null;
|
||
|
||
const counter = Math.floor(Date.now() / 1000 / 30);
|
||
const msg = new Uint8Array(8);
|
||
let c = counter;
|
||
for (let i = 7; i >= 0; i--) {
|
||
msg[i] = c & 0xff;
|
||
c = Math.floor(c / 256);
|
||
}
|
||
|
||
const cryptoKey = await crypto.subtle.importKey(
|
||
"raw",
|
||
keyBytes,
|
||
{ name: "HMAC", hash: "SHA-1" },
|
||
false,
|
||
["sign"],
|
||
);
|
||
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, msg));
|
||
const offset = sig[19] & 0x0f;
|
||
const code =
|
||
(((sig[offset] & 0x7f) << 24) |
|
||
(sig[offset + 1] << 16) |
|
||
(sig[offset + 2] << 8) |
|
||
sig[offset + 3]) %
|
||
1_000_000;
|
||
return String(code).padStart(6, "0");
|
||
}
|
||
|
||
function _totpSecondsLeft() {
|
||
return 30 - (Math.floor(Date.now() / 1000) % 30);
|
||
}
|
||
|
||
// Track active TOTP ticker intervals so we can clear them on re-render.
|
||
let _totpIntervals = [];
|
||
|
||
function _clearTotpTickers() {
|
||
_totpIntervals.forEach((id) => clearInterval(id));
|
||
_totpIntervals = [];
|
||
}
|
||
|
||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||
|
||
function getTabItems() {
|
||
const q = $("vault-search").value.trim().toLowerCase();
|
||
|
||
let items = _items;
|
||
|
||
// Text filter
|
||
if (q) {
|
||
items = items.filter(
|
||
(i) =>
|
||
i.name.toLowerCase().includes(q) ||
|
||
(i.plain?.username || "").toLowerCase().includes(q) ||
|
||
(i.plain?.url || "").toLowerCase().includes(q),
|
||
);
|
||
}
|
||
|
||
if (_activeTab === "relevant") {
|
||
// Show ONLY items that match the current domain, sorted by name.
|
||
items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name));
|
||
} else if (_activeTab === "recents") {
|
||
items = [...items]
|
||
.sort(
|
||
(a, b) =>
|
||
new Date(b.updated_at || b.created_at) -
|
||
new Date(a.updated_at || a.created_at),
|
||
)
|
||
.slice(0, 20);
|
||
} else if (_activeTab === "favorites") {
|
||
items = items
|
||
.filter((i) => (i.plain?.tags || []).includes("favorite"))
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
} else {
|
||
items = [...items].sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
function renderList() {
|
||
const listEl = $("vault-list");
|
||
const emptyEl = $("vault-empty");
|
||
const items = getTabItems();
|
||
|
||
// Stop any running TOTP tickers from a previous render.
|
||
_clearTotpTickers();
|
||
|
||
if (!items.length) {
|
||
listEl.innerHTML = "";
|
||
if (_activeTab === "relevant") {
|
||
const host = currentHostname();
|
||
emptyEl.textContent = host
|
||
? `No saved passwords for ${host}.`
|
||
: "No items match this page.";
|
||
} else {
|
||
emptyEl.textContent = "No items found.";
|
||
}
|
||
emptyEl.classList.remove("hidden");
|
||
return;
|
||
}
|
||
emptyEl.classList.add("hidden");
|
||
|
||
// SVG icons for action buttons
|
||
const svgCopy = `<svg viewBox="0 0 24 24" fill="none"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" stroke="currentColor" stroke-width="1.7"/></svg>`;
|
||
const svgUser = `<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="1.7"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`;
|
||
const svgDots = `<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>`;
|
||
const svgFill = `<svg viewBox="0 0 24 24" fill="none"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M17 3l4 4-9 9H8v-4l9-9z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>`;
|
||
const svgTotp = `<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><rect x="5" y="2" width="14" height="20" rx="2" stroke="currentColor" stroke-width="1.7"/><circle cx="12" cy="17" r="1" fill="currentColor"/><path d="M9 7h6M9 11h4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`;
|
||
|
||
function buildItemHtml(item) {
|
||
const matched = isMatch(item);
|
||
const site = escHtml(siteLabel(item));
|
||
const name = escHtml(item.name);
|
||
const badge = matched ? '<span class="badge-match">match</span>' : "";
|
||
const color = avatarColor(item.name);
|
||
const emoji = itemEmoji(item.item_type);
|
||
const canFill =
|
||
item.item_type === "password" &&
|
||
item.plain?.username &&
|
||
item.plain?.password &&
|
||
item.plain?.autofill !== false; // respect Advanced Setting
|
||
const canCopy = item.item_type === "password" && item.plain?.password;
|
||
const hasTotp =
|
||
item.item_type === "password" &&
|
||
!!_extractTotpSecret(item.plain?.totp_uri);
|
||
const tags = (item.plain?.tags || []).filter((t) => t !== "favorite");
|
||
const tagHtml = tags
|
||
.map((t) => `<span class="pk-tag">${escHtml(t)}</span>`)
|
||
.join("");
|
||
const isFav = (item.plain?.tags || []).includes("favorite");
|
||
return `<div class="vault-item" data-id="${item.id}">
|
||
<div class="item-avatar ${color}">${emoji}</div>
|
||
<div class="item-info">
|
||
<div class="item-site">${site}${badge}${isFav ? '<span class="pk-fav">★</span>' : ""}</div>
|
||
<div class="item-name">${name}</div>
|
||
${tagHtml ? `<div class="pk-tag-row">${tagHtml}</div>` : ""}
|
||
${hasTotp ? `<div class="item-totp-row"><span class="totp-code-inline" id="totp-${item.id}">······</span><span class="totp-timer-inline" id="totp-t-${item.id}"></span></div>` : ""}
|
||
</div>
|
||
<div class="item-actions">
|
||
${item.plain?.username ? `<button class="btn-item-action" data-copy-user="${item.id}" title="Copy username">${svgUser}</button>` : ""}
|
||
${canCopy ? `<button class="btn-item-action" data-copy-pass="${item.id}" title="Copy password">${svgCopy}</button>` : ""}
|
||
${hasTotp ? `<button class="btn-item-action totp-btn" data-copy-totp="${item.id}" title="Copy 2FA code">${svgTotp}</button>` : ""}
|
||
${canFill ? `<button class="btn-item-action" data-autofill="${item.id}" title="Autofill">${svgFill}</button>` : ""}
|
||
<button class="btn-item-action" data-menu="${item.id}" title="More options">${svgDots}</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Build the vault list — grouped by folder for 'all' tab, flat otherwise.
|
||
if (_activeTab === "all" && _folders.length > 0) {
|
||
const groups = {};
|
||
items.forEach((item) => {
|
||
const key = folderName(item.folder_id);
|
||
if (!groups[key]) groups[key] = [];
|
||
groups[key].push(item);
|
||
});
|
||
const keys = ["(none)", ..._folders.map((f) => f.name)].filter(
|
||
(k) => groups[k],
|
||
);
|
||
Object.keys(groups).forEach((k) => {
|
||
if (!keys.includes(k)) keys.push(k);
|
||
});
|
||
|
||
listEl.innerHTML = keys
|
||
.map((groupName) => {
|
||
if (!groups[groupName]) return "";
|
||
const isCollapsed = _collapsedFolders.has(groupName);
|
||
const count = groups[groupName].length;
|
||
const chevron = isCollapsed ? "›" : "⌄";
|
||
const itemsHtml = isCollapsed
|
||
? ""
|
||
: groups[groupName].map(buildItemHtml).join("");
|
||
return `<div class="pk-group" data-group="${escHtml(groupName)}">
|
||
<div class="pk-group-header">
|
||
<span class="pk-group-name">${escHtml(groupName)}</span>
|
||
<span class="pk-group-meta">
|
||
<span class="pk-group-count">${count}</span>
|
||
<span class="pk-group-chevron">${chevron}</span>
|
||
</span>
|
||
</div>
|
||
<div class="pk-group-items">${itemsHtml}</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
|
||
// Wire collapse toggle on group headers.
|
||
listEl.querySelectorAll(".pk-group-header").forEach((hdr) => {
|
||
hdr.addEventListener("click", () => {
|
||
const groupName = hdr.closest(".pk-group").dataset.group;
|
||
if (_collapsedFolders.has(groupName))
|
||
_collapsedFolders.delete(groupName);
|
||
else _collapsedFolders.add(groupName);
|
||
renderList();
|
||
});
|
||
});
|
||
} else {
|
||
listEl.innerHTML = items.map(buildItemHtml).join("");
|
||
}
|
||
|
||
// Start TOTP tickers for items that have a totp_uri.
|
||
items.forEach((item) => {
|
||
if (
|
||
item.item_type !== "password" ||
|
||
!_extractTotpSecret(item.plain?.totp_uri)
|
||
)
|
||
return;
|
||
const codeEl = $(`totp-${item.id}`);
|
||
const timerEl = $(`totp-t-${item.id}`);
|
||
if (!codeEl) return;
|
||
|
||
async function tick() {
|
||
const code = await getTotpCode(item.plain.totp_uri).catch(() => null);
|
||
if (!code || !codeEl.isConnected) return;
|
||
codeEl.textContent = code.slice(0, 3) + " " + code.slice(3);
|
||
const secs = _totpSecondsLeft();
|
||
timerEl.textContent = " " + secs + "s";
|
||
timerEl.style.color = secs <= 5 ? "#dc2626" : "#9ca3af";
|
||
}
|
||
tick();
|
||
const id = setInterval(tick, 1000);
|
||
_totpIntervals.push(id);
|
||
});
|
||
|
||
// Copy password
|
||
listEl.querySelectorAll("[data-copy-pass]").forEach((btn) =>
|
||
btn.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
const item = _items.find((i) => i.id === parseInt(btn.dataset.copyPass));
|
||
if (!item?.plain?.password) return;
|
||
if (item.plain?.reprompt) {
|
||
const ok = await _repromptMasterPassword();
|
||
if (!ok) return;
|
||
}
|
||
_copyWithAutoClear(item.plain.password);
|
||
btn.title = "Copied!";
|
||
setTimeout(() => {
|
||
btn.title = "Copy password";
|
||
}, 1500);
|
||
}),
|
||
);
|
||
|
||
// Copy TOTP code
|
||
listEl.querySelectorAll("[data-copy-totp]").forEach((btn) =>
|
||
btn.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
const item = _items.find((i) => i.id === parseInt(btn.dataset.copyTotp));
|
||
if (!item?.plain?.totp_uri) return;
|
||
const code = await getTotpCode(item.plain.totp_uri).catch(() => null);
|
||
if (code) {
|
||
// Use _copyWithAutoClear so the 2FA code is wiped from the clipboard
|
||
// after 30 s, consistent with password copy behaviour.
|
||
_copyWithAutoClear(code);
|
||
btn.title = "Copied!";
|
||
btn.style.color = "#16a34a";
|
||
setTimeout(() => {
|
||
btn.title = "Copy 2FA code";
|
||
btn.style.color = "";
|
||
}, 1500);
|
||
}
|
||
}),
|
||
);
|
||
|
||
// Autofill
|
||
listEl.querySelectorAll("[data-autofill]").forEach((btn) =>
|
||
btn.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
const item = _items.find((i) => i.id === parseInt(btn.dataset.autofill));
|
||
if (!item?.plain) return;
|
||
if (item.plain?.reprompt) {
|
||
const ok = await _repromptMasterPassword();
|
||
if (!ok) return;
|
||
}
|
||
const [tab] = await chrome.tabs.query({
|
||
active: true,
|
||
currentWindow: true,
|
||
});
|
||
if (tab?.id) {
|
||
chrome.tabs
|
||
.sendMessage(tab.id, {
|
||
type: "DO_AUTOFILL",
|
||
username: item.plain.username || "",
|
||
password: item.plain.password || "",
|
||
autologin: !!item.plain.autologin,
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
window.close();
|
||
}),
|
||
);
|
||
|
||
// Copy username (dedicated button)
|
||
listEl.querySelectorAll("[data-copy-user]").forEach((btn) =>
|
||
btn.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
const item = _items.find((i) => i.id === parseInt(btn.dataset.copyUser));
|
||
if (!item?.plain?.username) return;
|
||
if (item.plain?.reprompt) {
|
||
const ok = await _repromptMasterPassword();
|
||
if (!ok) return;
|
||
}
|
||
_copyWithAutoClear(item.plain.username);
|
||
btn.title = "Copied!";
|
||
setTimeout(() => {
|
||
btn.title = "Copy username";
|
||
}, 1500);
|
||
}),
|
||
);
|
||
|
||
// Three-dot menu: proper flyout with contextual actions
|
||
listEl.querySelectorAll("[data-menu]").forEach((btn) =>
|
||
btn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
// Close any already-open flyout first.
|
||
document.querySelectorAll(".pk-flyout").forEach((el) => el.remove());
|
||
|
||
const item = _items.find((i) => i.id === parseInt(btn.dataset.menu));
|
||
if (!item) return;
|
||
|
||
const flyout = document.createElement("div");
|
||
flyout.className = "pk-flyout";
|
||
|
||
const menuItems = [
|
||
item.plain?.url
|
||
? {
|
||
label: "Open URL",
|
||
icon: '<svg viewBox="0 0 24 24" fill="none" width="14" height="14"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M15 3h6v6M10 14L21 3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||
action: async () => {
|
||
chrome.tabs.create({ url: item.plain.url });
|
||
flyout.remove();
|
||
},
|
||
}
|
||
: null,
|
||
item.plain?.username
|
||
? {
|
||
label: "Copy username",
|
||
icon: '<svg viewBox="0 0 24 24" fill="none" width="14" height="14"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="1.7"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
|
||
action: async () => {
|
||
if (item.plain?.reprompt) {
|
||
const ok = await _repromptMasterPassword();
|
||
if (!ok) return;
|
||
}
|
||
_copyWithAutoClear(item.plain.username);
|
||
flyout.remove();
|
||
},
|
||
}
|
||
: null,
|
||
item.plain?.password
|
||
? {
|
||
label: "Copy password",
|
||
icon: '<svg viewBox="0 0 24 24" fill="none" width="14" height="14"><rect x="3" y="10" width="18" height="11" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 10V7a4 4 0 018 0v3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
|
||
action: async () => {
|
||
if (item.plain?.reprompt) {
|
||
const ok = await _repromptMasterPassword();
|
||
if (!ok) return;
|
||
}
|
||
_copyWithAutoClear(item.plain.password);
|
||
flyout.remove();
|
||
},
|
||
}
|
||
: null,
|
||
].filter(Boolean);
|
||
|
||
menuItems.forEach((mi) => {
|
||
const row = document.createElement("button");
|
||
row.className = "pk-flyout-item";
|
||
row.innerHTML = mi.icon + "<span>" + escHtml(mi.label) + "</span>";
|
||
row.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
await mi.action();
|
||
});
|
||
flyout.appendChild(row);
|
||
});
|
||
|
||
// Position flyout using fixed coords so it escapes vault-list overflow clipping.
|
||
const appEl = document.getElementById("app");
|
||
appEl.appendChild(flyout);
|
||
|
||
const btnRect = btn.getBoundingClientRect();
|
||
const flyoutH = flyout.offsetHeight || 120; // estimate if not yet painted
|
||
const spaceBelow = window.innerHeight - btnRect.bottom;
|
||
const flyoutW = flyout.offsetWidth || 160;
|
||
|
||
// Flip upward if not enough room below.
|
||
if (spaceBelow < flyoutH + 8) {
|
||
flyout.style.top = (btnRect.top - flyoutH - 4) + "px";
|
||
} else {
|
||
flyout.style.top = (btnRect.bottom + 4) + "px";
|
||
}
|
||
// Right-align with the button, but keep inside the popup.
|
||
const rightEdge = window.innerWidth - btnRect.right;
|
||
flyout.style.right = rightEdge + "px";
|
||
|
||
// Close on any outside click.
|
||
setTimeout(() => {
|
||
document.addEventListener("click", function handler() {
|
||
flyout.remove();
|
||
document.removeEventListener("click", handler);
|
||
});
|
||
}, 0);
|
||
}),
|
||
);
|
||
}
|
||
|
||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||
|
||
function initTabs() {
|
||
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
_activeTab = btn.dataset.tab;
|
||
document
|
||
.querySelectorAll(".tab-btn")
|
||
.forEach((b) => b.classList.toggle("active", b === btn));
|
||
renderList();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Save-prompt modal ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Populate the folder <select> inside the save modal.
|
||
* Fetches /api/folders and rebuilds the option list.
|
||
* The first option ("— No folder —") with value "" is always kept.
|
||
*/
|
||
async function loadFoldersIntoSaveModal() {
|
||
const select = $("save-folder");
|
||
// Reset to just the placeholder option.
|
||
select.innerHTML = '<option value="">— No folder —</option>';
|
||
try {
|
||
const res = await apiFetch("/api/folders");
|
||
if (!res?.ok) return;
|
||
const folders = await res.json();
|
||
folders.forEach((f) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = f.id;
|
||
opt.textContent = f.name;
|
||
select.appendChild(opt);
|
||
});
|
||
} catch (err) {
|
||
console.error("[PassKeeper] loadFoldersIntoSaveModal failed:", err);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show the save-prompt overlay. It is a blocking modal — no backdrop click,
|
||
* no ✕ button — so the user MUST click "Save" or "Not now".
|
||
* Called every time the popup opens while a pending_save is stored.
|
||
*
|
||
* pending_save is stored in chrome.storage.local (not session) so it survives
|
||
* service worker restarts and is reliably present when the popup re-opens.
|
||
*/
|
||
async function checkPendingSave() {
|
||
const { pending_save } = await chrome.storage.local.get("pending_save");
|
||
if (!pending_save) return;
|
||
|
||
// Populate fields.
|
||
$("save-name").value = pending_save.siteName || "";
|
||
$("save-username").value = pending_save.username || "";
|
||
$("save-prompt-subtitle").textContent =
|
||
`${pending_save.username || "(no username)"} on ${pending_save.siteName || pending_save.url || "unknown site"}`;
|
||
$("save-prompt-title").textContent = pending_save._isUpdate
|
||
? "Update in PassKeeper?"
|
||
: "Save to PassKeeper?";
|
||
|
||
// Load folders (non-blocking — modal shows immediately, folders populate async).
|
||
$("save-folder").value = "";
|
||
loadFoldersIntoSaveModal();
|
||
|
||
// Show the overlay.
|
||
$("save-prompt-overlay").classList.remove("hidden");
|
||
$("save-name").focus();
|
||
|
||
// Wire buttons — use onclick so re-running checkPendingSave never double-binds.
|
||
// Helper to dismiss the overlay, clear storage, and remove the toolbar badge.
|
||
function _clearPendingSave() {
|
||
$("save-prompt-overlay").classList.add("hidden");
|
||
chrome.storage.local.remove("pending_save");
|
||
chrome.runtime.sendMessage({ type: "CLEAR_SAVE_BADGE" }).catch(() => {});
|
||
}
|
||
|
||
$("btn-save-yes").onclick = async () => {
|
||
console.log(
|
||
"[PassKeeper] User saved credential for",
|
||
pending_save.siteName,
|
||
);
|
||
await saveCredential(pending_save);
|
||
_clearPendingSave();
|
||
};
|
||
$("btn-save-no").onclick = () => {
|
||
console.log(
|
||
"[PassKeeper] User dismissed save prompt for",
|
||
pending_save.siteName,
|
||
);
|
||
_clearPendingSave();
|
||
};
|
||
}
|
||
|
||
async function saveCredential(data) {
|
||
if (!_vaultKey) return;
|
||
const plain = {
|
||
url: data.url || "",
|
||
username: data.username || "",
|
||
password: data.password || "",
|
||
notes: "",
|
||
};
|
||
const name = $("save-name").value.trim() || data.siteName || "Untitled";
|
||
// Read selected folder — empty string means no folder (null).
|
||
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, // actual site name (plaintext fallback for legacy clients)
|
||
item_type: "password",
|
||
folder_id,
|
||
enc_data,
|
||
iv,
|
||
enc_name,
|
||
iv_name,
|
||
}),
|
||
});
|
||
if (res?.ok) {
|
||
console.log(
|
||
"[PassKeeper] Credential saved to vault:",
|
||
name,
|
||
"(name encrypted), folder_id:",
|
||
folder_id,
|
||
);
|
||
await fetchAndDecryptVault();
|
||
renderList();
|
||
}
|
||
} catch (err) {
|
||
console.error("saveCredential failed:", err);
|
||
}
|
||
}
|
||
|
||
// ── Password Generator ────────────────────────────────────────────────────────
|
||
|
||
const GEN_SETS = {
|
||
lower: "abcdefghijklmnopqrstuvwxyz",
|
||
upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||
numbers: "0123456789",
|
||
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 = [
|
||
useLower ? GEN_SETS.lower : "",
|
||
useUpper ? GEN_SETS.upper : "",
|
||
useNumbers ? GEN_SETS.numbers : "",
|
||
useSymbols ? GEN_SETS.symbols : "",
|
||
].join("");
|
||
if (!pool) return "";
|
||
|
||
// Guarantee at least one character from each selected charset
|
||
// using cryptographically secure random selection.
|
||
const required = [];
|
||
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)]);
|
||
|
||
// Use _cryptoRandInt() for each character to avoid modulo bias that arises
|
||
// when pool.length is not a power of 2.
|
||
const rest = Array.from({ length }, () => pool[_cryptoRandInt(pool.length)]);
|
||
|
||
// Splice required chars into random positions and trim to length.
|
||
const combined = [...rest];
|
||
required.forEach((ch, i) => {
|
||
combined[i] = ch;
|
||
});
|
||
// Fisher-Yates shuffle — fully CSPRNG, no Math.random().
|
||
for (let i = combined.length - 1; i > 0; i--) {
|
||
const j = _cryptoRandInt(i + 1);
|
||
[combined[i], combined[j]] = [combined[j], combined[i]];
|
||
}
|
||
return combined.slice(0, length).join("");
|
||
}
|
||
|
||
function passwordStrength(pw) {
|
||
if (!pw) return { label: "", color: "" };
|
||
let score = 0;
|
||
if (pw.length >= 8) score++;
|
||
if (pw.length >= 12) score++;
|
||
if (pw.length >= 16) score++;
|
||
if (/[a-z]/.test(pw)) score++;
|
||
if (/[A-Z]/.test(pw)) score++;
|
||
if (/[0-9]/.test(pw)) score++;
|
||
if (/[^a-zA-Z0-9]/.test(pw)) score++;
|
||
if (score <= 2) return { label: "Weak", color: "#dc2626" };
|
||
if (score <= 4) return { label: "Fair", color: "#d97706" };
|
||
if (score <= 5) return { label: "Good", color: "#2563eb" };
|
||
if (score <= 6) return { label: "Strong", color: "#16a34a" };
|
||
return { label: "Very Strong", color: "#15803d" };
|
||
}
|
||
|
||
let _genInitialised = false;
|
||
|
||
function initGenerator() {
|
||
// Only bind events once — subsequent calls just re-generate a password.
|
||
if (!_genInitialised) {
|
||
_genInitialised = true;
|
||
|
||
const slider = $("gen-length");
|
||
const numInput = $("gen-length-num");
|
||
|
||
// Keep slider and number input in sync.
|
||
slider.addEventListener("input", () => {
|
||
numInput.value = slider.value;
|
||
refreshGen();
|
||
});
|
||
numInput.addEventListener("input", () => {
|
||
const v = Math.min(64, Math.max(8, parseInt(numInput.value) || 16));
|
||
slider.value = v;
|
||
numInput.value = v;
|
||
refreshGen();
|
||
});
|
||
|
||
["gen-lower", "gen-upper", "gen-numbers", "gen-symbols"].forEach((id) => {
|
||
$(id).addEventListener("change", refreshGen);
|
||
});
|
||
|
||
$("btn-gen-refresh").addEventListener("click", refreshGen);
|
||
|
||
$("btn-gen-copy").addEventListener("click", () => {
|
||
const pw = $("gen-output").textContent;
|
||
if (!pw || pw === "Click refresh to generate") return;
|
||
navigator.clipboard.writeText(pw).then(() => {
|
||
$("btn-gen-copy").title = "Copied!";
|
||
setTimeout(() => {
|
||
$("btn-gen-copy").title = "Copy password";
|
||
}, 1500);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Always generate a fresh password when the view opens.
|
||
refreshGen();
|
||
}
|
||
|
||
function refreshGen() {
|
||
const length = Math.min(
|
||
64,
|
||
Math.max(8, parseInt($("gen-length").value) || 16),
|
||
);
|
||
const useLower = $("gen-lower").checked;
|
||
const useUpper = $("gen-upper").checked;
|
||
const useNumbers = $("gen-numbers").checked;
|
||
const useSymbols = $("gen-symbols").checked;
|
||
|
||
// Require at least one charset.
|
||
if (!useLower && !useUpper && !useNumbers && !useSymbols) {
|
||
$("gen-output").textContent = "Select at least one character type";
|
||
$("gen-strength-label").textContent = "";
|
||
return;
|
||
}
|
||
|
||
const pw = generatePassword(
|
||
length,
|
||
useLower,
|
||
useUpper,
|
||
useNumbers,
|
||
useSymbols,
|
||
);
|
||
$("gen-output").textContent = pw;
|
||
|
||
const { label, color } = passwordStrength(pw);
|
||
const strengthEl = $("gen-strength-label");
|
||
strengthEl.textContent = label;
|
||
strengthEl.style.color = color;
|
||
}
|
||
|
||
// ── Add Item view ─────────────────────────────────────────────────────────────
|
||
|
||
let _addViewInitialised = false;
|
||
|
||
async function initAddView() {
|
||
// Pre-fill URL from the current active tab.
|
||
try {
|
||
const [tab] = await chrome.tabs.query({
|
||
active: true,
|
||
currentWindow: true,
|
||
});
|
||
if (tab?.url?.startsWith("http")) {
|
||
$("add-url").value = tab.url;
|
||
// Derive a human-friendly site name from the tab title or hostname.
|
||
if (!$("add-name").value) {
|
||
$("add-name").value = tab.title
|
||
? tab.title.replace(/[-|–].*$/, "").trim() // strip " - Login" suffixes
|
||
: new URL(tab.url).hostname.replace(/^www\./, "");
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
|
||
// Load folders into the add-folder select.
|
||
const addFolderSel = $("add-folder");
|
||
addFolderSel.innerHTML = '<option value="">— No folder —</option>';
|
||
try {
|
||
const res = await apiFetch("/api/folders");
|
||
if (res?.ok) {
|
||
const folders = await res.json();
|
||
folders.forEach((f) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = f.id;
|
||
opt.textContent = f.name;
|
||
addFolderSel.appendChild(opt);
|
||
});
|
||
}
|
||
} catch (e) {}
|
||
|
||
// Wire one-time event listeners.
|
||
if (!_addViewInitialised) {
|
||
_addViewInitialised = true;
|
||
|
||
// Back button.
|
||
$("btn-add-back").addEventListener("click", () => {
|
||
showView("vault");
|
||
hideError("add-error");
|
||
});
|
||
|
||
// Show/hide password toggle.
|
||
$("btn-add-toggle-pw").addEventListener("click", () => {
|
||
const pw = $("add-password");
|
||
pw.type = pw.type === "password" ? "text" : "password";
|
||
});
|
||
|
||
// Generate password — generates via the generator logic and pastes into field.
|
||
$("btn-add-generate").addEventListener("click", () => {
|
||
const pw = generatePassword(16, true, true, true, true);
|
||
$("add-password").value = pw;
|
||
$("add-password").type = "text"; // show it so the user can see what was generated
|
||
});
|
||
|
||
// Save button.
|
||
$("btn-add-save").addEventListener("click", addItemToVault);
|
||
|
||
// Allow Enter in any text field to submit.
|
||
["add-name", "add-url", "add-username", "add-password"].forEach((id) => {
|
||
$(id).addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") addItemToVault();
|
||
});
|
||
});
|
||
}
|
||
|
||
// Clear fields and error each time the view opens.
|
||
["add-name", "add-url", "add-username", "add-password", "add-notes"].forEach(
|
||
(id) => {
|
||
// Don't reset url/name — already pre-filled above.
|
||
if (id === "add-url" || id === "add-name") return;
|
||
$(id).value = "";
|
||
},
|
||
);
|
||
$("add-password").type = "password";
|
||
hideError("add-error");
|
||
$("add-name").focus();
|
||
}
|
||
|
||
async function addItemToVault() {
|
||
const name = $("add-name").value.trim();
|
||
const url = $("add-url").value.trim();
|
||
const username = $("add-username").value.trim();
|
||
const password = $("add-password").value;
|
||
const notes = $("add-notes").value.trim();
|
||
const folderVal = $("add-folder").value;
|
||
const folder_id = folderVal ? parseInt(folderVal, 10) : null;
|
||
|
||
hideError("add-error");
|
||
|
||
if (!name) {
|
||
showError("add-error", "Site name is required.");
|
||
return;
|
||
}
|
||
if (!password) {
|
||
showError("add-error", "Password is required.");
|
||
return;
|
||
}
|
||
if (!_vaultKey) {
|
||
showError("add-error", "Vault is locked. Please unlock first.");
|
||
return;
|
||
}
|
||
|
||
const btn = $("btn-add-save");
|
||
btn.disabled = true;
|
||
btn.textContent = "Saving…";
|
||
|
||
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, // actual site name (plaintext fallback for legacy clients)
|
||
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, "(name encrypted)");
|
||
// Refresh vault and go back.
|
||
await fetchAndDecryptVault();
|
||
renderList();
|
||
showView("vault");
|
||
} catch (err) {
|
||
showError("add-error", "Network error: " + err.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = "Save to Vault";
|
||
}
|
||
}
|
||
|
||
// ── Account view ──────────────────────────────────────────────────────────────
|
||
|
||
const IDLE_TIMEOUT_KEY = "idle_lock_seconds";
|
||
|
||
async function initAccountView() {
|
||
// Load saved timeout value and reflect it in the select.
|
||
const stored = await chrome.storage.local.get(IDLE_TIMEOUT_KEY);
|
||
const saved = stored[IDLE_TIMEOUT_KEY];
|
||
if (saved !== undefined) {
|
||
const sel = $("acct-idle-timeout");
|
||
// Find the matching option; fall back to 600 if not found.
|
||
const opt = Array.from(sel.options).find(
|
||
(o) => parseInt(o.value) === saved,
|
||
);
|
||
if (opt) sel.value = String(saved);
|
||
}
|
||
|
||
// Save on change and notify the background to apply immediately.
|
||
$("acct-idle-timeout").addEventListener("change", async () => {
|
||
const val = parseInt($("acct-idle-timeout").value);
|
||
await chrome.storage.local.set({ [IDLE_TIMEOUT_KEY]: val });
|
||
// Tell background to re-apply the new interval.
|
||
chrome.runtime
|
||
.sendMessage({ type: "SET_IDLE_TIMEOUT", seconds: val })
|
||
.catch(() => {});
|
||
// Brief "Saved" confirmation.
|
||
const saved = $("acct-idle-saved");
|
||
saved.classList.remove("hidden");
|
||
setTimeout(() => saved.classList.add("hidden"), 2000);
|
||
console.log("[PassKeeper] Idle lock timeout set to", val, "seconds");
|
||
});
|
||
|
||
$("acct-open-vault").href = VAULT_URL;
|
||
$("acct-signout").addEventListener("click", signOut);
|
||
}
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
|
||
async function init() {
|
||
try {
|
||
const [tab] = await chrome.tabs.query({
|
||
active: true,
|
||
currentWindow: true,
|
||
});
|
||
_currentUrl = tab?.url || "";
|
||
} catch {}
|
||
|
||
// Set vault link
|
||
$("btn-open-vault").href = VAULT_URL;
|
||
|
||
const sessionState = await restoreSessionIfAvailable();
|
||
|
||
if (sessionState === "vault") {
|
||
// Check whether the content script requested a specific view (e.g. generator).
|
||
const { popup_nav } = await chrome.storage.session.get("popup_nav");
|
||
if (popup_nav) {
|
||
await chrome.storage.session.remove("popup_nav");
|
||
if (popup_nav === "generator") {
|
||
showView("generator");
|
||
initGenerator();
|
||
// Still load vault data in background so the Vault tab is ready.
|
||
fetchAndDecryptVault().then(() => {});
|
||
// Wire event listeners below, then return early from vault-specific setup.
|
||
} else {
|
||
showView("vault");
|
||
await checkPendingSave();
|
||
if (_items.length) {
|
||
renderList();
|
||
fetchAndDecryptVault().then(() => renderList());
|
||
} else {
|
||
await fetchAndDecryptVault();
|
||
renderList();
|
||
}
|
||
}
|
||
} else {
|
||
showView("vault");
|
||
await checkPendingSave();
|
||
if (_items.length) {
|
||
renderList();
|
||
fetchAndDecryptVault().then(() => renderList());
|
||
} else {
|
||
await fetchAndDecryptVault();
|
||
renderList();
|
||
}
|
||
}
|
||
} else if (sessionState === "unlock") {
|
||
showView("unlock");
|
||
$("unlock-password").focus();
|
||
} else {
|
||
showView("login");
|
||
}
|
||
|
||
// Login
|
||
$("btn-login").addEventListener("click", handleLogin);
|
||
$("login-password").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") handleLogin();
|
||
});
|
||
$("login-email").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") $("login-password").focus();
|
||
});
|
||
|
||
// MFA
|
||
$("btn-mfa-back").addEventListener("click", () => {
|
||
_mfaToken = null;
|
||
showView("login");
|
||
});
|
||
$("mfa-code").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") $("btn-mfa-verify")?.click();
|
||
});
|
||
|
||
// Unlock-only
|
||
$("btn-unlock").addEventListener("click", handleUnlockOnly);
|
||
$("unlock-password").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") handleUnlockOnly();
|
||
});
|
||
$("btn-unlock-signout").addEventListener("click", signOut);
|
||
|
||
// Vault actions
|
||
$("btn-add-item").addEventListener("click", () => {
|
||
showView("add");
|
||
initAddView();
|
||
});
|
||
$("nav-vault").addEventListener("click", () => {
|
||
showView("vault");
|
||
});
|
||
$("nav-generator").addEventListener("click", () => {
|
||
showView("generator");
|
||
initGenerator();
|
||
});
|
||
$("nav-alerts").addEventListener("click", () => {
|
||
chrome.tabs.create({ url: ALERTS_URL });
|
||
});
|
||
$("nav-account").addEventListener("click", () => {
|
||
showView("account");
|
||
initAccountView();
|
||
});
|
||
|
||
// Keep the service worker alive so chrome.storage.session survives while the
|
||
// browser is open. We ping it every 20 s; the ping itself is a no-op but
|
||
// prevents the SW from being killed between popup openings.
|
||
const _keepalive = setInterval(() => {
|
||
chrome.runtime.sendMessage({ type: "KEEPALIVE" }).catch(() => {});
|
||
}, 20_000);
|
||
|
||
// Search
|
||
$("vault-search").addEventListener("input", () => renderList());
|
||
|
||
// Tabs
|
||
initTabs();
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", init);
|