From fba05f1ce570539b5cf8526cd457b22872fc93f1 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:47:36 +0100 Subject: [PATCH] fix(import): la restauration dupliquait l'historique, comme la sync Le meme defaut, par une autre porte : restaurer une sauvegarde par-dessus des entrees existantes passe aussi par encryptImportEntry puis PUT, donc re-chiffre un mot de passe inchange et fait croire au serveur qu'il a change. La sync etait la cause de ce qui se voyait, pas la seule. La regle est extraite en une fonction, keepCiphertextIfSamePassword, appelee par les deux. Les re-publications partielles - tag, dossier, migration des metadonnees - sont hors de cause : elles reutilisent deja le chiffre stocke. Co-Authored-By: Claude Opus 5 --- js/app.import.js | 31 ++++++++++++++++++++++++++++++- js/app.sync.js | 29 +++++------------------------ 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/js/app.import.js b/js/app.import.js index 057b395..4cdaf7f 100644 --- a/js/app.import.js +++ b/js/app.import.js @@ -448,6 +448,34 @@ function normalizeImportTimestamp(s) { return d.toISOString().slice(0, 19).replace('T', ' '); } +/// Rend à `enc` le chiffré d'origine quand le mot de passe n'a pas changé. +/// +/// POURQUOI CE N'EST PAS COSMÉTIQUE. Un conteneur — instantané de sync ou +/// sauvegarde — transporte le mot de passe EN CLAIR ; `encryptImportEntry` le +/// re-chiffre avec un IV neuf, donc le chiffré diffère TOUJOURS, même quand +/// rien n'a bougé. Or le serveur décide d'archiver une version d'historique en +/// comparant les chiffrés (`encrypted_password <> :newenc`) : il ne voit que du +/// chiffré et croit à un changement. Chaque synchronisation, chaque restauration +/// déposait donc un doublon daté du jour, et l'historique montrait la même +/// valeur plusieurs fois à des jours d'écart. +/// +/// Seul le client connaît les deux clairs. Il compare ici, et rend le chiffré +/// stocké quand ils sont égaux — ce que le commentaire du handler appelle déjà +/// « JS reuses originalEncrypted bit-for-bit ». Le garde du serveur redevient +/// exact sans qu'il ait à déchiffrer quoi que ce soit. +/// +/// À appeler AVANT tout PUT construit par encryptImportEntry sur une entrée qui +/// existe déjà. Les re-publications partielles (tag, dossier, migration) sont +/// hors de cause : elles réutilisent déjà le chiffré stocké. +async function keepCiphertextIfSamePassword(enc, local, plainPassword) { + if (!local || !local.encrypted_password || !local.iv) return enc; + const before = await decryptPwd(local.encrypted_password, local.iv); + if (before === '[ERROR]' || before !== (plainPassword || '')) return enc; + enc.encrypted_password = local.encrypted_password; + enc.iv = local.iv; + return enc; +} + async function encryptImportEntry(plain) { const pw = await encryptPwd(plain.password); let totpEnc = '', totpIv = ''; @@ -733,7 +761,8 @@ async function doImport() { try { const local = localByUuid.get(src.uuid); if (!local) continue; - const enc = await encryptImportEntry(src); + const enc = await keepCiphertextIfSamePassword( + await encryptImportEntry(src), local, src.password); await api('/entries/' + local.id, { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), diff --git a/js/app.sync.js b/js/app.sync.js index 8c9e020..1d36e95 100644 --- a/js/app.sync.js +++ b/js/app.sync.js @@ -539,30 +539,11 @@ async function applyRemoteSnapshot(remote) { const remoteWins = (r.updated_at || '') > (local.updated_at || ''); if (!remoteWins) continue; try { - const enc = await encryptImportEntry(r); - // LE MOT DE PASSE INCHANGÉ GARDE SON CHIFFRÉ, BIT POUR BIT. - // - // L'instantané transporte le clair, et encryptImportEntry le - // re-chiffre avec un IV neuf : le chiffré diffère donc TOUJOURS, - // même quand le mot de passe n'a pas bougé. Or le serveur décide - // d'écrire une version dans l'historique en comparant les - // chiffrés (`encrypted_password <> :newenc`) — il voyait une - // différence là où il n'y en avait aucune, et chaque - // synchronisation qui redescendait une entrée y déposait un - // doublon daté du jour. - // - // Le serveur ne peut pas trancher : il ne voit que du chiffré. - // Seul le client connaît les deux clairs. On compare ici, et on - // rend son chiffré d'origine quand ils sont égaux — ce que le - // commentaire du handler appelle déjà « JS reuses - // originalEncrypted bit-for-bit ». - if (local.encrypted_password && local.iv) { - const before = await decryptPwd(local.encrypted_password, local.iv); - if (before !== '[ERROR]' && before === (r.password || '')) { - enc.encrypted_password = local.encrypted_password; - enc.iv = local.iv; - } - } + // Le chiffré d'origine si le mot de passe n'a pas bougé : + // sans cela le serveur archive une version d'historique à + // chaque synchronisation. Voir keepCiphertextIfSamePassword. + const enc = await keepCiphertextIfSamePassword( + await encryptImportEntry(r), local, r.password); // PUT keeps the existing id but accepts the encrypted blobs. // template + uuid not touched here (server preserves columns // when fields aren't in body — uuid is immutable anyway).