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.
This commit is contained in:
r-zakarya
2026-08-21 14:22:08 +01:00
parent f32cde81fc
commit d65fe909b4
3 changed files with 295 additions and 71 deletions
+150 -71
View File
@@ -204,7 +204,12 @@ type
procedure PatchSelfCheck;
{$ENDIF}
procedure OpenEntry(AVaultIndex: Integer);
procedure CommitChange(const ALabel, AComment, AReopenUuid: string;
const APatch: TFunc<string, string>);
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<string, string>);
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')))