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;