From ba4ab18d0f3089f2bb43058f2f2b34d5b8b04575 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:14:04 +0100 Subject: [PATCH] feat: the last three passwords travel, and the phone can show them The desktop keeps twenty previous versions per entry and never sent any. It now sends three. Three, not twenty, because the trade is not symmetrical. On the desktop they sit encrypted column by column and never move. In the snapshot everything travels in the clear inside the container, and comes down into a phone cache sealed only by the device key - not by the master password. An old password stays sensitive: it is often still valid somewhere else. The real use of the list is short - "I changed it recently, what was it?" - and three cover it. The other seventeen would be copies with no need facing them. The phone reads them ON DEMAND from the raw snapshot, never into TVault: loading them with the vault would put every entry's old passwords in the clear in memory from the moment it opens, for something one looks at now and then. Copying goes by RANK, not by value. The page displays the text but never sends it back - that is the bridge's rule everywhere else, and an old password has no reason to be the exception just because it happens to be on screen. They leave with the plaintext at lock, on both sides. --- android-client/PMA.VaultPatch.pas | 47 +++++++++++++++++++ android-client/TESTS.md | 23 ++++++++-- android-client/web/app.html | 57 ++++++++++++++++++++++- js/app.sync.js | 28 ++++++++++++ mainApk.pas | 75 +++++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 5 deletions(-) diff --git a/android-client/PMA.VaultPatch.pas b/android-client/PMA.VaultPatch.pas index 1353898..22f6c03 100644 --- a/android-client/PMA.VaultPatch.pas +++ b/android-client/PMA.VaultPatch.pas @@ -99,6 +99,19 @@ function AttachmentContent(const APlainJson, AUuid: string; function PatchEntryIcon(const APlainJson, AUuid, AUri: string; ACustom: Boolean): string; +/// Les anciens mots de passe d'une entree, relus dans l'instantane BRUT. +/// +/// A LA DEMANDE, ET JAMAIS DANS TVault. Les charger avec le coffre les +/// mettrait en clair en memoire pour TOUTES les entrees des l'ouverture, alors +/// qu'on en regarde une de temps en temps. Meme raison que pour les pieces +/// jointes, mais pour l'exposition plutot que pour le poids : un ancien mot de +/// passe est souvent encore valide ailleurs. +/// +/// Rend une ligne par version, la plus recente d'abord, sous la forme +/// `datemot de passe`. Vide s'il n'y en a pas - le poste ne l'envoie que +/// depuis peu, et un instantane plus ancien n'en porte aucun. +function PasswordHistory(const APlainJson, AUuid: string): TArray; + /// Fond l'instantane LOCAL dans l'instantane DISTANT et rend le resultat. /// /// LE DISTANT SERT DE BASE, jamais le local. Il porte ce que le telephone ne @@ -309,6 +322,40 @@ begin end; end; +function PasswordHistory(const APlainJson, AUuid: string): TArray; +var + LRoot, LEntry, LRow: TJSONObject; + LEntries, LHist: TJSONArray; + I, J: Integer; +begin + Result := nil; + if AUuid = '' then Exit; + LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject; + if LRoot = nil then Exit; + try + if not LRoot.TryGetValue('entries', LEntries) then Exit; + for I := 0 to LEntries.Count - 1 do + begin + if not (LEntries.Items[I] is TJSONObject) then Continue; + LEntry := LEntries.Items[I] as TJSONObject; + if LEntry.GetValue('uuid', '') <> AUuid then Continue; + if not LEntry.TryGetValue('password_history', LHist) then Exit; + // J, PAS I : la boucle exterieure cherche encore l'entree, et Delphi + // refuse - a juste titre - qu'on lui reecrive son compteur. + for J := 0 to LHist.Count - 1 do + begin + if not (LHist.Items[J] is TJSONObject) then Continue; + LRow := LHist.Items[J] as TJSONObject; + Result := Result + [LRow.GetValue('changed_at', '') + #9 + + LRow.GetValue('password', '')]; + end; + Exit; + end; + finally + LRoot.Free; + end; +end; + function PatchEntryIcon(const APlainJson, AUuid, AUri: string; ACustom: Boolean): string; var diff --git a/android-client/TESTS.md b/android-client/TESTS.md index fbe1120..8ed78ed 100644 --- a/android-client/TESTS.md +++ b/android-client/TESTS.md @@ -154,12 +154,29 @@ prouve rien. **pas** la traiter comme une icône choisie — c'est le rôle du drapeau de provenance, mis à zéro par une recherche automatique. -### H. Pièces jointes +### H. Anciens mots de passe + +**Le poste doit synchroniser d'abord** : il ne les envoie que depuis ce soir, +et un instantané publié avant ne porte aucun historique. + +1. Sur le poste, changer le mot de passe d'une entrée, puis synchroniser. +2. Téléphone : *Sync*, ouvrir l'entrée, trois points → **Previous passwords**. + L'ancienne version doit être là, avec sa date. +3. Copier l'une d'elles → le presse-papiers doit la porter, et le message dire + *« Previous password copied »*. +4. Touche **retour** → le voile se referme sans toucher au coffre. +5. Une entrée jamais modifiée → *« No previous password recorded »*, et non un + écran vide. Un instantané d'avant ce soir donne le même message : c'est + volontaire, et le libellé le dit plutôt que de laisser chercher une panne. +6. Contre-épreuve : verrouiller, rouvrir, **Previous passwords** → la liste + doit être reconstruite, jamais celle d'avant restée à l'écran. + +### I. Pièces jointes 1. Ligne *Attachment* → flèche de téléchargement → le fichier apparaît dans *Téléchargements* sous son nom, accents et arabe compris. -### I. Code de déverrouillage +### J. Code de déverrouillage 1. Poser un code, ouvrir le coffre, fermer, rouvrir → il est demandé. 2. Code faux → *« Wrong unlock code »*, et **Open vault reste offert** (le @@ -169,7 +186,7 @@ prouve rien. 4. Contre-épreuve : taper n'importe quoi dans le champ du code puis *Sync* → le code **ne doit pas changer**. -### J. Empaquetage +### K. Empaquetage 1. `build-apk.ps1 -Release -Keystore ... -Alias ...` → la sortie doit afficher `Signer #1 certificate DN: CN=R Zakarya`. Si elle affiche `CN=Android diff --git a/android-client/web/app.html b/android-client/web/app.html index 775400a..81240b9 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -312,7 +312,7 @@ .sheet { position: fixed; inset: 0; z-index: 80; display: none; flex-direction: column; background: linear-gradient(var(--bg-top), var(--bg-bot)); } - body.on-settings .sheet { display: flex; } + body.on-settings #settings, body.on-history #history { display: flex; } .sheet h2 { margin: 18px 4px 6px; font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; } @@ -550,6 +550,18 @@ + +
+
+ +

Previous passwords

+
+
+
+
@@ -1260,7 +1272,8 @@ function closeMenu() { /// un menu qui apparait loin du doigt ne se rattache a rien. function openMenu() { var m = document.getElementById('menu'); - m.innerHTML = '' + m.innerHTML = '' + + '' + '' + '' + ''; @@ -1276,6 +1289,11 @@ document.getElementById('kebab').addEventListener('click', function (ev) { function onMenuClick(ev) { var b = ev.target.closest('button'); if (!b) return; + if (b.getAttribute('data-hist') !== null) { + closeMenu(); + send('history'); + return; + } var ic = b.getAttribute('data-icon'); if (ic !== null) { closeMenu(); @@ -1429,6 +1447,23 @@ function closeSettings() { } document.getElementById('setBack').addEventListener('click', closeSettings); + +function closeHistory() { + // Le clair part avec la vue, pas seulement de l'ecran. + document.getElementById('histBody').innerHTML = ''; + document.body.classList.remove('on-history'); + send('state?view=' + view); +} +document.getElementById('histBack').addEventListener('click', closeHistory); +document.getElementById('histBody').addEventListener('click', function (ev) { + var b = ev.target.closest('[data-hcopy]'); + if (!b) return; + // UN RANG, JAMAIS UNE VALEUR. C'est la regle du pont partout ailleurs - + // depuis la liste, un indice de coffre ; depuis le detail, un rang de ligne - + // et il n'y a pas de raison qu'un ancien mot de passe fasse exception parce + // qu'il se trouve deja affiche. + send('copyhist?k=' + b.getAttribute('data-hcopy')); +}); document.getElementById('sLog').addEventListener('click', function () { closeSettings(); if (document.getElementById('log').hidden) togglePanel('log'); @@ -1636,6 +1671,21 @@ var PM = { document.getElementById('lock').classList.toggle('ahead', !!on); document.getElementById('btnSync').classList.toggle('ahead', !!on); }, + /// Les anciennes versions descendent ICI et nulle part ailleurs : une seule + /// entree, sur demande. PM.wipe les efface avec le reste du clair. + showHistory: function (json) { + var rows = JSON.parse(json), html = '', i; + for (i = 0; i < rows.length; i++) + html += '
' + + esc(rows[i].at.replace('T', ' ').slice(0, 16)) + '
' + + '
' + + esc(rows[i].pwd) + '
' + + '
'; + document.getElementById('histBody').innerHTML = html; + document.body.classList.add('on-history'); + send('state?view=history'); + }, setVersion: function (v) { document.getElementById('sVersion').textContent = v; }, @@ -1649,6 +1699,7 @@ var PM = { /// Sans cela, un geste d'annulation fermait le coffre - le contraire de ce /// qu'il veut dire. clearSelection: function () { clearSel(); }, + closeHistory: function () { closeHistory(); }, /// La touche retour referme les reglages avant de toucher au coffre. closeSettings: function () { closeSettings(); }, // Efface tout ce qui est en clair. Appele au passage en arriere-plan et au @@ -1658,6 +1709,8 @@ var PM = { document.getElementById('rows').innerHTML = ''; document.getElementById('fields').innerHTML = ''; document.getElementById('folders').innerHTML = ''; + document.getElementById('histBody').innerHTML = ''; + document.body.classList.remove('on-history', 'on-settings'); q.value = ''; document.body.classList.remove('on-list', 'on-detail', 'searching'); view = 'unlock'; diff --git a/js/app.sync.js b/js/app.sync.js index 6f83419..6fbdc4a 100644 --- a/js/app.sync.js +++ b/js/app.sync.js @@ -303,6 +303,29 @@ async function buildSyncSnapshot() { e.custom_fields, e.custom_fields_iv); } catch (_) {} } + // TROIS ANCIENNES VERSIONS, PAS VINGT. + // + // La table en garde vingt, et c'est bien sur le poste : elles y restent + // chiffrees colonne par colonne et ne bougent jamais. Dans l'instantane + // tout voyage EN CLAIR a l'interieur du conteneur, et redescend dans le + // cache d'un telephone que seule la cle de l'appareil scelle - pas le + // mot de passe maitre. Un ancien mot de passe reste sensible : il est + // souvent encore valide ailleurs. + // + // L'usage reel de cette liste est court - "je l'ai change recemment, + // quel etait l'ancien ?" - et trois le couvrent. Les dix-sept autres + // seraient des copies supplementaires sans besoin en face. + let history = []; + try { + const hist = await api('/entries/' + e.id + '/history', + { headers: authHeaders() }); + for (const h of (hist || []).slice(0, 3)) { + const old = await decryptPwd(h.encrypted_password, h.iv); + if (old === '[ERROR]') continue; + history.push({ password: old, changed_at: h.changed_at }); + } + } catch (_) {} + let attachments = []; try { const metas = await api('/entries/' + e.id + '/attachments', @@ -339,6 +362,11 @@ async function buildSyncSnapshot() { icon_custom: e.icon_custom ? 1 : 0, created_at: e.created_at, updated_at: e.updated_at, + // Lecture seule pour les autres appareils : la table du poste est + // la source, et applyRemoteSnapshot ignore ce champ. Il survit aux + // ecritures du telephone parce que celui-ci modifie le JSON recu + // sans jamais le reserialiser. + password_history: history, }); } try { diff --git a/mainApk.pas b/mainApk.pas index 6903852..71c8687 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -154,6 +154,10 @@ type /// Elle part au verrouillage, avec lui. FKey, FKeySalt: TBytes; FKeyIters: Integer; + /// LES ANCIENNES VERSIONS QUI VIENNENT D'ETRE MONTREES, pour que la copie + /// se demande par un rang. La page en affiche le texte ; elle ne le renvoie + /// jamais - c'est la regle du pont partout ailleurs. + FHistory: TArray; /// Des modifications sont rangees ici sans avoir atteint le depot. FPending: Boolean; /// Le depot porte quelque chose que nous n'avons pas. @@ -308,6 +312,8 @@ type procedure ClearUnlockCode; /// Va chercher l'icone du site de l'entree ouverte, ou l'efface. procedure SetEntryIcon(AFetch: Boolean); + /// Envoie a la page les anciens mots de passe de l'entree ouverte. + procedure ShowPasswordHistory; procedure ReopenEntryByUuid(const AUuid: string); procedure OnClipTimer(Sender: TObject); procedure OnDeadline(Sender: TObject); @@ -536,6 +542,15 @@ begin end; // C'est la PAGE qui sait ce qu'elle affiche - Delphi ne le devine pas. Elle // le declare a chaque changement de vue (cmd://state). + // L'HISTORIQUE SE REFERME D'ABORD, comme les reglages : un voile pose + // par-dessus, et la touche retour le retire sans toucher a ce qu'il couvre. + if FWebView = 'history' then + begin + WebCall('PM.closeHistory()'); + Key := 0; + Exit; + end; + // LES REGLAGES SE REFERMENT D'ABORD. Ils sont une parenthese posee par-dessus // le reste : la touche retour la ferme, elle ne saute pas ce qu'il y a // derriere. @@ -1621,6 +1636,21 @@ begin TNetEncoding.URL.Decode(Param('k')), TNetEncoding.URL.Decode(Param('d'))) + else if LAction = 'history' then + ShowPasswordHistory + + else if LAction = 'copyhist' then + begin + LIdx := StrToIntDef(Param('k'), -1); + if (LIdx >= 0) and (LIdx <= High(FHistory)) then + begin + LValue := FHistory[LIdx]; + LIdx := Pos(#9, LValue); + if LIdx > 0 then + CopyToClipboard(Copy(LValue, LIdx + 1, MaxInt), 'Previous password'); + end; + end + else if LAction = 'icon' then SetEntryIcon(Param('a') = 'fetch') @@ -1706,6 +1736,7 @@ begin FKeySalt := nil; FKeyIters := 0; FPlain := ''; + FHistory := nil; // Le code aussi : le retenir apres un verrouillage reviendrait a ne pas // verrouiller. Le garde de SaveVault protege le cache d'ici la. FPinSealed := ''; @@ -2560,6 +2591,50 @@ end; /// La recherche est hors du thread principal : cinq secondes d'attente /// figeraient l'ecran. L'ecriture, elle, repasse par CommitChange - donc par /// le meme chemin que tout le reste, avec sa publication et son hors-ligne. +/// LES ANCIENS MOTS DE PASSE DE L'ENTREE OUVERTE. +/// +/// Ils ne descendent vers la page qu'ici, sur demande, et pour une seule +/// entree. La page les jette au verrouillage comme le reste du clair. +procedure TForm8.ShowPasswordHistory; +var + LRows: TArray; + LArr: TJSONArray; + LObj: TJSONObject; + LTab: Integer; + LLine: string; +begin + if FCurrent.Uuid = '' then Exit; + if FPlain = '' then + begin + WebCall('PM.toast("Open the vault again to read the history")'); + Exit; + end; + LRows := PasswordHistory(FPlain, FCurrent.Uuid); + FHistory := LRows; + if Length(LRows) = 0 then + begin + // DIRE LAQUELLE DES DEUX. Une entree jamais modifiee n'a pas d'historique ; + // un instantane publie par un poste plus ancien n'en porte pour aucune. Les + // confondre ferait chercher une panne la ou il n'y a rien a trouver. + WebCall('PM.toast("No previous password recorded for this entry")'); + Exit; + end; + LArr := TJSONArray.Create; + try + for LLine in LRows do + begin + LTab := Pos(#9, LLine); + LObj := TJSONObject.Create; + LObj.AddPair('at', Copy(LLine, 1, LTab - 1)); + LObj.AddPair('pwd', Copy(LLine, LTab + 1, MaxInt)); + LArr.AddElement(LObj); + end; + WebCall('PM.showHistory(' + JsArg(LArr.ToJSON) + ')'); + finally + LArr.Free; + end; +end; + procedure TForm8.SetEntryIcon(AFetch: Boolean); var LUuid, LSite, LTitle: string;