05/18 Enhanced codes and functionalities 4
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
+11
-6
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
+32
-3
@@ -1413,7 +1413,15 @@ const Vault = (() => {
|
||||
${
|
||||
!s.accepted
|
||||
? `<button class="btn-primary btn-sm" data-accept="${s.id}">Accept</button>`
|
||||
: `<button class="btn-secondary btn-sm" data-view-share="${s.id}" data-owner-key="${escHtml(s.owner_public_key || "")}" data-enc="${escHtml(s.enc_data)}" data-iv="${escHtml(s.iv)}" data-name="${escHtml(s.item_name)}" data-type="${escHtml(s.item_type || "")}">View</button>`
|
||||
: `<button class="btn-secondary btn-sm"
|
||||
data-view-share="${s.id}"
|
||||
data-owner-key="${escHtml(s.owner_public_key || "")}"
|
||||
data-enc="${escHtml(s.enc_data)}"
|
||||
data-iv="${escHtml(s.iv)}"
|
||||
data-enc-name="${escHtml(s.enc_name || "")}"
|
||||
data-iv-name="${escHtml(s.iv_name || "")}"
|
||||
data-name="${escHtml(s.item_name)}"
|
||||
data-type="${escHtml(s.item_type || "")}">View</button>`
|
||||
}
|
||||
</li>`,
|
||||
)
|
||||
@@ -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;
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [],
|
||||
"commands": {
|
||||
"_execute_browser_action": {
|
||||
"suggested_key": {
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": [],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user