From 7d5423ef3f2c7aa1d05b634af42d1af7d19fc01d Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 1 Jun 2026 09:17:40 -0400 Subject: [PATCH] 05/29 Update extension --- extension/popup/popup.css | 13 +++ extension/popup/popup.html | 1 + extension/popup/popup.js | 105 +++++++++++++++++++++---- extension/shared/sharing-crypto.js | 122 +++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 14 deletions(-) create mode 100644 extension/shared/sharing-crypto.js diff --git a/extension/popup/popup.css b/extension/popup/popup.css index 12691a9..e130dd4 100644 --- a/extension/popup/popup.css +++ b/extension/popup/popup.css @@ -393,6 +393,19 @@ body { vertical-align: middle; } +.badge-shared { + display: inline-block; + background: #f3e8ff; + color: #7c3aed; + font-size: 9px; + font-weight: 700; + padding: 1px 5px; + border-radius: 8px; + letter-spacing: 0.3px; + margin-left: 4px; + vertical-align: middle; +} + /* ── States ──────────────────────────────────────────────────────── */ .pk-error { font-size: 12px; diff --git a/extension/popup/popup.html b/extension/popup/popup.html index e19f460..aca995b 100644 --- a/extension/popup/popup.html +++ b/extension/popup/popup.html @@ -622,6 +622,7 @@ + diff --git a/extension/popup/popup.js b/extension/popup/popup.js index ed9411c..eb15280 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -24,6 +24,7 @@ let _mfaToken = null; let _currentUrl = ""; let _activeTab = "relevant"; const _collapsedFolders = new Set(); // persists collapsed state across re-renders +let _sharingPrivKey = null; // decrypted ECDH private key for the session // ── DOM helpers ─────────────────────────────────────────────────────────────── @@ -509,10 +510,36 @@ async function signOut() { 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; + _sharingPrivKey = null; _items = []; showView("login"); } +// ── Sharing private key ────────────────────────────────────────────────────── + +/** + * Load and decrypt the ECDH sharing private key into _sharingPrivKey. + * No-op if already loaded or if the user hasn't set up sharing keys yet. + * Called once per fetchAndDecryptVault() cycle. + */ +async function _loadSharingPrivKey() { + if (_sharingPrivKey) return; // already loaded this session + if (!_vaultKey) return; + try { + const res = await apiFetch('/api/sharing/keys'); + if (!res?.ok) return; + const data = await res.json(); + if (!data.keys_setup || !data.private_key_enc || !data.private_key_iv) return; + _sharingPrivKey = await ExtSharingCrypto.decryptPrivateKey( + _vaultKey, + data.private_key_enc, + data.private_key_iv, + ); + } catch (err) { + console.warn('[PassKeeper] Could not load sharing private key:', err); + } +} + // ── Vault loading ───────────────────────────────────────────────────────────── /** @@ -596,12 +623,63 @@ async function fetchAndDecryptVault() { }), ); - // Write to session for the popup's own use (badge, rendering). + // Write own items to session for popup use (badge, rendering). + // vault_items_cs and VAULT_UPDATED are sent after shared items are merged below. 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. + // Load ECDH sharing private key (no-op if already loaded or keys not set up). + await _loadSharingPrivKey(); + + // Fetch and decrypt accepted shared items, then merge into _items. + // Shared items are treated like own items for autofill/copy but carry + // _shared: true and _sharedFrom: ownerEmail for UI differentiation. + if (_sharingPrivKey) { + try { + const inboxRes = await apiFetch('/api/sharing/inbox'); + if (inboxRes?.ok) { + const inbox = await inboxRes.json(); + const accepted = inbox.filter((s) => s.accepted && s.owner_public_key); + const sharedItems = (await Promise.all( + accepted.map(async (s) => { + try { + const ownerPub = await ExtSharingCrypto.importPublicKey(s.owner_public_key); + const sharedKey = await ExtSharingCrypto.deriveSharedKey(_sharingPrivKey, ownerPub); + const plain = await ExtSharingCrypto.decryptShare(sharedKey, s.enc_data, s.iv); + let displayName = s.item_name; + if (s.enc_name && s.iv_name) { + const dec = await ExtSharingCrypto.decryptName(sharedKey, s.enc_name, s.iv_name); + if (dec) displayName = dec; + } + return { + // Use a namespaced id so shared items never collide with own items. + id: `shared-${s.id}`, + _shareId: s.id, + _shared: true, + _sharedFrom: s.owner_email || 'Unknown', + item_type: s.item_type, + name: displayName, + folder_id: null, + created_at: s.created_at, + updated_at: s.created_at, + enc_data: s.enc_data, + iv: s.iv, + plain, + }; + } catch { + return null; + } + }), + )).filter(Boolean); + // Append shared items after own items so they don't displace own-item matches. + _items = [..._items, ...sharedItems]; + } + } catch (err) { + console.warn('[PassKeeper] Could not load shared items:', err); + } + } + + // Persist the merged list (own + shared) to session for badge/content script use. + // Shared items use their own namespaced ids so the content script can use them. const itemsForContentScript = _items.map((item) => ({ id: item.id, name: item.name, @@ -609,11 +687,9 @@ async function fetchAndDecryptVault() { 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", + type: 'VAULT_UPDATED', vault_items: itemsForContentScript, }) .catch(() => { }); @@ -640,7 +716,7 @@ async function fetchAndDecryptVault() { async function _runPopupHealthCheck() { try { const pwItems = _items.filter( - (i) => i.item_type === "password" && i.plain?.password, + (i) => !i._shared && i.item_type === "password" && i.plain?.password, ); if (!pwItems.length) { chrome.runtime @@ -883,6 +959,7 @@ function renderList() { const site = escHtml(siteLabel(item)); const name = escHtml(item.name); const badge = matched ? 'match' : ""; + const sharedBadge = item._shared ? `shared` : ""; const color = avatarColor(item.name); const emoji = itemEmoji(item.item_type); const canFill = @@ -902,7 +979,7 @@ function renderList() { return `
${emoji}
-
${site}${badge}${isFav ? '' : ""}
+
${site}${badge}${sharedBadge}${isFav ? '' : ""}
${name}
${tagHtml ? `
${tagHtml}
` : ""} ${hasTotp ? `
······
` : ""} @@ -996,7 +1073,7 @@ function renderList() { 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)); + const item = _items.find((i) => String(i.id) === btn.dataset.copyPass); if (!item?.plain?.password) return; if (item.plain?.reprompt) { const ok = await _repromptMasterPassword(); @@ -1014,7 +1091,7 @@ function renderList() { 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)); + const item = _items.find((i) => String(i.id) === btn.dataset.copyTotp); if (!item?.plain?.totp_uri) return; const code = await getTotpCode(item.plain.totp_uri).catch(() => null); if (code) { @@ -1035,7 +1112,7 @@ function renderList() { listEl.querySelectorAll("[data-autofill]").forEach((btn) => btn.addEventListener("click", async (e) => { e.stopPropagation(); - const item = _items.find((i) => i.id === parseInt(btn.dataset.autofill)); + const item = _items.find((i) => String(i.id) === btn.dataset.autofill); if (!item?.plain) return; if (item.plain?.reprompt) { const ok = await _repromptMasterPassword(); @@ -1063,7 +1140,7 @@ function renderList() { 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)); + const item = _items.find((i) => String(i.id) === btn.dataset.copyUser); if (!item?.plain?.username) return; if (item.plain?.reprompt) { const ok = await _repromptMasterPassword(); @@ -1084,7 +1161,7 @@ function renderList() { // 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)); + const item = _items.find((i) => String(i.id) === btn.dataset.menu); if (!item) return; const flyout = document.createElement("div"); diff --git a/extension/shared/sharing-crypto.js b/extension/shared/sharing-crypto.js new file mode 100644 index 0000000..12e0b58 --- /dev/null +++ b/extension/shared/sharing-crypto.js @@ -0,0 +1,122 @@ +/** + * extension/shared/sharing-crypto.js + * + * ECDH P-256 cryptography for zero-knowledge item sharing — extension port. + * Mirrors app/static/js/sharing.js exactly but uses `crypto.subtle` + * (no `window.`) so it works in both the popup and the service worker. + * + * Only the primitives needed by the extension are included: + * importPublicKey, decryptPrivateKey, deriveSharedKey, decryptShare, decryptName + */ + +const ExtSharingCrypto = (() => { + const subtle = crypto.subtle; + + // ── Helpers ──────────────────────────────────────────────────────────────── + + function base64ToBytes(b64) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + + // ── Key import ───────────────────────────────────────────────────────────── + + /** + * Import a remote user's ECDH public key from base64 raw bytes (65-byte + * uncompressed P-256 point). + */ + async function importPublicKey(base64Raw) { + return subtle.importKey( + 'raw', + base64ToBytes(base64Raw), + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [], // public keys have no usages in WebCrypto ECDH + ); + } + + /** + * Decrypt the user's ECDH private key JWK (fetched from server as + * private_key_enc / private_key_iv) using the vault AES-256-GCM key. + * Returns a CryptoKey usable for ECDH deriveBits. + */ + async function decryptPrivateKey(vaultKey, private_key_enc, private_key_iv) { + const plaintext = await subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBytes(private_key_iv) }, + vaultKey, + base64ToBytes(private_key_enc), + ); + const jwk = JSON.parse(new TextDecoder().decode(plaintext)); + return subtle.importKey( + 'jwk', + jwk, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + ['deriveBits'], + ); + } + + // ── Shared-secret derivation ─────────────────────────────────────────────── + + /** + * Derive an AES-256-GCM CryptoKey from the ECDH shared secret. + * ECDH is commutative: ECDH(A_priv, B_pub) === ECDH(B_priv, A_pub). + */ + async function deriveSharedKey(myPrivateKey, theirPublicKey) { + const bits = await subtle.deriveBits( + { name: 'ECDH', public: theirPublicKey }, + myPrivateKey, + 256, + ); + return subtle.importKey( + 'raw', + bits, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'], + ); + } + + // ── Decrypt ──────────────────────────────────────────────────────────────── + + /** + * Decrypt a shared item payload (JSON object) using the ECDH shared key. + */ + async function decryptShare(sharedKey, enc_data, iv) { + const plaintext = await subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBytes(iv) }, + sharedKey, + base64ToBytes(enc_data), + ); + return JSON.parse(new TextDecoder().decode(plaintext)); + } + + /** + * Decrypt an encrypted item display name. + * Returns null on failure (e.g. legacy share without enc_name). + */ + async function decryptName(sharedKey, enc_name, iv_name) { + try { + const plaintext = await subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBytes(iv_name) }, + sharedKey, + base64ToBytes(enc_name), + ); + return new TextDecoder().decode(plaintext); + } catch { + return null; + } + } + + // ── Public API ───────────────────────────────────────────────────────────── + + return { + importPublicKey, + decryptPrivateKey, + deriveSharedKey, + decryptShare, + decryptName, + }; +})();