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')))