diff --git a/app/routes/auth.py b/app/routes/auth.py index 64a475c..8618e32 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -575,7 +575,9 @@ def change_password(): current_auth_hash = data.get('current_auth_hash', '') new_auth_hash = data.get('new_auth_hash', '') new_enc_key_salt = data.get('new_enc_key_salt', '') - items = data.get('items', []) # [{id, enc_data, iv}, ...] + items = data.get('items', []) # [{id, enc_data, iv, enc_name?, iv_name?}, ...] + sharing_private_key_enc = data.get('sharing_private_key_enc', '') + sharing_private_key_iv = data.get('sharing_private_key_iv', '') if not current_auth_hash or not new_auth_hash or not new_enc_key_salt: return jsonify({'error': 'current_auth_hash, new_auth_hash, and new_enc_key_salt are required'}), 400 @@ -629,6 +631,11 @@ def change_password(): # Clear recovery data — it was encrypted with the old vault key and is now invalid user.recovery_enc_salt = None user.recovery_iv = None + # Re-encrypt sharing private key with new vault key if the client sent it. + # Without this update, the old ciphertext would be undecryptable after key rotation. + if sharing_private_key_enc and sharing_private_key_iv: + user.sharing_private_key_enc = sharing_private_key_enc + user.sharing_private_key_iv = sharing_private_key_iv AuditLog.log( user_id=user.id, @@ -976,7 +983,13 @@ def recovery_items(): items = VaultItem.query.filter_by(user_id=user.id).all() return jsonify({ 'items': [ - {'id': item.id, 'enc_data': item.enc_data, 'iv': item.iv} + { + 'id': item.id, + 'enc_data': item.enc_data, + 'iv': item.iv, + 'enc_name': item.enc_name, + 'iv_name': item.iv_name, + } for item in items ] }), 200 \ No newline at end of file diff --git a/app/static/js/recover.js b/app/static/js/recover.js index 52efcf9..b643678 100644 --- a/app/static/js/recover.js +++ b/app/static/js/recover.js @@ -339,11 +339,23 @@ const Recover = (() => { item.enc_data, item.iv, ); - const { enc_data, iv } = await Crypto.encryptItem( - newVaultKey, - plain, - ); - reEncryptedItems.push({ id: item.id, enc_data, iv }); + const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain); + let encNamePayload = {}; + if (item.enc_name && item.iv_name) { + const plainName = await Crypto.decryptName( + _oldVaultKey, + item.enc_name, + item.iv_name, + ); + if (plainName) { + const { enc_name, iv_name } = await Crypto.encryptName( + newVaultKey, + plainName, + ); + encNamePayload = { enc_name, iv_name }; + } + } + reEncryptedItems.push({ id: item.id, enc_data, iv, ...encNamePayload }); } catch { // Item decryption failed — skip (shouldn't happen if recovery code is correct) console.warn(`Could not re-encrypt item ${item.id}`); diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 61f977d..464fe7b 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -3323,6 +3323,48 @@ const Vault = (() => { reEncrypted.push({ id: item.id, enc_data, iv, ...encNamePayload }); } + // Re-encrypt sharing private key with new vault key so sharing stays functional. + // The private key is stored as AES-GCM ciphertext on the server; rotating the + // vault key without re-encrypting it would leave it permanently unreadable. + let sharingKeyPayload = {}; + try { + const sharingRes = await apiFetch("/api/sharing/keys"); + if (sharingRes && sharingRes.ok) { + const sharingData = await sharingRes.json(); + if ( + sharingData.keys_setup && + sharingData.private_key_enc && + sharingData.private_key_iv + ) { + const b64ToArr = (b64) => + Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const arrToB64 = (arr) => + btoa(String.fromCharCode(...new Uint8Array(arr))); + const privKeyBytes = await window.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: b64ToArr(sharingData.private_key_iv), + }, + vaultKey, + b64ToArr(sharingData.private_key_enc), + ); + const newPrivIv = window.crypto.getRandomValues(new Uint8Array(12)); + const reEncPriv = await window.crypto.subtle.encrypt( + { name: "AES-GCM", iv: newPrivIv }, + newVaultKey, + privKeyBytes, + ); + sharingKeyPayload = { + sharing_private_key_enc: arrToB64(reEncPriv), + sharing_private_key_iv: arrToB64(newPrivIv), + }; + } + } + } catch (err) { + console.warn("[PassKeeper] Could not re-encrypt sharing key:", err); + // Non-fatal — password change continues; user can regenerate sharing keys + } + // Submit atomic password change const res = await apiFetch("/api/auth/change-password", { method: "POST", @@ -3331,6 +3373,7 @@ const Vault = (() => { new_auth_hash: newAuthHash, new_enc_key_salt: newEncKeySalt, items: reEncrypted, + ...sharingKeyPayload, }), }); if (!res) return;