Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0295fac3fa | ||
|
|
7d5423ef3f | ||
|
|
9f1d1bdc61 | ||
|
|
977805f5e7 | ||
|
|
5f8ce4b5de | ||
|
|
b614b63ead | ||
|
|
6ea40b8084 | ||
|
|
450b236e26 | ||
|
|
cd2992a193 | ||
|
|
0cc8cfcadf |
@@ -444,7 +444,17 @@ The sharing key uses raw `SubtleCrypto` calls (not `SharingCrypto.decryptPrivate
|
|||||||
|
|
||||||
### Shared item name encryption
|
### Shared item name encryption
|
||||||
|
|
||||||
When creating a share, the client encrypts `item.name` with `SharingCrypto.encryptName(sharedKey, name)` → `enc_name`/`iv_name`. The server receives `item_name = item.item_type` (non-sensitive type label) for the `NOT NULL` column. On the recipient's inbox, `enc_name`/`iv_name` are stored in data attributes on the View button and decrypted client-side with `SharingCrypto.decryptName()` when the user clicks View. Legacy shares (pre-migration, no `enc_name`) fall back to showing `item_name` (the type string).
|
When creating a share, the client encrypts `item.name` with `SharingCrypto.encryptName(sharedKey, name)` → `enc_name`/`iv_name`. The server receives `item_name = item.item_type` (non-sensitive type label) for the `NOT NULL` column. On the recipient's inbox, `enc_name`/`iv_name` are decrypted client-side with `SharingCrypto.decryptName()` when the user clicks View. Legacy shares (pre-migration, no `enc_name`) fall back to showing `item_name` (the type string).
|
||||||
|
|
||||||
|
### Live sync of owner edits to accepted shares
|
||||||
|
|
||||||
|
When the owner edits a vault item, `buildSharedUpdates(itemId, plainData, name)` re-encrypts the item for every accepted share using each recipient's ECDH public key, then POSTs all re-encrypted blobs in `shared_updates[]` inside `PUT /api/vault/<id>`. The server updates `enc_data`, `iv`, `enc_name`, `iv_name` on each matching `SharedItem` row and logs a `shared_item.update` audit entry.
|
||||||
|
|
||||||
|
On the grantee side there are two paths:
|
||||||
|
- **Vault list (main view):** `openFreshSharedDetail()` calls `loadSharedItemsForVault()` → re-fetches `/api/sharing/inbox` on every View click — always current.
|
||||||
|
- **Sharing tab "View" button:** previously baked `enc_data`/`iv`/`enc_name`/`iv_name` into HTML `data-*` attributes at render time. Fixed to fetch `/api/sharing/inbox` fresh on every click, look up the share by ID, then decrypt the server's latest ciphertext. This prevents stale data if the sharing tab was already open when the owner edited.
|
||||||
|
|
||||||
|
**Gotcha:** never read `data-enc` / `data-iv` / `data-owner-key` from the View button for decryption — those attrs may be stale. Always re-fetch from `/api/sharing/inbox`.
|
||||||
|
|
||||||
### Vault item tags
|
### Vault item tags
|
||||||
|
|
||||||
@@ -598,6 +608,7 @@ Audit log details **never** contain plaintext item names, shared item names, or
|
|||||||
| `auth.py` | `auth.delete_account` / `auth.delete_account_failed` | Deletion |
|
| `auth.py` | `auth.delete_account` / `auth.delete_account_failed` | Deletion |
|
||||||
| `auth.py` | `auth.recovery_setup/failed/items_denied/success` | Recovery |
|
| `auth.py` | `auth.recovery_setup/failed/items_denied/success` | Recovery |
|
||||||
| `vault.py` | `vault_item.create/update/delete` | CRUD (detail: type + id only) |
|
| `vault.py` | `vault_item.create/update/delete` | CRUD (detail: type + id only) |
|
||||||
|
| `vault.py` | `shared_item.update` | Re-encrypted N accepted share copies when owner edits item |
|
||||||
| `vault.py` | `vault_item.export` / `vault_item.import` | Import/Export |
|
| `vault.py` | `vault_item.export` / `vault_item.import` | Import/Export |
|
||||||
| `folders.py` | `folder.create/update/delete` | Folder CRUD |
|
| `folders.py` | `folder.create/update/delete` | Folder CRUD |
|
||||||
| `sharing.py` | `sharing_keys.create/update` | ECDH key setup |
|
| `sharing.py` | `sharing_keys.create/update` | ECDH key setup |
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ def list_outgoing():
|
|||||||
d = s.to_dict()
|
d = s.to_dict()
|
||||||
recipient = db.session.get(User, s.recipient_id) if s.recipient_id else None
|
recipient = db.session.get(User, s.recipient_id) if s.recipient_id else None
|
||||||
d['recipient_name'] = recipient.email if recipient else s.recipient_email
|
d['recipient_name'] = recipient.email if recipient else s.recipient_email
|
||||||
|
d['recipient_public_key'] = recipient.sharing_public_key if recipient else None
|
||||||
result.append(d)
|
result.append(d)
|
||||||
return jsonify(result), 200
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
|||||||
+28
-1
@@ -142,6 +142,24 @@ def update_item(item_id):
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
item.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
item.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Re-encrypt accepted shared copies if the owner provided updated ciphertext.
|
||||||
|
# Each entry: { share_id, enc_data, iv, enc_name?, iv_name? }
|
||||||
|
from app.models.shared_item import SharedItem
|
||||||
|
updated_share_ids = []
|
||||||
|
for upd in (data.get('shared_updates') or []):
|
||||||
|
share = SharedItem.query.filter_by(
|
||||||
|
id=upd.get('share_id'),
|
||||||
|
owner_id=g.current_user_id,
|
||||||
|
accepted=True,
|
||||||
|
).first()
|
||||||
|
if share and upd.get('enc_data') and upd.get('iv'):
|
||||||
|
share.enc_data = upd['enc_data']
|
||||||
|
share.iv = upd['iv']
|
||||||
|
if upd.get('enc_name') is not None:
|
||||||
|
share.enc_name = upd['enc_name']
|
||||||
|
share.iv_name = upd.get('iv_name')
|
||||||
|
updated_share_ids.append(share.id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
AuditLog.log(
|
AuditLog.log(
|
||||||
@@ -152,6 +170,15 @@ def update_item(item_id):
|
|||||||
detail=f'Updated {item.item_type} item (id={item.id})',
|
detail=f'Updated {item.item_type} item (id={item.id})',
|
||||||
ip_address=client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
|
if updated_share_ids:
|
||||||
|
AuditLog.log(
|
||||||
|
user_id=g.current_user_id,
|
||||||
|
action='shared_item.update',
|
||||||
|
resource_type='shared_item',
|
||||||
|
resource_id=item.id,
|
||||||
|
detail=f'Re-encrypted {len(updated_share_ids)} shared copy(ies) for vault item (id={item.id}), share_ids={updated_share_ids}',
|
||||||
|
ip_address=client_ip(),
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
@@ -304,4 +331,4 @@ def audit_bulk_export():
|
|||||||
ip_address=client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'logged': len(valid_ids)}), 200
|
return jsonify({'logged': len(valid_ids)}), 200
|
||||||
@@ -343,6 +343,30 @@ ul {
|
|||||||
border-color: #d1d5db;
|
border-color: #d1d5db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shared item — subtle left-border indicator */
|
||||||
|
.vault-item--shared {
|
||||||
|
border-left: 3px solid #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-item--shared:hover {
|
||||||
|
border-left-color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-avatar {
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #4f46e5;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-shared-from {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
/* Initials avatar */
|
/* Initials avatar */
|
||||||
.item-avatar {
|
.item-avatar {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
|||||||
+291
-34
@@ -8,6 +8,7 @@
|
|||||||
const Vault = (() => {
|
const Vault = (() => {
|
||||||
let _items = [];
|
let _items = [];
|
||||||
let _folders = [];
|
let _folders = [];
|
||||||
|
let _sharedItems = []; // Accepted shared items from other users, decrypted
|
||||||
let _currentView = "vault";
|
let _currentView = "vault";
|
||||||
let _vaultLoaded = false;
|
let _vaultLoaded = false;
|
||||||
|
|
||||||
@@ -141,6 +142,29 @@ const Vault = (() => {
|
|||||||
"user_handle",
|
"user_handle",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Fields that are internal metadata and should never be shown in the detail
|
||||||
|
// view or shared with recipients.
|
||||||
|
const INTERNAL_PLAIN_FIELDS = new Set([
|
||||||
|
"password_history",
|
||||||
|
"password_changed_at",
|
||||||
|
"autofill",
|
||||||
|
"autologin",
|
||||||
|
"reprompt",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip internal/owner-only fields from a plain object before encrypting it
|
||||||
|
* for a recipient. Recipients should only see credential data, not owner
|
||||||
|
* preferences or history.
|
||||||
|
*/
|
||||||
|
function prepareSharePlain(plain) {
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(plain)) {
|
||||||
|
if (!INTERNAL_PLAIN_FIELDS.has(k)) out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// ── API helpers ───────────────────────────────────────────────────────────
|
// ── API helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function apiFetch(path, options = {}) {
|
async function apiFetch(path, options = {}) {
|
||||||
@@ -285,6 +309,9 @@ const Vault = (() => {
|
|||||||
// blocking the vault render. Results are cached so opening the Security
|
// blocking the vault render. Results are cached so opening the Security
|
||||||
// tab doesn't re-run HIBP checks.
|
// tab doesn't re-run HIBP checks.
|
||||||
runBackgroundHealthCheck();
|
runBackgroundHealthCheck();
|
||||||
|
// Load accepted shared items asynchronously so own items render first,
|
||||||
|
// then re-render once shared items are decrypted.
|
||||||
|
loadSharedItemsForVault().then(() => applyCurrentFilter());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast("Failed to load vault: " + err.message, "error");
|
showToast("Failed to load vault: " + err.message, "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -303,6 +330,124 @@ const Vault = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sharing session + shared-item vault integration ───────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the user's ECDH private key into SharingSession if not already loaded.
|
||||||
|
* Called before any operation that needs to decrypt shared items.
|
||||||
|
*/
|
||||||
|
async function ensureSharingSession() {
|
||||||
|
if (SharingSession.isReady()) return true;
|
||||||
|
const vaultKey = VaultSession.getKey();
|
||||||
|
if (!vaultKey) return false;
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/sharing/keys");
|
||||||
|
if (!res) return false;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.keys_setup) return false;
|
||||||
|
const privKey = await SharingCrypto.decryptPrivateKey(
|
||||||
|
vaultKey,
|
||||||
|
data.private_key_enc,
|
||||||
|
data.private_key_iv,
|
||||||
|
);
|
||||||
|
SharingSession.setKey(privKey);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and decrypt all accepted shared items, populating _sharedItems.
|
||||||
|
* Runs asynchronously after the vault loads so own items appear first.
|
||||||
|
*/
|
||||||
|
async function loadSharedItemsForVault() {
|
||||||
|
const ready = await ensureSharingSession();
|
||||||
|
if (!ready) { _sharedItems = []; return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/sharing/inbox");
|
||||||
|
if (!res) { _sharedItems = []; return; }
|
||||||
|
const inbox = await res.json();
|
||||||
|
const accepted = inbox.filter((s) => s.accepted && s.owner_public_key);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
accepted.map(async (s) => {
|
||||||
|
try {
|
||||||
|
const ownerPub = await SharingCrypto.importPublicKey(s.owner_public_key);
|
||||||
|
const sharedKey = await SharingCrypto.deriveSharedKey(
|
||||||
|
SharingSession.getKey(),
|
||||||
|
ownerPub,
|
||||||
|
);
|
||||||
|
const plain = await SharingCrypto.decryptShare(sharedKey, s.enc_data, s.iv);
|
||||||
|
let displayName = s.item_name;
|
||||||
|
if (s.enc_name && s.iv_name) {
|
||||||
|
const dec = await SharingCrypto.decryptName(sharedKey, s.enc_name, s.iv_name);
|
||||||
|
if (dec) displayName = dec;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: `shared-${s.id}`,
|
||||||
|
_shareId: s.id,
|
||||||
|
_sharedFrom: s.owner_email,
|
||||||
|
_shared: true,
|
||||||
|
item_type: s.item_type,
|
||||||
|
name: displayName,
|
||||||
|
folder_id: null,
|
||||||
|
created_at: s.created_at,
|
||||||
|
enc_data: s.enc_data,
|
||||||
|
iv: s.iv,
|
||||||
|
plain,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
_sharedItems = results.filter(Boolean);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[PassKeeper] loadSharedItemsForVault:", err);
|
||||||
|
_sharedItems = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After editing a vault item, re-encrypt its data for every accepted share
|
||||||
|
* so recipients always see the latest version.
|
||||||
|
* Returns an array suitable for the shared_updates field of PUT /api/vault/<id>.
|
||||||
|
*/
|
||||||
|
async function buildSharedUpdates(itemId, plainData, itemName) {
|
||||||
|
// Ensure the owner's ECDH private key is loaded before attempting re-encryption.
|
||||||
|
// The session may not be ready yet if the user edits before loadSharedItemsForVault
|
||||||
|
// completes (async race on first load).
|
||||||
|
await ensureSharingSession();
|
||||||
|
if (!SharingSession.isReady()) return [];
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/sharing");
|
||||||
|
if (!res) return [];
|
||||||
|
const outgoing = await res.json();
|
||||||
|
const mine = outgoing.filter(
|
||||||
|
(s) => s.item_id === parseInt(itemId) && s.accepted && s.recipient_public_key,
|
||||||
|
);
|
||||||
|
if (!mine.length) return [];
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
mine.map(async (s) => {
|
||||||
|
const recipPub = await SharingCrypto.importPublicKey(s.recipient_public_key);
|
||||||
|
const sharedKey = await SharingCrypto.deriveSharedKey(
|
||||||
|
SharingSession.getKey(),
|
||||||
|
recipPub,
|
||||||
|
);
|
||||||
|
const { enc_data, iv } = await SharingCrypto.encryptForShare(sharedKey, prepareSharePlain(plainData));
|
||||||
|
const { enc_name, iv_name } = await SharingCrypto.encryptName(sharedKey, itemName);
|
||||||
|
return { share_id: s.id, enc_data, iv, enc_name, iv_name };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[PassKeeper] buildSharedUpdates:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── View switching ────────────────────────────────────────────────────────
|
// ── View switching ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function switchView(view, { pushState = true } = {}) {
|
function switchView(view, { pushState = true } = {}) {
|
||||||
@@ -445,9 +590,7 @@ const Vault = (() => {
|
|||||||
_activeFilter = { type: "tag", value: tag };
|
_activeFilter = { type: "tag", value: tag };
|
||||||
switchView("vault");
|
switchView("vault");
|
||||||
updateVaultTitle("#" + tag);
|
updateVaultTitle("#" + tag);
|
||||||
renderItemList(
|
applyCurrentFilter();
|
||||||
_items.filter((i) => (i.plain?.tags || []).includes(tag)),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
ul.appendChild(li);
|
ul.appendChild(li);
|
||||||
});
|
});
|
||||||
@@ -470,10 +613,11 @@ const Vault = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SHARED_GROUP = "🔗 Shared with me";
|
||||||
const sorted = getSortedItems(items);
|
const sorted = getSortedItems(items);
|
||||||
const groups = {};
|
const groups = {};
|
||||||
sorted.forEach((item) => {
|
sorted.forEach((item) => {
|
||||||
const key = folderName(item.folder_id);
|
const key = item._shared ? SHARED_GROUP : folderName(item.folder_id);
|
||||||
if (!groups[key]) groups[key] = [];
|
if (!groups[key]) groups[key] = [];
|
||||||
groups[key].push(item);
|
groups[key].push(item);
|
||||||
});
|
});
|
||||||
@@ -481,7 +625,7 @@ const Vault = (() => {
|
|||||||
const keys =
|
const keys =
|
||||||
_sortOrder === "folder"
|
_sortOrder === "folder"
|
||||||
? Object.keys(groups).sort()
|
? Object.keys(groups).sort()
|
||||||
: ["(No folder)", ..._folders.map((f) => f.name)].filter(
|
: ["(No folder)", ..._folders.map((f) => f.name), SHARED_GROUP].filter(
|
||||||
(k) => groups[k],
|
(k) => groups[k],
|
||||||
);
|
);
|
||||||
Object.keys(groups).forEach((k) => {
|
Object.keys(groups).forEach((k) => {
|
||||||
@@ -540,7 +684,85 @@ const Vault = (() => {
|
|||||||
return name.slice(0, 2).toUpperCase();
|
return name.slice(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _createSharedItemElement(item) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "vault-item vault-item--shared";
|
||||||
|
li.dataset.id = item.id;
|
||||||
|
|
||||||
|
const p = item.plain || {};
|
||||||
|
let subText = "";
|
||||||
|
switch (item.item_type) {
|
||||||
|
case "password": subText = p.username || p.url || ""; break;
|
||||||
|
case "note": subText = (p.note_body || "").slice(0, 60); break;
|
||||||
|
case "card": subText = p.card_number ? "•••• " + String(p.card_number).replace(/\s/g, "").slice(-4) : ""; break;
|
||||||
|
case "bank": subText = p.bank_name || ""; break;
|
||||||
|
case "address": subText = [p.first_name, p.last_name].filter(Boolean).join(" "); break;
|
||||||
|
case "ssn": subText = "•••-••-••••"; break;
|
||||||
|
case "passkey": subText = [p.username, p.rp_id ? `@ ${p.rp_id}` : ""].filter(Boolean).join(" "); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCopyUser = item.item_type === "password";
|
||||||
|
const showCopyPass = ["password", "card", "bank", "ssn"].includes(item.item_type);
|
||||||
|
const showLaunch = item.item_type === "password" && p.url;
|
||||||
|
|
||||||
|
li.innerHTML = `
|
||||||
|
<div class="item-avatar shared-avatar" aria-hidden="true">🔗</div>
|
||||||
|
<div class="item-info">
|
||||||
|
<span class="item-name">${escHtml(item.name)}</span>
|
||||||
|
<span class="item-sub">${escHtml(subText)}</span>
|
||||||
|
<span class="item-shared-from">From: ${escHtml(item._sharedFrom)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-actions">
|
||||||
|
${showLaunch ? `<button class="btn-icon" title="Open URL" data-action="launch">🌐</button>` : ""}
|
||||||
|
${showCopyUser ? `<button class="btn-icon" title="Copy username" data-action="copy-user">👤</button>` : ""}
|
||||||
|
${showCopyPass ? `<button class="btn-icon" title="Copy secret" data-action="copy-pass">📋</button>` : ""}
|
||||||
|
<button class="btn-icon" title="View details" data-action="view">👁️</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (showLaunch) {
|
||||||
|
li.querySelector('[data-action="launch"]').addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let url = p.url || "";
|
||||||
|
if (url && !/^https?:\/\//i.test(url)) url = "https://" + url;
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (showCopyUser) {
|
||||||
|
li.querySelector('[data-action="copy-user"]').addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyToClipboard(p.username || "", "Username copied");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (showCopyPass) {
|
||||||
|
li.querySelector('[data-action="copy-pass"]').addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const secret = item.item_type === "password" ? p.password
|
||||||
|
: item.item_type === "card" ? p.cvv
|
||||||
|
: item.item_type === "bank" ? p.account_number
|
||||||
|
: p.ssn_number;
|
||||||
|
copyToClipboard(secret || "", "Copied to clipboard");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function openFreshSharedDetail() {
|
||||||
|
// Re-fetch shared items so the grantee always sees the owner's latest
|
||||||
|
// version, not a stale in-memory snapshot.
|
||||||
|
await loadSharedItemsForVault();
|
||||||
|
const fresh = _sharedItems.find((si) => si._shareId === item._shareId);
|
||||||
|
const target = fresh || item;
|
||||||
|
showSharedItemDetails(target.name, target.item_type, target.plain);
|
||||||
|
}
|
||||||
|
li.querySelector('[data-action="view"]').addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openFreshSharedDetail();
|
||||||
|
});
|
||||||
|
li.addEventListener("click", () => openFreshSharedDetail());
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
function createItemElement(item) {
|
function createItemElement(item) {
|
||||||
|
// ── Shared-item (read-only) card ──────────────────────────────────────────
|
||||||
|
if (item._shared) return _createSharedItemElement(item);
|
||||||
|
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.className = "vault-item";
|
li.className = "vault-item";
|
||||||
li.dataset.id = item.id;
|
li.dataset.id = item.id;
|
||||||
@@ -1990,8 +2212,20 @@ const Vault = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// Always fetch fresh share data from the server so the grantee sees
|
||||||
|
// the owner's latest version, not the ciphertext that was baked into
|
||||||
|
// the button's data-* attributes when the tab was last rendered.
|
||||||
|
const shareId = parseInt(btn.dataset.viewShare);
|
||||||
|
const inboxRes = await apiFetch("/api/sharing/inbox");
|
||||||
|
if (!inboxRes) return;
|
||||||
|
const inbox = await inboxRes.json();
|
||||||
|
const freshShare = inbox.find((s) => s.id === shareId);
|
||||||
|
if (!freshShare || !freshShare.owner_public_key) {
|
||||||
|
showToast("Share not found or owner key unavailable.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ownerPubKey = await SharingCrypto.importPublicKey(
|
const ownerPubKey = await SharingCrypto.importPublicKey(
|
||||||
btn.dataset.ownerKey,
|
freshShare.owner_public_key,
|
||||||
);
|
);
|
||||||
const sharedKey = await SharingCrypto.deriveSharedKey(
|
const sharedKey = await SharingCrypto.deriveSharedKey(
|
||||||
SharingSession.getKey(),
|
SharingSession.getKey(),
|
||||||
@@ -1999,21 +2233,21 @@ const Vault = (() => {
|
|||||||
);
|
);
|
||||||
const plain = await SharingCrypto.decryptShare(
|
const plain = await SharingCrypto.decryptShare(
|
||||||
sharedKey,
|
sharedKey,
|
||||||
btn.dataset.enc,
|
freshShare.enc_data,
|
||||||
btn.dataset.iv,
|
freshShare.iv,
|
||||||
);
|
);
|
||||||
// Decrypt the item name if an encrypted version is available.
|
// Decrypt the item name if an encrypted version is available.
|
||||||
// Falls back to the non-sensitive label for legacy shares.
|
// Falls back to the non-sensitive label for legacy shares.
|
||||||
let displayName = btn.dataset.name;
|
let displayName = freshShare.item_name;
|
||||||
if (btn.dataset.encName && btn.dataset.ivName) {
|
if (freshShare.enc_name && freshShare.iv_name) {
|
||||||
const decrypted = await SharingCrypto.decryptName(
|
const decrypted = await SharingCrypto.decryptName(
|
||||||
sharedKey,
|
sharedKey,
|
||||||
btn.dataset.encName,
|
freshShare.enc_name,
|
||||||
btn.dataset.ivName,
|
freshShare.iv_name,
|
||||||
);
|
);
|
||||||
if (decrypted) displayName = decrypted;
|
if (decrypted) displayName = decrypted;
|
||||||
}
|
}
|
||||||
showSharedItemDetails(displayName, btn.dataset.type, plain);
|
showSharedItemDetails(displayName, freshShare.item_type, plain);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast("Could not decrypt: " + err.message, "error");
|
showToast("Could not decrypt: " + err.message, "error");
|
||||||
}
|
}
|
||||||
@@ -2029,12 +2263,31 @@ const Vault = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDetailFields(container, plain) {
|
function renderDetailFields(container, plain) {
|
||||||
|
const DATE_FIELDS = new Set(["password_changed_at"]);
|
||||||
container.innerHTML =
|
container.innerHTML =
|
||||||
Object.entries(plain)
|
Object.entries(plain)
|
||||||
.filter(([, v]) => v)
|
.filter(([k, v]) => {
|
||||||
|
if (INTERNAL_PLAIN_FIELDS.has(k)) return false; // strip internal metadata
|
||||||
|
if (k === "tags") return Array.isArray(v) && v.length > 0;
|
||||||
|
return !!v;
|
||||||
|
})
|
||||||
.map(([k, v]) => {
|
.map(([k, v]) => {
|
||||||
const label = FIELD_LABELS[k] || k.replace(/_/g, " ");
|
const label = FIELD_LABELS[k] || k.replace(/_/g, " ");
|
||||||
const val = String(v);
|
// Tags → badge chips, no copy button
|
||||||
|
if (k === "tags") {
|
||||||
|
const badges = (v || [])
|
||||||
|
.map((t) => `<span class="item-tag">${escHtml(t)}</span>`)
|
||||||
|
.join(" ");
|
||||||
|
return `<div class="detail-field">
|
||||||
|
<span class="detail-label">${escHtml(label)}</span>
|
||||||
|
<div class="detail-val-row"><span class="detail-val">${badges}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
// Dates → human-readable
|
||||||
|
let val = String(v);
|
||||||
|
if (DATE_FIELDS.has(k)) {
|
||||||
|
try { val = new Date(v).toLocaleString(); } catch { /* keep raw */ }
|
||||||
|
}
|
||||||
const sensitive = SENSITIVE_FIELDS.has(k);
|
const sensitive = SENSITIVE_FIELDS.has(k);
|
||||||
const valHtml = sensitive
|
const valHtml = sensitive
|
||||||
? `<span class="detail-sensitive">
|
? `<span class="detail-sensitive">
|
||||||
@@ -2122,7 +2375,7 @@ const Vault = (() => {
|
|||||||
|
|
||||||
const { enc_data, iv } = await SharingCrypto.encryptForShare(
|
const { enc_data, iv } = await SharingCrypto.encryptForShare(
|
||||||
sharedKey,
|
sharedKey,
|
||||||
item.plain,
|
prepareSharePlain(item.plain),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Encrypt the display name with the same ECDH shared key so the server
|
// Encrypt the display name with the same ECDH shared key so the server
|
||||||
@@ -3634,19 +3887,19 @@ const Vault = (() => {
|
|||||||
// ── Filtering / Search ────────────────────────────────────────────────────
|
// ── Filtering / Search ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function applyCurrentFilter() {
|
function applyCurrentFilter() {
|
||||||
|
const all = [..._items, ..._sharedItems];
|
||||||
if (!_activeFilter) {
|
if (!_activeFilter) {
|
||||||
renderItemList(_items);
|
renderItemList(all);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_activeFilter.type === "itemType")
|
if (_activeFilter.type === "itemType")
|
||||||
renderItemList(_items.filter((i) => i.item_type === _activeFilter.value));
|
renderItemList(all.filter((i) => i.item_type === _activeFilter.value));
|
||||||
else if (_activeFilter.type === "folder")
|
else if (_activeFilter.type === "folder")
|
||||||
|
// Shared items belong to no folder — exclude them from folder filters.
|
||||||
renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value));
|
renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value));
|
||||||
else if (_activeFilter.type === "tag")
|
else if (_activeFilter.type === "tag")
|
||||||
renderItemList(
|
renderItemList(
|
||||||
_items.filter((i) =>
|
all.filter((i) => (i.plain?.tags || []).includes(_activeFilter.value)),
|
||||||
(i.plain?.tags || []).includes(_activeFilter.value),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3669,17 +3922,16 @@ const Vault = (() => {
|
|||||||
applyCurrentFilter();
|
applyCurrentFilter();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const all = [..._items, ..._sharedItems];
|
||||||
const pool = !_activeFilter
|
const pool = !_activeFilter
|
||||||
? _items
|
? all
|
||||||
: _activeFilter.type === "itemType"
|
: _activeFilter.type === "itemType"
|
||||||
? _items.filter((i) => i.item_type === _activeFilter.value)
|
? all.filter((i) => i.item_type === _activeFilter.value)
|
||||||
: _activeFilter.type === "folder"
|
: _activeFilter.type === "folder"
|
||||||
? _items.filter((i) => i.folder_id === _activeFilter.value)
|
? _items.filter((i) => i.folder_id === _activeFilter.value)
|
||||||
: _activeFilter.type === "tag"
|
: _activeFilter.type === "tag"
|
||||||
? _items.filter((i) =>
|
? all.filter((i) => (i.plain?.tags || []).includes(_activeFilter.value))
|
||||||
(i.plain?.tags || []).includes(_activeFilter.value),
|
: all;
|
||||||
)
|
|
||||||
: _items;
|
|
||||||
renderItemList(
|
renderItemList(
|
||||||
pool.filter((item) => {
|
pool.filter((item) => {
|
||||||
const p = item.plain || {};
|
const p = item.plain || {};
|
||||||
@@ -3855,8 +4107,8 @@ const Vault = (() => {
|
|||||||
return `<div class="pw-history-row" data-hist-idx="${idx}">
|
return `<div class="pw-history-row" data-hist-idx="${idx}">
|
||||||
<span class="pw-history-masked">••••••••</span>
|
<span class="pw-history-masked">••••••••</span>
|
||||||
<span class="pw-history-date">${escHtml(date)}</span>
|
<span class="pw-history-date">${escHtml(date)}</span>
|
||||||
<button class="btn-text btn-sm pw-history-reveal" title="Reveal">👁</button>
|
<button type="button" class="btn-text btn-sm pw-history-reveal" title="Reveal">👁</button>
|
||||||
<button class="btn-text btn-sm pw-history-restore" title="Restore this password">↩ Restore</button>
|
<button type="button" class="btn-text btn-sm pw-history-restore" title="Restore this password">↩ Restore</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
@@ -4067,9 +4319,11 @@ const Vault = (() => {
|
|||||||
if (!res) return;
|
if (!res) return;
|
||||||
showToast("Item added");
|
showToast("Item added");
|
||||||
} else {
|
} else {
|
||||||
|
// Re-encrypt shared copies so recipients always see the latest data.
|
||||||
|
const shared_updates = await buildSharedUpdates(itemId, plainData, name);
|
||||||
const res = await apiFetch(`/api/vault/${itemId}`, {
|
const res = await apiFetch(`/api/vault/${itemId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({ ...payload, shared_updates }),
|
||||||
});
|
});
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
showToast("Item updated");
|
showToast("Item updated");
|
||||||
@@ -4355,12 +4609,15 @@ const Vault = (() => {
|
|||||||
|
|
||||||
function initTabs(container) {
|
function initTabs(container) {
|
||||||
const tabs = container.querySelectorAll(".tab-btn");
|
const tabs = container.querySelectorAll(".tab-btn");
|
||||||
|
// Tab panes are siblings of the .panel-tabs container, not descendants of it,
|
||||||
|
// so search in the parent element.
|
||||||
|
const paneRoot = container.parentElement;
|
||||||
tabs.forEach((btn) => {
|
tabs.forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
tabs.forEach((t) => t.classList.remove("active"));
|
tabs.forEach((t) => t.classList.remove("active"));
|
||||||
btn.classList.add("active");
|
btn.classList.add("active");
|
||||||
const target = btn.dataset.tab;
|
const target = btn.dataset.tab;
|
||||||
container.querySelectorAll(".tab-pane").forEach((pane) => {
|
paneRoot.querySelectorAll(".tab-pane").forEach((pane) => {
|
||||||
pane.classList.toggle("hidden", !pane.id.endsWith(target));
|
pane.classList.toggle("hidden", !pane.id.endsWith(target));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -4478,7 +4735,7 @@ const Vault = (() => {
|
|||||||
.forEach((el) => el.classList.remove("active"));
|
.forEach((el) => el.classList.remove("active"));
|
||||||
document.getElementById("sidebar-all").classList.add("active");
|
document.getElementById("sidebar-all").classList.add("active");
|
||||||
updateVaultTitle("All Items");
|
updateVaultTitle("All Items");
|
||||||
renderItemList(_items);
|
applyCurrentFilter();
|
||||||
});
|
});
|
||||||
document.getElementById("sidebar-favorites")?.addEventListener("click", () => {
|
document.getElementById("sidebar-favorites")?.addEventListener("click", () => {
|
||||||
_activeFilter = { type: "tag", value: "favorite" };
|
_activeFilter = { type: "tag", value: "favorite" };
|
||||||
@@ -4488,7 +4745,7 @@ const Vault = (() => {
|
|||||||
.forEach((el) => el.classList.remove("active"));
|
.forEach((el) => el.classList.remove("active"));
|
||||||
document.getElementById("sidebar-favorites").classList.add("active");
|
document.getElementById("sidebar-favorites").classList.add("active");
|
||||||
updateVaultTitle("⭐ Favorites");
|
updateVaultTitle("⭐ Favorites");
|
||||||
renderItemList(_items.filter((i) => (i.plain?.tags || []).includes("favorite")));
|
applyCurrentFilter();
|
||||||
});
|
});
|
||||||
document
|
document
|
||||||
.getElementById("sidebar-security")
|
.getElementById("sidebar-security")
|
||||||
@@ -4525,7 +4782,7 @@ const Vault = (() => {
|
|||||||
.forEach((s) => s.classList.remove("active"));
|
.forEach((s) => s.classList.remove("active"));
|
||||||
el.classList.add("active");
|
el.classList.add("active");
|
||||||
updateVaultTitle(labels[type] || type);
|
updateVaultTitle(labels[type] || type);
|
||||||
renderItemList(_items.filter((i) => i.item_type === type));
|
applyCurrentFilter();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -379,17 +379,17 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="panel-tabs">
|
<div class="panel-tabs">
|
||||||
<button class="tab-btn active" data-tab="em-grants">
|
<button class="tab-btn" data-tab="em-grants">
|
||||||
My trusted contacts
|
My trusted contacts
|
||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" data-tab="em-access">
|
<button class="tab-btn active" data-tab="em-access">
|
||||||
I'm a trusted contact
|
I'm a trusted contact
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-em-grants" class="tab-pane">
|
<div id="tab-em-grants" class="tab-pane hidden">
|
||||||
<ul id="em-grants-list" class="share-list"></ul>
|
<ul id="em-grants-list" class="share-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-em-access" class="tab-pane hidden">
|
<div id="tab-em-access" class="tab-pane">
|
||||||
<ul id="em-access-list" class="share-list"></ul>
|
<ul id="em-access-list" class="share-list"></ul>
|
||||||
<div id="em-vault-panel" class="em-vault-panel hidden">
|
<div id="em-vault-panel" class="em-vault-panel hidden">
|
||||||
<div class="em-vault-header">
|
<div class="em-vault-header">
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ if (!chrome.storage.session) {
|
|||||||
|
|
||||||
// ── Idle lock (timeout-based, no chrome.idle) ─────────────────────────────────
|
// ── Idle lock (timeout-based, no chrome.idle) ─────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_IDLE_LOCK_SECONDS = 600;
|
const DEFAULT_IDLE_LOCK_SECONDS = 0;
|
||||||
const IDLE_TIMEOUT_KEY = 'idle_lock_seconds';
|
const IDLE_TIMEOUT_KEY = 'idle_lock_seconds';
|
||||||
let _idleTimer = null;
|
let _idleTimer = null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
|
|
||||||
// ── Idle lock ─────────────────────────────────────────────────────────────────
|
// ── Idle lock ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Default: 10 minutes. User can change via the Account view in the popup.
|
// Default: never lock (session clears naturally on browser close via chrome.storage.session).
|
||||||
const DEFAULT_IDLE_LOCK_SECONDS = 600;
|
const DEFAULT_IDLE_LOCK_SECONDS = 0;
|
||||||
const IDLE_TIMEOUT_KEY = "idle_lock_seconds";
|
const IDLE_TIMEOUT_KEY = "idle_lock_seconds";
|
||||||
|
|
||||||
/** Apply the idle detection interval, reading the user's saved preference. */
|
/** Apply the idle detection interval, reading the user's saved preference. */
|
||||||
|
|||||||
@@ -393,6 +393,19 @@ body {
|
|||||||
vertical-align: middle;
|
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 ──────────────────────────────────────────────────────── */
|
/* ── States ──────────────────────────────────────────────────────── */
|
||||||
.pk-error {
|
.pk-error {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -450,9 +450,9 @@
|
|||||||
<select id="acct-idle-timeout" class="acct-select">
|
<select id="acct-idle-timeout" class="acct-select">
|
||||||
<option value="60">1 minute</option>
|
<option value="60">1 minute</option>
|
||||||
<option value="300">5 minutes</option>
|
<option value="300">5 minutes</option>
|
||||||
<option value="600" selected>10 minutes (default)</option>
|
<option value="600">10 minutes</option>
|
||||||
<option value="1800">30 minutes</option>
|
<option value="1800">30 minutes</option>
|
||||||
<option value="0">Never</option>
|
<option value="0" selected>Never (default)</option>
|
||||||
</select>
|
</select>
|
||||||
<p id="acct-idle-saved" class="acct-saved hidden">✓ Saved</p>
|
<p id="acct-idle-saved" class="acct-saved hidden">✓ Saved</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -622,6 +622,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- /app -->
|
<!-- /app -->
|
||||||
<script src="../shared/crypto.js"></script>
|
<script src="../shared/crypto.js"></script>
|
||||||
|
<script src="../shared/sharing-crypto.js"></script>
|
||||||
<script src="popup.js"></script>
|
<script src="popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+91
-14
@@ -24,6 +24,7 @@ let _mfaToken = null;
|
|||||||
let _currentUrl = "";
|
let _currentUrl = "";
|
||||||
let _activeTab = "relevant";
|
let _activeTab = "relevant";
|
||||||
const _collapsedFolders = new Set(); // persists collapsed state across re-renders
|
const _collapsedFolders = new Set(); // persists collapsed state across re-renders
|
||||||
|
let _sharingPrivKey = null; // decrypted ECDH private key for the session
|
||||||
|
|
||||||
// ── DOM helpers ───────────────────────────────────────────────────────────────
|
// ── DOM helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -509,10 +510,36 @@ async function signOut() {
|
|||||||
await chrome.storage.local.remove(["refresh_token", "enc_key_salt"]);
|
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.
|
// vault_items_cs is now in session storage — cleared by the session.clear() call above.
|
||||||
_vaultKey = null;
|
_vaultKey = null;
|
||||||
|
_sharingPrivKey = null;
|
||||||
_items = [];
|
_items = [];
|
||||||
showView("login");
|
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 ─────────────────────────────────────────────────────────────
|
// ── 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 });
|
await chrome.storage.session.set({ vault_items: _items });
|
||||||
|
|
||||||
// Write a lightweight copy to session storage for content scripts.
|
// Load ECDH sharing private key (no-op if already loaded or keys not set up).
|
||||||
// session storage is memory-only (cleared on browser close) — decrypted
|
await _loadSharingPrivKey();
|
||||||
// vault data must never be persisted to disk via chrome.storage.local.
|
|
||||||
|
// 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) => ({
|
const itemsForContentScript = _items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
@@ -609,11 +687,9 @@ async function fetchAndDecryptVault() {
|
|||||||
plain: item.plain,
|
plain: item.plain,
|
||||||
}));
|
}));
|
||||||
await chrome.storage.session.set({ vault_items_cs: itemsForContentScript });
|
await chrome.storage.session.set({ vault_items_cs: itemsForContentScript });
|
||||||
|
|
||||||
// Notify background to refresh badges and forward to content scripts.
|
|
||||||
chrome.runtime
|
chrome.runtime
|
||||||
.sendMessage({
|
.sendMessage({
|
||||||
type: "VAULT_UPDATED",
|
type: 'VAULT_UPDATED',
|
||||||
vault_items: itemsForContentScript,
|
vault_items: itemsForContentScript,
|
||||||
})
|
})
|
||||||
.catch(() => { });
|
.catch(() => { });
|
||||||
@@ -640,7 +716,7 @@ async function fetchAndDecryptVault() {
|
|||||||
async function _runPopupHealthCheck() {
|
async function _runPopupHealthCheck() {
|
||||||
try {
|
try {
|
||||||
const pwItems = _items.filter(
|
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) {
|
if (!pwItems.length) {
|
||||||
chrome.runtime
|
chrome.runtime
|
||||||
@@ -883,6 +959,7 @@ function renderList() {
|
|||||||
const site = escHtml(siteLabel(item));
|
const site = escHtml(siteLabel(item));
|
||||||
const name = escHtml(item.name);
|
const name = escHtml(item.name);
|
||||||
const badge = matched ? '<span class="badge-match">match</span>' : "";
|
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 color = avatarColor(item.name);
|
||||||
const emoji = itemEmoji(item.item_type);
|
const emoji = itemEmoji(item.item_type);
|
||||||
const canFill =
|
const canFill =
|
||||||
@@ -902,7 +979,7 @@ function renderList() {
|
|||||||
return `<div class="vault-item" data-id="${item.id}">
|
return `<div class="vault-item" data-id="${item.id}">
|
||||||
<div class="item-avatar ${color}">${emoji}</div>
|
<div class="item-avatar ${color}">${emoji}</div>
|
||||||
<div class="item-info">
|
<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>
|
<div class="item-name">${name}</div>
|
||||||
${tagHtml ? `<div class="pk-tag-row">${tagHtml}</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>` : ""}
|
${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) =>
|
listEl.querySelectorAll("[data-copy-pass]").forEach((btn) =>
|
||||||
btn.addEventListener("click", async (e) => {
|
btn.addEventListener("click", async (e) => {
|
||||||
e.stopPropagation();
|
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?.password) return;
|
||||||
if (item.plain?.reprompt) {
|
if (item.plain?.reprompt) {
|
||||||
const ok = await _repromptMasterPassword();
|
const ok = await _repromptMasterPassword();
|
||||||
@@ -1014,7 +1091,7 @@ function renderList() {
|
|||||||
listEl.querySelectorAll("[data-copy-totp]").forEach((btn) =>
|
listEl.querySelectorAll("[data-copy-totp]").forEach((btn) =>
|
||||||
btn.addEventListener("click", async (e) => {
|
btn.addEventListener("click", async (e) => {
|
||||||
e.stopPropagation();
|
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;
|
if (!item?.plain?.totp_uri) return;
|
||||||
const code = await getTotpCode(item.plain.totp_uri).catch(() => null);
|
const code = await getTotpCode(item.plain.totp_uri).catch(() => null);
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -1035,7 +1112,7 @@ function renderList() {
|
|||||||
listEl.querySelectorAll("[data-autofill]").forEach((btn) =>
|
listEl.querySelectorAll("[data-autofill]").forEach((btn) =>
|
||||||
btn.addEventListener("click", async (e) => {
|
btn.addEventListener("click", async (e) => {
|
||||||
e.stopPropagation();
|
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) return;
|
||||||
if (item.plain?.reprompt) {
|
if (item.plain?.reprompt) {
|
||||||
const ok = await _repromptMasterPassword();
|
const ok = await _repromptMasterPassword();
|
||||||
@@ -1063,7 +1140,7 @@ function renderList() {
|
|||||||
listEl.querySelectorAll("[data-copy-user]").forEach((btn) =>
|
listEl.querySelectorAll("[data-copy-user]").forEach((btn) =>
|
||||||
btn.addEventListener("click", async (e) => {
|
btn.addEventListener("click", async (e) => {
|
||||||
e.stopPropagation();
|
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?.username) return;
|
||||||
if (item.plain?.reprompt) {
|
if (item.plain?.reprompt) {
|
||||||
const ok = await _repromptMasterPassword();
|
const ok = await _repromptMasterPassword();
|
||||||
@@ -1084,7 +1161,7 @@ function renderList() {
|
|||||||
// Close any already-open flyout first.
|
// Close any already-open flyout first.
|
||||||
document.querySelectorAll(".pk-flyout").forEach((el) => el.remove());
|
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;
|
if (!item) return;
|
||||||
|
|
||||||
const flyout = document.createElement("div");
|
const flyout = document.createElement("div");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user