05/29 Update extension

This commit is contained in:
2026-06-01 09:17:40 -04:00
parent 9f1d1bdc61
commit 7d5423ef3f
4 changed files with 227 additions and 14 deletions
+13
View File
@@ -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;
+1
View File
@@ -622,6 +622,7 @@
</div>
<!-- /app -->
<script src="../shared/crypto.js"></script>
<script src="../shared/sharing-crypto.js"></script>
<script src="popup.js"></script>
</body>
</html>
+91 -14
View File
@@ -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 ? '<span class="badge-match">match</span>' : "";
const sharedBadge = item._shared ? `<span class="badge-shared" title="Shared by ${escHtml(item._sharedFrom)}">shared</span>` : "";
const color = avatarColor(item.name);
const emoji = itemEmoji(item.item_type);
const canFill =
@@ -902,7 +979,7 @@ function renderList() {
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-site">${site}${badge}${sharedBadge}${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>` : ""}
@@ -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");
+122
View File
@@ -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,
};
})();