From 2eaf2adbf1d7cee828f641811ac06093326b82fb Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 18 May 2026 12:12:41 -0400 Subject: [PATCH] 05/18 Enhanced codes and functionalities 4 --- app/models/shared_item.py | 11 ++++- app/routes/sharing.py | 17 ++++--- app/static/js/sharing.js | 34 ++++++++++++++ app/static/js/vault.js | 37 +++++++++++++-- extension/manifest.firefox.json | 1 + extension/manifest.json | 6 +++ .../g7h8i9j0k1l2_encrypt_shared_item_name.py | 45 +++++++++++++++++++ 7 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 migrations/versions/g7h8i9j0k1l2_encrypt_shared_item_name.py diff --git a/app/models/shared_item.py b/app/models/shared_item.py index 1dfcad8..44959f4 100644 --- a/app/models/shared_item.py +++ b/app/models/shared_item.py @@ -32,12 +32,19 @@ class SharedItem(db.Model): db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, ) - # Plaintext metadata — not sensitive, used for display before decryption + # Non-sensitive fallback label (set to item_type, e.g. "password"). + # The actual display name is stored encrypted in enc_name/iv_name below. item_name = db.Column(db.String(255), nullable=False) item_type = db.Column(db.String(20), nullable=False, default='password') # ECDH-encrypted payload enc_data = db.Column(db.Text, nullable=False) iv = db.Column(db.String(64), nullable=False) + # Encrypted item name — AES-256-GCM with the ECDH shared secret. + # enc_name: base64 ciphertext of the display name string. + # iv_name : base64 12-byte GCM nonce. + # Nullable for backwards compatibility with rows created before this column existed. + enc_name = db.Column(db.String(512), nullable=True) + iv_name = db.Column(db.String(64), nullable=True) accepted = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False) @@ -53,6 +60,8 @@ class SharedItem(db.Model): 'item_type': self.item_type, 'enc_data': self.enc_data, 'iv': self.iv, + 'enc_name': self.enc_name, + 'iv_name': self.iv_name, 'accepted': self.accepted, 'created_at': self.created_at.isoformat() if self.created_at else None, } diff --git a/app/routes/sharing.py b/app/routes/sharing.py index 06598cc..c57cfd5 100644 --- a/app/routes/sharing.py +++ b/app/routes/sharing.py @@ -123,6 +123,9 @@ def create_share(): iv = data.get('iv', '') item_name = (data.get('item_name') or '').strip() item_type = data.get('item_type', 'password') + # Encrypted display name — encrypted with the ECDH shared secret client-side. + enc_name = data.get('enc_name') or None + iv_name = data.get('iv_name') or None if not all([item_id, recipient_email, enc_data, iv, item_name]): return jsonify({'error': 'item_id, recipient_email, enc_data, iv, item_name are required'}), 400 @@ -144,10 +147,12 @@ def create_share(): owner_id=g.current_user_id, recipient_email=recipient_email, recipient_id=recipient.id if recipient else None, - item_name=item_name, + item_name=item_name, # non-sensitive label (item_type value) item_type=item_type, enc_data=enc_data, iv=iv, + enc_name=enc_name, + iv_name=iv_name, ) db.session.add(share) db.session.flush() # populate share.id before logging @@ -157,7 +162,7 @@ def create_share(): action='shared_item.create', resource_type='shared_item', resource_id=share.id, - detail=f'Shared item "{item_name}" ({item_type}) with {recipient_email}', + detail=f'Shared {item_type} item (id={item_id}) with {recipient_email}', ip_address=client_ip(), ) db.session.commit() @@ -173,8 +178,8 @@ def delete_share(share_id): if not share: return jsonify({'error': 'Share not found'}), 404 - item_name = share.item_name - recipient_email = share.recipient_email + item_type_saved = share.item_type + recipient_email_saved = share.recipient_email db.session.delete(share) db.session.flush() @@ -183,7 +188,7 @@ def delete_share(share_id): action='shared_item.delete', resource_type='shared_item', resource_id=share_id, - detail=f'Revoked share of "{item_name}" with {recipient_email}', + detail=f'Revoked share of {item_type_saved} item (share_id={share_id}) with {recipient_email_saved}', ip_address=client_ip(), ) db.session.commit() @@ -247,7 +252,7 @@ def accept_share(share_id): action='shared_item.accept', resource_type='shared_item', resource_id=share.id, - detail=f'Accepted shared item "{share.item_name}" from {owner_email}', + detail=f'Accepted shared {share.item_type} item (share_id={share.id}) from {owner_email}', ip_address=client_ip(), ) db.session.commit() diff --git a/app/static/js/sharing.js b/app/static/js/sharing.js index 55e6a4d..ad75d94 100644 --- a/app/static/js/sharing.js +++ b/app/static/js/sharing.js @@ -151,6 +151,38 @@ const SharingCrypto = (() => { return JSON.parse(new TextDecoder().decode(plaintext)); } + /** + * Encrypt a display name string with the ECDH shared key. + * Uses the same AES-256-GCM primitive as encryptForShare but operates on + * a plain string rather than a JSON-serialised object. + */ + async function encryptName(sharedKey, name) { + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(name); + const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, plaintext); + return { + enc_name: bytesToBase64(new Uint8Array(ciphertext)), + iv_name: bytesToBase64(iv), + }; + } + + /** + * Decrypt an encrypted display name. + * Returns null on failure (e.g. legacy share with no 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 { @@ -162,6 +194,8 @@ const SharingCrypto = (() => { deriveSharedKey, encryptForShare, decryptShare, + encryptName, + decryptName, }; })(); diff --git a/app/static/js/vault.js b/app/static/js/vault.js index fe4a004..2361734 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -1413,7 +1413,15 @@ const Vault = (() => { ${ !s.accepted ? `` - : `` + : `` } `, ) @@ -1452,7 +1460,18 @@ const Vault = (() => { btn.dataset.enc, btn.dataset.iv, ); - showSharedItemDetails(btn.dataset.name, btn.dataset.type, plain); + // 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) { + const decrypted = await SharingCrypto.decryptName( + sharedKey, + btn.dataset.encName, + btn.dataset.ivName, + ); + if (decrypted) displayName = decrypted; + } + showSharedItemDetails(displayName, btn.dataset.type, plain); } catch (err) { showToast("Could not decrypt: " + err.message, "error"); } @@ -1564,6 +1583,14 @@ const Vault = (() => { item.plain, ); + // Encrypt the display name with the same ECDH shared key so the server + // never sees the plaintext name. item_name is set to item_type as a + // non-sensitive fallback label for legacy-compat (server requires the field). + const { enc_name, iv_name } = await SharingCrypto.encryptName( + sharedKey, + item.name, + ); + const res = await apiFetch("/api/sharing", { method: "POST", body: JSON.stringify({ @@ -1571,8 +1598,10 @@ const Vault = (() => { recipient_email: recipientEmail, enc_data, iv, - item_name: item.name, + item_name: item.item_type, // non-sensitive label; real name is in enc_name item_type: item.item_type, + enc_name, + iv_name, }), }); if (!res) return; @@ -4016,4 +4045,4 @@ const Vault = (() => { } })(); -document.addEventListener("DOMContentLoaded", Vault.init); \ No newline at end of file +document.addEventListener("DOMContentLoaded", Vault.init); diff --git a/extension/manifest.firefox.json b/extension/manifest.firefox.json index 02e8715..1ca0a48 100644 --- a/extension/manifest.firefox.json +++ b/extension/manifest.firefox.json @@ -36,6 +36,7 @@ "run_at": "document_idle" } ], + "web_accessible_resources": [], "commands": { "_execute_browser_action": { "suggested_key": { diff --git a/extension/manifest.json b/extension/manifest.json index e87070b..e238ba7 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -46,6 +46,12 @@ "run_at": "document_idle" } ], + "web_accessible_resources": [ + { + "resources": [], + "matches": [""] + } + ], "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", diff --git a/migrations/versions/g7h8i9j0k1l2_encrypt_shared_item_name.py b/migrations/versions/g7h8i9j0k1l2_encrypt_shared_item_name.py new file mode 100644 index 0000000..3ec0593 --- /dev/null +++ b/migrations/versions/g7h8i9j0k1l2_encrypt_shared_item_name.py @@ -0,0 +1,45 @@ +"""encrypt shared item name — add enc_name/iv_name to shared_items + +Revision ID: g7h8i9j0k1l2 +Revises: f6a7b8c9d0e1 +Create Date: 2026-05-18 00:00:00.000000 + +Prior to this migration, shared_items.item_name stored the plaintext display +name of the shared vault item (e.g. "Chase Bank"), leaking it to anyone with +DB read access and contradicting the zero-knowledge architecture. + +After this migration: + - enc_name : AES-256-GCM ciphertext of the item name, encrypted with the + ECDH shared secret (same key used for enc_data). Base64. + - iv_name : 12-byte GCM nonce for enc_name. Base64. + - item_name : Repurposed as a non-sensitive fallback label (item_type string, + e.g. "password"). Existing rows keep their current value; + new rows set item_name = item_type. + +Both new columns are nullable so existing rows are not affected before the +application writes enc_name/iv_name on the next share operation. +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'g7h8i9j0k1l2' +down_revision = 'f6a7b8c9d0e1' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'shared_items', + sa.Column('enc_name', sa.String(512), nullable=True), + ) + op.add_column( + 'shared_items', + sa.Column('iv_name', sa.String(64), nullable=True), + ) + + +def downgrade(): + op.drop_column('shared_items', 'iv_name') + op.drop_column('shared_items', 'enc_name')