From 977805f5e722067e6223efd2ef674a086ce33e9d Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 29 May 2026 14:58:11 -0400 Subject: [PATCH] 05/29 Fixed a shared item isn't auto updated when it's modified by owner --- CLAUDE.md | 13 ++++++++++++- app/routes/vault.py | 13 ++++++++++++- app/static/js/vault.js | 28 ++++++++++++++++++++-------- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cb7bb89..9127f08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -444,7 +444,17 @@ The sharing key uses raw `SubtleCrypto` calls (not `SharingCrypto.decryptPrivate ### 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/`. 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 @@ -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.recovery_setup/failed/items_denied/success` | Recovery | | `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 | | `folders.py` | `folder.create/update/delete` | Folder CRUD | | `sharing.py` | `sharing_keys.create/update` | ECDH key setup | diff --git a/app/routes/vault.py b/app/routes/vault.py index 3265886..69ae9e8 100644 --- a/app/routes/vault.py +++ b/app/routes/vault.py @@ -145,6 +145,7 @@ def update_item(item_id): # 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'), @@ -157,6 +158,7 @@ def update_item(item_id): 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: db.session.flush() @@ -168,6 +170,15 @@ def update_item(item_id): detail=f'Updated {item.item_type} item (id={item.id})', 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() except Exception: db.session.rollback() @@ -320,4 +331,4 @@ def audit_bulk_export(): ip_address=client_ip(), ) db.session.commit() - return jsonify({'logged': len(valid_ids)}), 200 + return jsonify({'logged': len(valid_ids)}), 200 \ No newline at end of file diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 684d959..a604d0b 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -2212,8 +2212,20 @@ const Vault = (() => { return; } 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( - btn.dataset.ownerKey, + freshShare.owner_public_key, ); const sharedKey = await SharingCrypto.deriveSharedKey( SharingSession.getKey(), @@ -2221,21 +2233,21 @@ const Vault = (() => { ); const plain = await SharingCrypto.decryptShare( sharedKey, - btn.dataset.enc, - btn.dataset.iv, + freshShare.enc_data, + freshShare.iv, ); // Decrypt the item name if an encrypted version is available. // Falls back to the non-sensitive label for legacy shares. - let displayName = btn.dataset.name; - if (btn.dataset.encName && btn.dataset.ivName) { + let displayName = freshShare.item_name; + if (freshShare.enc_name && freshShare.iv_name) { const decrypted = await SharingCrypto.decryptName( sharedKey, - btn.dataset.encName, - btn.dataset.ivName, + freshShare.enc_name, + freshShare.iv_name, ); if (decrypted) displayName = decrypted; } - showSharedItemDetails(displayName, btn.dataset.type, plain); + showSharedItemDetails(displayName, freshShare.item_type, plain); } catch (err) { showToast("Could not decrypt: " + err.message, "error"); }