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">
×
+
+
+
+
+
@@ -689,6 +695,80 @@ addEventListener('keydown', function (ev) {
if (save && !save.disabled) save.click();
});
+// NOUVELLE ENTREE - dans l'ecran de DETAIL, pas dans une quatrieme vue.
+//
+// Le rail en porte trois, et chacune coute ses regles de transformation. Le
+// detail a deja tout ce qu'il faut : un titre, un retour, une colonne qui
+// defile. Une entree qu'on cree et une entree qu'on consulte occupent la meme
+// place a l'ecran, et c'est exactement ce que cette vue veut dire.
+var newKind = 'login';
+
+function newField(id, label, type) {
+ return '' + label + '
'
+ + '
'
+ + '
';
+}
+
+function openNewEntry() {
+ newKind = 'login';
+ detailRows = [];
+ document.getElementById('dtitle').textContent = 'New entry';
+ document.getElementById('fields').innerHTML =
+ 'Kind
'
+ + 'Login '
+ + 'Note
'
+ + 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.
+ + 'Save '
+ + '
';
+ 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')))