diff --git a/android-client/PMA.VaultPatch.pas b/android-client/PMA.VaultPatch.pas new file mode 100644 index 0000000..f0224af --- /dev/null +++ b/android-client/PMA.VaultPatch.pas @@ -0,0 +1,100 @@ +unit PMA.VaultPatch; +{ + Modifier UN champ d'UNE entree dans l'instantane, sans toucher au reste. + + LA CONTRAINTE QUI GOUVERNE CETTE UNITE. TVault ne lit qu'une partie des + champs : il ignore `content_b64` des pieces jointes, `created_at`, l'avatar du + compte, les tombstones, et tout ce que le poste ajoutera demain. Reserialiser + le coffre a partir de ce qu'il a compris EFFACERAIT tout cela - et le poste + adopterait cette version amputee au tirage suivant, la fusion etant un + dernier-ecrit-gagne sur `updated_at`. La perte serait silencieuse et + definitive. + + Donc on ne reconstruit rien : on prend le JSON RECU, on change une valeur + dedans, et on rend le reste tel quel. Ce que le telephone ne comprend pas + voyage intact, par construction plutot que par vigilance. + + COROLLAIRE SUR LA FUSION. Comme le patch s'applique a l'instantane qu'on + vient de tirer, il n'y a pas de fusion a ecrire : ce qui n'est pas touche est + deja la version la plus recente du serveur. Le seul conflit possible est + qu'un autre appareil publie entre notre lecture et notre ecriture, et Gitea + le refuse par le sha (409) - il suffit alors de recommencer. +} + +interface + +uses + System.SysUtils, System.JSON, System.DateUtils; + +type + EPMAPatch = class(Exception); + +/// Remplace AField de l'entree AUuid par AValue, et bump `updated_at`. +/// Rend le JSON complet, tout le reste inchange. Leve si l'entree est absente - +/// ce qui veut dire que le distant ne la contient pas (supprimee ailleurs), et +/// ecrire quand meme la ferait reapparaitre. +function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string; + +/// Horodatage UTC au format que tout le reste emploie (toISOString cote JS). +/// MELANGER heure locale et UTC a deja decale l'arbitrage de la fusion une +/// fois : il n'y a qu'une source d'heure ici, et elle est en UTC. +function NowUtcIso: string; + +implementation + +function NowUtcIso: string; +begin + Result := FormatDateTime('yyyy-mm-dd"T"hh:nn:ss.zzz"Z"', + TTimeZone.Local.ToUniversalTime(Now)); +end; + +function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string; +var + LRoot, LEntry: TJSONObject; + LEntries: TJSONArray; + I: Integer; + LFound: Boolean; +begin + if AUuid = '' then + raise EPMAPatch.Create('Entree sans uuid : rien a retrouver chez le distant'); + + LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject; + if LRoot = nil then + raise EPMAPatch.Create('Instantane illisible'); + try + if not LRoot.TryGetValue('entries', LEntries) then + raise EPMAPatch.Create('Instantane sans tableau "entries"'); + + LFound := False; + 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; + + // RemovePair puis AddPair : AddPair seul ajouterait un DOUBLON, et le + // lecteur prendrait le premier - donc l'ancienne valeur. + LEntry.RemovePair(AField).Free; + LEntry.AddPair(AField, AValue); + + // Sans ce bump, la modification voyage mais les autres appareils + // l'ignorent : leur arbitrage compare `updated_at`, et le notre serait + // reste plus ancien que le leur. + LEntry.RemovePair('updated_at').Free; + LEntry.AddPair('updated_at', NowUtcIso); + + LFound := True; + Break; + end; + + if not LFound then + raise EPMAPatch.CreateFmt( + 'Entree %s absente du distant - supprimee ailleurs ?', [AUuid]); + + Result := LRoot.ToJSON; + finally + LRoot.Free; + end; +end; + +end. diff --git a/mainApk.pas b/mainApk.pas index 1008254..d7b6a15 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -187,6 +187,7 @@ type procedure PushConfig; {$IFDEF DEBUG} procedure CryptoSelfCheck; + procedure PatchSelfCheck; procedure WriteTest; {$ENDIF} procedure OpenEntry(AVaultIndex: Integer); @@ -212,7 +213,7 @@ implementation uses PMA.Crypto, PMA.SyncClient, PMA.Store, PMA.Biometric, PMA.Totp, - PMA.ScreenGuard, PMA.Clipboard, PMA.Theme, PMA.WebUI, System.JSON; + PMA.ScreenGuard, PMA.Clipboard, PMA.Theme, PMA.WebUI, PMA.VaultPatch, System.JSON; {$R *.fmx} @@ -468,12 +469,58 @@ begin System.IOUtils.TPath.GetHomePath, 'selfcheck.json'); TFile.WriteAllText(LPath, LContainer, TEncoding.UTF8); Log('selfcheck: container written for cross-check'); + PatchSelfCheck; except on E: Exception do Log('selfcheck FAILED: ' + E.ClassName + ' - ' + E.Message); end; end; +/// Verifie ce que le patch PRESERVE, pas seulement ce qu'il change. +/// +/// C'est l'invariant de ยง4 : un champ que le telephone ne comprend pas doit +/// ressortir intact, sinon une modification faite ici efface silencieusement +/// des donnees chez tous les autres appareils. On le controle sur un champ +/// inconnu de TVault et sur une piece jointe, qui sont precisement les deux +/// que la reserialisation naive perdrait. +procedure TForm8.PatchSelfCheck; +const + SNAP = + '{"version":1,"username":"test","folders":[{"name":"f"}],' + + '"entries":[' + + '{"uuid":"u1","title":"a","password":"old","updated_at":"2020-01-01T00:00:00Z",' + + '"attachments":[{"filename":"x.pdf","content_b64":"KEEPME"}],' + + '"champ_inconnu":"KEEPTOO"},' + + '{"uuid":"u2","title":"b","password":"other"}],' + + '"tombstones":[{"uuid":"u9"}]}'; +var + LOut: string; + LRoot: TJSONObject; + LEntry: TJSONObject; + LOk: Boolean; +begin + try + LOut := PatchEntryField(SNAP, 'u1', 'password', 'NEW'); + LRoot := TJSONObject.ParseJSONValue(LOut) as TJSONObject; + try + LEntry := (LRoot.GetValue('entries').Items[0]) as TJSONObject; + LOk := (LEntry.GetValue('password') = 'NEW') + and (LEntry.GetValue('updated_at') <> '2020-01-01T00:00:00Z') + and (LEntry.GetValue('champ_inconnu') = 'KEEPTOO') + and (LOut.Contains('KEEPME')) // piece jointe intacte + and (LOut.Contains('"tombstones"')) // et le reste du document + and (LOut.Contains('"other"')); // l'autre entree non touchee + if LOk then Log('selfcheck: patch keeps what it does not understand') + else Log('selfcheck: PATCH LOSES DATA - do not enable writing'); + finally + LRoot.Free; + end; + except + on E: Exception do + Log('selfcheck patch FAILED: ' + E.ClassName + ' - ' + E.Message); + end; +end; + /// Essai du chemin d'ecriture, VERS UN AUTRE FICHIER que le coffre. /// /// Le premier PUT est le moment ou une erreur de transport se paie en donnees diff --git a/pmserverApk.dpr b/pmserverApk.dpr index 2614b01..963482f 100644 --- a/pmserverApk.dpr +++ b/pmserverApk.dpr @@ -14,7 +14,8 @@ uses PMA.ScreenGuard in 'android-client\PMA.ScreenGuard.pas', PMA.Clipboard in 'android-client\PMA.Clipboard.pas', PMA.Theme in 'android-client\PMA.Theme.pas', - PMA.WebUI in 'android-client\PMA.WebUI.pas'; + PMA.WebUI in 'android-client\PMA.WebUI.pas', + PMA.VaultPatch in 'android-client\PMA.VaultPatch.pas'; {$R *.res}