From d65fe909b4ed3de007558569704c6a4ebc19e349 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:22:08 +0100 Subject: [PATCH] feat(android): entries can be created on the phone A vault you cannot add to is not a vault, so an account created here was useless without this. A plus button next to the padlock, a form, and a kind - login or note, which the vault distinguishes and whose body is the password field either way. One save path now, two transports, and the rule that decides between them is: if a local container exists it is the authority. A vault born here has no repository; a vault pulled from one has no local container. The case where both exist - a local vault that later gains a repository - needs a merge, and that is section 7 step 4. The patch arrives as a function rather than an already-patched snapshot: the remote path must modify WHAT IT HAS JUST PULLED, or it would overwrite whatever another device published in between. That constraint is why the two transports could not simply share a finished payload. Two traps closed while passing: the form left the typed password sitting in the document after the save, and a refusal left its Save button dead with the text behind it. Verified on the device that the button and the form appear and read correctly. Saving was not exercised - the vault on this phone syncs to a real repository, and a test entry would have been published to it with no way to delete it from here yet. --- android-client/PMA.VaultPatch.pas | 55 ++++++++ android-client/web/app.html | 90 ++++++++++++ mainApk.pas | 221 ++++++++++++++++++++---------- 3 files changed, 295 insertions(+), 71 deletions(-) diff --git a/android-client/PMA.VaultPatch.pas b/android-client/PMA.VaultPatch.pas index 11cf28c..693a0d1 100644 --- a/android-client/PMA.VaultPatch.pas +++ b/android-client/PMA.VaultPatch.pas @@ -40,6 +40,17 @@ function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): strin /// naisse pas dans un dialecte a part. function NewEmptySnapshot(const AUsername: string): string; +/// Ajoute une entree a l'instantane et rend le JSON complet. +/// +/// LA MEME FORME QUE CE QU'ECRIT LE POSTE, cles vides comprises : une entree +/// nee ici sera relue la-bas, et une cle absente ne se comporte pas toujours +/// comme une cle vide. `folder` vaut 'All' parce que c'est le dossier par +/// defaut du poste, pas parce que c'est joli. +/// +/// Un uuid neuf, et lui seul : pas de tombstone, on ne supprime pas encore. +function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, + AKind: 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. @@ -75,6 +86,50 @@ begin end; end; +function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, + AKind: string): string; +var + LRoot, LEntry: TJSONObject; + LEntries: TJSONArray; + LNow: string; +begin + LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject; + if LRoot = nil then + raise EPMAPatch.Create('Snapshot is not a JSON object'); + try + LEntries := LRoot.GetValue('entries') as TJSONArray; + if LEntries = nil then + raise EPMAPatch.Create('Snapshot has no entries array'); + + LNow := NowUtcIso; + LEntry := TJSONObject.Create; + // Minuscules et sans accolades : la forme que tout le reste emploie. + LEntry.AddPair('uuid', LowerCase(Copy(GUIDToString(TGUID.NewGuid), 2, 36))); + LEntry.AddPair('title', ATitle); + LEntry.AddPair('site', ASite); + LEntry.AddPair('username', AUsername); + // Le corps d'une note EST son mot de passe cote stockage - il n'y a pas de + // colonne separee, ni ici ni sur le poste. + LEntry.AddPair('password', APassword); + LEntry.AddPair('folder', 'All'); + LEntry.AddPair('tags', TJSONArray.Create); + LEntry.AddPair('totp_secret', ''); + LEntry.AddPair('kind', AKind); + LEntry.AddPair('template', ''); + LEntry.AddPair('custom_fields', TJSONArray.Create); + LEntry.AddPair('attachments', TJSONArray.Create); + LEntry.AddPair('icon_b64', ''); + LEntry.AddPair('icon_custom', TJSONNumber.Create(0)); + LEntry.AddPair('created_at', LNow); + LEntry.AddPair('updated_at', LNow); + LEntries.AddElement(LEntry); + + Result := LRoot.ToJSON; + finally + LRoot.Free; + end; +end; + function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string; var LRoot, LEntry: TJSONObject; diff --git a/android-client/web/app.html b/android-client/web/app.html index 6c69892..b1a1c87 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -323,6 +323,12 @@ autocapitalize="none" spellcheck="false">
×
+ ' + + '' + + newField('nTitle', 'Name', 'text') + + newField('nSite', 'Site', 'text') + + newField('nUser', 'Username', 'text') + // EN CLAIR pendant la saisie, comme l'editeur : on tape un mot de passe + // qu'aucune confirmation ne rattrape et qu'on ne peut relire nulle part. + + newField('nPwd', 'Password', 'text') + + '
' + // flex:1 et non .wide : .wide ne regle que le rembourrage, et la largeur + // pleine vient d'etre etire dans son conteneur. + + '' + + '
'; + document.body.classList.remove('on-list'); + document.body.classList.add('on-detail'); + view = 'detail'; + send('state?view=detail'); +} + +// Un seul ecouteur pour le formulaire : ses boutons naissent et meurent avec +// lui, et en attacher a chaque ouverture les empilerait. +document.getElementById('fields').addEventListener('click', function (ev) { + var kind = ev.target.getAttribute('data-kind'); + if (kind) { + newKind = kind; + var all = document.querySelectorAll('[data-kind]'); + for (var i = 0; i < all.length; i++) + all[i].classList.toggle('main', all[i].getAttribute('data-kind') === kind); + // Le corps d'une note EST son mot de passe cote stockage : meme champ, + // autre intitule, et rien a masquer. + var caps = document.querySelectorAll('#fields .cap'); + for (i = 0; i < caps.length; i++) { + if (caps[i].textContent === 'Password' || caps[i].textContent === 'Content') + caps[i].textContent = (kind === 'note') ? 'Content' : 'Password'; + // Une note n'a ni site ni identifiant. Les laisser proposes revient a + // demander de remplir ce qui ne sera jamais affiche. + if (caps[i].textContent === 'Site' || caps[i].textContent === 'Username') + caps[i].parentNode.hidden = (kind === 'note'); + } + return; + } + if (ev.target.id !== 'nSave') return; + var v = function (id) { return document.getElementById(id).value.trim(); }; + ev.target.disabled = true; + send('newentry?t=' + encodeURIComponent(v('nTitle')) + + '&s=' + encodeURIComponent(v('nSite')) + + '&u=' + encodeURIComponent(v('nUser')) + + '&p=' + encodeURIComponent(document.getElementById('nPwd').value) + + '&k=' + newKind); +}); +document.getElementById('new').addEventListener('click', openNewEntry); + function goList() { document.body.classList.remove('on-detail'); document.body.classList.add('on-list'); @@ -710,6 +790,11 @@ var PM = { q.value = ''; query = ''; document.body.classList.remove('searching', 'on-detail'); document.body.classList.add('on-list'); + // LE FORMULAIRE PART AVEC LA VUE, pas seulement de l'ecran. Il garde le + // mot de passe qu'on vient de taper ; le laisser dans le document apres + // l'enregistrement le laisserait la jusqu'au verrouillage. + detailRows = []; + document.getElementById('fields').innerHTML = ''; view = 'list'; renderFolders(); renderRows(); @@ -719,6 +804,11 @@ var PM = { /// immediats : l'editeur y restait ouvert, boutons desactives, et Cancel /// lui-meme ne repondait plus. editDone: function () { + // Le formulaire de creation passe par ici aussi : son bouton se desactive + // a l'envoi, et sans cela un refus le laissait mort avec le texte saisi + // derriere - a retaper sans savoir pourquoi. + var save = document.getElementById('nSave'); + if (save) { save.disabled = false; return; } if (lastDetail) renderDetail(lastDetail); }, diff --git a/mainApk.pas b/mainApk.pas index 8d803b4..bcaa751 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -204,7 +204,12 @@ type procedure PatchSelfCheck; {$ENDIF} procedure OpenEntry(AVaultIndex: Integer); + procedure CommitChange(const ALabel, AComment, AReopenUuid: string; + const APatch: TFunc); procedure SaveEntryPassword(const ANewValue: string); + /// Ajoute une entree au coffre. Le mot de passe voyage par le pont, + /// comme 'setpwd' - meme regle, meme journalisation muette. + procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string); procedure ReopenEntryByUuid(const AUuid: string); procedure OnClipTimer(Sender: TObject); procedure OnDeadline(Sender: TObject); @@ -601,9 +606,20 @@ end; /// tout - y compris le dechiffrement, parce que le contenu a change. Trois /// essais : au-dela, c'est que l'autre appareil publie en boucle, et insister /// n'y changerait rien. -procedure TForm8.SaveEntryPassword(const ANewValue: string); -var - LUuid, LTitle: string; +/// ENREGISTRER UNE MODIFICATION - un seul chemin, deux transports. +/// +/// La regle qui tranche : SI UN CONTENEUR LOCAL EXISTE, C'EST LUI L'AUTORITE. +/// Un coffre ne ici n'a pas de depot ; un coffre tire d'un depot n'a pas de +/// conteneur local. Le cas ou les deux coexistent - un coffre local auquel on +/// ajoute ensuite un depot - demande une fusion, et c'est l'etape 4 de la §7. +/// +/// APatch recoit le clair et rend le clair modifie. Il ne peut pas etre +/// applique d'avance : le chemin distant doit modifier CE QU'IL VIENT DE +/// TIRER, sinon il ecraserait ce qu'un autre appareil a publie entre-temps. +/// +/// AReopenUuid vide = revenir a la liste plutot qu'a une entree. +procedure TForm8.CommitChange(const ALabel, AComment, AReopenUuid: string; + const APatch: TFunc); begin // CHAQUE REFUS REND LA MAIN A LA PAGE. Ces gardes se contentaient d'un // message : l'editeur restait ouvert avec ses boutons desactives, donc @@ -614,20 +630,12 @@ begin WebCall('PM.editDone()'); Exit; end; - if FCurrent.Uuid = '' then - begin - WebCall('PM.toast("This entry has no uuid - cannot be updated")'); - WebCall('PM.editDone()'); - Exit; - end; if FSyncPwd = '' then begin - WebCall('PM.toast("Sync password missing - open the vault first")'); + WebCall('PM.toast("Master password missing - open the vault first")'); WebCall('PM.editDone()'); Exit; end; - LUuid := FCurrent.Uuid; - LTitle := FCurrent.Display; SetBusy(True); TThread.CreateAnonymousThread( @@ -641,88 +649,115 @@ begin LSw: TStopwatch; LDone: Boolean; begin - LClient := TSyncClient.Create(FUrl, FToken); LDone := False; LErr := ''; + LClient := nil; try try - for LAttempt := 1 to 3 do + if HasLocalContainer then begin - Log(Format('save: attempt %d - GET', [LAttempt])); - // Sans empreinte connue : on VEUT le corps, pas un 304. - LGet := LClient.Fetch; - if LGet.Status <> ssOK then - begin - LErr := Format('read failed (HTTP %d) %s', [LGet.HttpCode, LGet.Error]); - Break; - end; - - try - // UNE SEULE DERIVATION PAR ENREGISTREMENT : la variante Ex rend la - // cle, son sel et les iterations, que le rechiffrement reutilise. - // Deriver deux fois le meme mot de passe coutait une seconde fois - // quatre a six secondes, pour une cle equivalente. - LSw := TStopwatch.StartNew; - LPlain := DecryptContainerEx(LGet.Container, FSyncPwd, - LKey, LSalt, LIter); - LNew := PatchEntryField(LPlain, LUuid, 'password', ANewValue); - Log(Format('save: decrypt+patch in %d ms', [LSw.ElapsedMilliseconds])); - except - on E: Exception do - begin - LErr := E.Message; - Break; - end; - end; - + // COFFRE LOCAL : rien a tirer, rien a publier, aucun conflit possible + // - il n'y a qu'un ecrivain. Le conteneur est reecrit d'abord, le + // cache ensuite : l'inverse laisserait un cache en avance sur + // l'autorite si l'ecriture echouait entre les deux. + LSw := TStopwatch.StartNew; + LPlain := DecryptContainerEx(LoadLocalContainer, FSyncPwd, + LKey, LSalt, LIter); + LNew := APatch(LPlain); + Log(Format('save: decrypt+patch in %d ms', [LSw.ElapsedMilliseconds])); LSw := TStopwatch.StartNew; LBody := EncryptContainerWithKey(LNew, LKey, LSalt, LIter); Log(Format('save: re-encrypt in %d ms', [LSw.ElapsedMilliseconds])); - LPut := LClient.Put(LBody, LGet.ETag, 'password change from phone'); - if LPut.Status = ssOK then + SaveLocalContainer(LBody); + try + SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False, FPin); + except + on E: Exception do Log(' cache not written: ' + E.Message); + end; + Log('save: written locally'); + LDone := True; + end + else + begin + LClient := TSyncClient.Create(FUrl, FToken); + for LAttempt := 1 to 3 do begin - Log('save: published, new sha ' + LPut.ETag); - // Le cache local suit le distant, avec SON empreinte : les laisser - // diverger ferait retelecharger et redechiffrer a la prochaine - // ouverture pour retrouver ce qu'on vient d'ecrire. - FETag := LPut.ETag; - try - SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False, FPin); - except - on E: Exception do Log(' cache not written: ' + E.Message); + Log(Format('save: attempt %d - GET', [LAttempt])); + // Sans empreinte connue : on VEUT le corps, pas un 304. + LGet := LClient.Fetch; + if LGet.Status <> ssOK then + begin + LErr := Format('read failed (HTTP %d) %s', [LGet.HttpCode, LGet.Error]); + Break; end; - LDone := True; + + try + // UNE SEULE DERIVATION PAR ENREGISTREMENT : la variante Ex rend + // la cle, son sel et les iterations, que le rechiffrement + // reutilise. Deriver deux fois le meme mot de passe coutait une + // seconde fois quatre a six secondes, pour une cle equivalente. + LSw := TStopwatch.StartNew; + LPlain := DecryptContainerEx(LGet.Container, FSyncPwd, + LKey, LSalt, LIter); + LNew := APatch(LPlain); + Log(Format('save: decrypt+patch in %d ms', [LSw.ElapsedMilliseconds])); + except + on E: Exception do + begin + LErr := E.Message; + Break; + end; + end; + + LSw := TStopwatch.StartNew; + LBody := EncryptContainerWithKey(LNew, LKey, LSalt, LIter); + Log(Format('save: re-encrypt in %d ms', [LSw.ElapsedMilliseconds])); + LPut := LClient.Put(LBody, LGet.ETag, AComment); + if LPut.Status = ssOK then + begin + Log('save: published, new sha ' + LPut.ETag); + // Le cache local suit le distant, avec SON empreinte : les + // laisser diverger ferait retelecharger et redechiffrer a la + // prochaine ouverture pour retrouver ce qu'on vient d'ecrire. + FETag := LPut.ETag; + try + SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False, FPin); + except + on E: Exception do Log(' cache not written: ' + E.Message); + end; + LDone := True; + Break; + end; + + if LPut.Status = ssConflict then + begin + Log('save: 409 - another device published, re-reading'); + Continue; + end; + + LErr := Format('write failed (HTTP %d) %s', [LPut.HttpCode, LPut.Error]); Break; end; - if LPut.Status = ssConflict then - begin - Log('save: 409 - another device published, re-reading'); - Continue; - end; - - LErr := Format('write failed (HTTP %d) %s', [LPut.HttpCode, LPut.Error]); - Break; + if (not LDone) and (LErr = '') then + LErr := 'the remote kept changing - try again'; end; - if (not LDone) and (LErr = '') then - LErr := 'the remote kept changing - try again'; - TThread.Queue(nil, procedure begin if LDone then begin AdoptVault(LNew); - WebCall('PM.toast(' + JsArg('Saved: ' + LTitle) + ')'); + WebCall('PM.toast(' + JsArg(ALabel) + ')'); end else WebCall('PM.toast(' + JsArg('Not saved - ' + LErr) + ')'); end); except - // FILET. Le rechiffrement et le PUT sont hors du try qui - // couvre le dechiffrement : une exception y sortait sans un mot, - // et l'echec devenait indiscernable d'une lenteur. + // FILET. Le rechiffrement et le PUT sont hors du try qui couvre le + // dechiffrement : une exception y sortait sans un mot, et l'echec + // devenait indiscernable d'une lenteur. on E: Exception do begin LErr := E.ClassName + ': ' + E.Message; @@ -735,20 +770,57 @@ begin LClient.Free; // LA REMISE EN ETAT EST ICI, PAS DANS LE CHEMIN NOMINAL. Elle etait // apres la boucle : une exception au rechiffrement ou au PUT la - // sautait, et la ligne restait sur « Saving... » pour toujours - sans + // sautait, et la ligne restait sur "Saving..." pour toujours - sans // message, puisque le message aussi etait dans la partie sautee. // Quoi qu'il arrive au-dessus, l'ecran redevient utilisable. TThread.Queue(nil, procedure begin SetBusy(False); - ReopenEntryByUuid(LUuid); + if AReopenUuid <> '' then ReopenEntryByUuid(AReopenUuid) + // Rien a rouvrir : c'est une creation. La page doit quand meme + // reprendre la main, sinon un echec laisse son bouton mort. + else WebCall('PM.editDone()'); end); end; end).Start; end; -/// Descend les coordonnees du serveur dans les champs de la page. +procedure TForm8.SaveEntryPassword(const ANewValue: string); +var + LUuid, LTitle: string; +begin + if FCurrent.Uuid = '' then + begin + WebCall('PM.toast("This entry has no uuid - cannot be updated")'); + WebCall('PM.editDone()'); + Exit; + end; + LUuid := FCurrent.Uuid; + LTitle := FCurrent.Display; + CommitChange('Saved: ' + LTitle, 'password change from phone', LUuid, + function (APlain: string): string + begin + Result := PatchEntryField(APlain, LUuid, 'password', ANewValue); + end); +end; + +procedure TForm8.CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string); +begin + // Une entree sans rien pour la reconnaitre serait une ligne muette dans la + // liste, et rien ne permettrait de la retrouver pour la corriger. + if (ATitle = '') and (ASite = '') and (AUser = '') then + begin + WebCall('PM.toast("Give it at least a name")'); + Exit; + end; + CommitChange('Added: ' + ATitle, 'new entry from phone', '', + function (APlain: string): string + begin + Result := AddEntry(APlain, ATitle, ASite, AUser, APwd, AKind); + end); +end; + procedure TForm8.PushConfig; var LUrl, LToken: string; @@ -886,6 +958,13 @@ begin // La NOUVELLE valeur remonte par le pont - c'est la seule commande qui // transporte un secret. Elle ne quitte pas le processus, l'iframe qui la // porte est detruite, et HandleWebCommand ne journalise que l'action. + else if LAction = 'newentry' then + CreateEntry(TNetEncoding.URL.Decode(Param('t')), + TNetEncoding.URL.Decode(Param('s')), + TNetEncoding.URL.Decode(Param('u')), + TNetEncoding.URL.Decode(Param('p')), + TNetEncoding.URL.Decode(Param('k'))) + else if LAction = 'setpwd' then SaveEntryPassword(TNetEncoding.URL.Decode(Param('v')))