feat(android): change a password from the phone, section 4 complete

Pull, patch, re-encrypt, publish - in that order, and against the SERVER's
snapshot rather than the local cache. That is what replaces the merge the plan
asked for: anything untouched is already everyone's newest version, so there is
nothing to reconcile.

The only conflict left is another device publishing between our read and our
write. Gitea refuses it on the sha with a 409, and the whole cycle repeats,
decryption included, since the content changed. Three attempts: past that the
other device is publishing in a loop and insisting would not help.

The editable field is marked in Delphi rather than recognised by its caption in
the page. A caption is prose; the day it changes, matching on it would silently
stop offering the edit.

The editor is inline rather than window.prompt: a JavaScript prompt needs a
WebChromeClient that FMX does not provide, and prompt() then returns null
without a word - the button would appear to do nothing. The value shows in
clear while typing, deliberately: you are entering a password you cannot read
back anywhere else, and masking it would mean confirming a keystroke blind.

This is the one command that carries a secret over the bridge, agreed
beforehand. It stays in the process, the iframe carrying it is destroyed, and
the handler logs the action only - never the query.

Only the password is editable. Creating or deleting an entry would need
tombstones and uuid generation, and changing a password covers the need
without either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-17 19:41:05 +01:00
parent 6bb056220e
commit 440bfd5536
3 changed files with 180 additions and 8 deletions
+19 -5
View File
@@ -149,6 +149,14 @@ type
TDetailRow = record TDetailRow = record
Caption, Value: string; Caption, Value: string;
Secret: Boolean; Secret: Boolean;
/// Modifiable depuis le telephone. UN SEUL champ l'est pour l'instant - le
/// mot de passe - et c'est deliberé : creer ou supprimer une entree
/// demanderait des tombstones et la generation d'uuid, alors que changer un
/// mot de passe couvre l'essentiel du besoin sans rien de tout cela.
/// Marque ICI plutot que reconnu au libelle cote page : un intitule est de
/// la prose, et le jour ou il change la page cesserait silencieusement de
/// proposer la modification.
Editable: Boolean;
end; end;
function BuildRows(const E: TVaultEntry): TArray<TDetailRow>; function BuildRows(const E: TVaultEntry): TArray<TDetailRow>;
@@ -157,13 +165,15 @@ var
I: Integer; I: Integer;
LMeta: string; LMeta: string;
procedure Add(const ACaption, AValue: string; ASecret: Boolean); procedure Add(const ACaption, AValue: string; ASecret: Boolean;
AEditable: Boolean = False);
begin begin
if AValue = '' then Exit; // une ligne vide n'apprend rien if AValue = '' then Exit; // une ligne vide n'apprend rien
SetLength(LRows, Length(LRows) + 1); SetLength(LRows, Length(LRows) + 1);
LRows[High(LRows)].Caption := ACaption; LRows[High(LRows)].Caption := ACaption;
LRows[High(LRows)].Value := AValue; LRows[High(LRows)].Value := AValue;
LRows[High(LRows)].Secret := ASecret; LRows[High(LRows)].Secret := ASecret;
LRows[High(LRows)].Editable := AEditable;
end; end;
begin begin
@@ -171,8 +181,11 @@ begin
Add('Username', E.Username, False); Add('Username', E.Username, False);
// Le corps d'une note est range dans le champ mot de passe : meme donnee, // Le corps d'une note est range dans le champ mot de passe : meme donnee,
// autre intitule, et pas de raison de la masquer comme un secret. // autre intitule, et pas de raison de la masquer comme un secret.
// Seule la ligne du mot de passe est modifiable. Le corps d'une note le
// serait en principe, mais il se saisit sur plusieurs lignes - un autre
// controle, une autre fois.
if E.IsNote then Add('Content', E.Password, False) if E.IsNote then Add('Content', E.Password, False)
else Add('Password', E.Password, True); else Add('Password', E.Password, True, True);
for I := 0 to High(E.Fields) do for I := 0 to High(E.Fields) do
Add(E.Fields[I].Caption, E.Fields[I].Value, E.Fields[I].Secret); Add(E.Fields[I].Caption, E.Fields[I].Value, E.Fields[I].Secret);
@@ -220,6 +233,7 @@ begin
LObj.AddPair('c', LRows[I].Caption); LObj.AddPair('c', LRows[I].Caption);
LObj.AddPair('v', LRows[I].Value); LObj.AddPair('v', LRows[I].Value);
LObj.AddPair('s', TJSONBool.Create(LRows[I].Secret)); LObj.AddPair('s', TJSONBool.Create(LRows[I].Secret));
if LRows[I].Editable then LObj.AddPair('e', TJSONBool.Create(True));
LArr.AddElement(LObj); LArr.AddElement(LObj);
end; end;
Result := LRoot.ToJSON; Result := LRoot.ToJSON;
+40 -3
View File
@@ -250,7 +250,7 @@
<div class="in-wrap"> <div class="in-wrap">
<!-- NEUTRE : c'est l'URL qui decide de la nature du jeton, pas ce <!-- NEUTRE : c'est l'URL qui decide de la nature du jeton, pas ce
champ. Une adresse d'API Gitea en fait un jeton d'acces personnel champ. Une adresse d'API Gitea en fait un jeton d'acces personnel
envoye en « token » ; l'endpoint PHP en fait un « Bearer ». Le envoye en "token" ; l'endpoint PHP en fait un "Bearer". Le
nommer d'apres un seul des deux le rend faux pour l'autre. --> nommer d'apres un seul des deux le rend faux pour l'autre. -->
<input class="field-in" id="tok" placeholder="access token" <input class="field-in" id="tok" placeholder="access token"
type="password" autocomplete="off"> type="password" autocomplete="off">
@@ -484,6 +484,9 @@ document.getElementById('qx').addEventListener('click', function () {
// --- detail ---------------------------------------------------------------- // --- detail ----------------------------------------------------------------
var detailRows = []; var detailRows = [];
// Le dernier detail recu, pour redessiner la ligne apres une annulation sans
// avoir a le redemander a Delphi.
var lastDetail = null;
function renderDetail(d) { function renderDetail(d) {
detailRows = d.rows || []; detailRows = d.rows || [];
@@ -503,6 +506,7 @@ function renderDetail(d) {
+ '</div>' + '</div>'
+ (r.s ? '<button class="btn" data-show="' + i + '">Show</button>' : '') + (r.s ? '<button class="btn" data-show="' + i + '">Show</button>' : '')
+ '<button class="btn" data-copy="row" data-k="' + i + '">Copy</button>' + '<button class="btn" data-copy="row" data-k="' + i + '">Copy</button>'
+ (r.e ? '<button class="btn" data-edit="' + i + '">Edit</button>' : '')
+ '</div></div>'; + '</div></div>';
} }
document.getElementById('fields').innerHTML = html; document.getElementById('fields').innerHTML = html;
@@ -524,10 +528,43 @@ document.getElementById('fields').addEventListener('click', function (ev) {
return; return;
} }
var copy = t.getAttribute('data-copy'); var copy = t.getAttribute('data-copy');
if (copy === 'totp') send('copy?f=totp'); if (copy === 'totp') { send('copy?f=totp'); return; }
else if (copy === 'row') send('copy?f=row&k=' + t.getAttribute('data-k')); if (copy === 'row') { send('copy?f=row&k=' + t.getAttribute('data-k')); return; }
var edit = t.getAttribute('data-edit');
if (edit !== null) { openEditor(edit); return; }
if (t.getAttribute('data-editcancel') !== null) { renderDetail(lastDetail); return; }
if (t.getAttribute('data-editsave') !== null) {
var input = document.getElementById('editval');
if (!input || !input.value) return; // vide : on n'ecrase pas par megarde
send('setpwd?v=' + encodeURIComponent(input.value));
return;
}
}); });
// EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au
// moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors
// null sans un mot - le bouton semblerait ne rien faire.
//
// La valeur s'affiche EN CLAIR pendant la saisie. On tape un mot de passe
// qu'on ne peut pas relire ailleurs ; le masquer ferait valider a l'aveugle
// une frappe qu'aucune confirmation ne rattrape.
function openEditor(k) {
var row = document.querySelector('[data-v="' + k + '"]');
if (!row) return;
var line = row.parentNode;
line.innerHTML =
'<input class="field-in" id="editval" type="text" autocomplete="off"'
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"'
+ ' style="margin:0">'
+ '<button class="btn" data-editcancel>Cancel</button>'
+ '<button class="btn main" data-editsave>Save</button>';
var input = document.getElementById('editval');
input.value = detailRows[k].v;
input.focus();
send('touch');
}
function goList() { function goList() {
document.body.classList.remove('on-detail'); document.body.classList.remove('on-detail');
document.body.classList.add('on-list'); document.body.classList.add('on-list');
+121
View File
@@ -191,6 +191,7 @@ type
procedure WriteTest; procedure WriteTest;
{$ENDIF} {$ENDIF}
procedure OpenEntry(AVaultIndex: Integer); procedure OpenEntry(AVaultIndex: Integer);
procedure SaveEntryPassword(const ANewValue: string);
procedure OnClipTimer(Sender: TObject); procedure OnClipTimer(Sender: TObject);
procedure OnDeadline(Sender: TObject); procedure OnDeadline(Sender: TObject);
procedure OnHardwareBack(Sender: TObject; var Key: Word; procedure OnHardwareBack(Sender: TObject; var Key: Word;
@@ -521,6 +522,120 @@ begin
end; end;
end; end;
/// Change le mot de passe de l'entree ouverte, et publie.
///
/// TIRER, MODIFIER, RECHIFFRER, PUBLIER - dans cet ordre, et sur l'instantane
/// DU SERVEUR, pas sur le cache local. C'est ce qui remplace la fusion : ce
/// qu'on ne touche pas est deja la version la plus recente de tout le monde.
///
/// Le seul conflit possible est qu'un autre appareil publie entre notre lecture
/// et notre ecriture. Gitea le refuse alors sur le sha (409), et on recommence
/// 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;
begin
if FBusy then Exit;
if FCurrent.Uuid = '' then
begin
WebCall('PM.toast("This entry has no uuid - cannot be updated")');
Exit;
end;
if FSyncPwd = '' then
begin
WebCall('PM.toast("Sync password missing - open the vault first")');
Exit;
end;
LUuid := FCurrent.Uuid;
LTitle := FCurrent.Display;
SetBusy(True);
TThread.CreateAnonymousThread(
procedure
var
LClient: TSyncClient;
LGet, LPut: TSyncResult;
LPlain, LNew, LBody, LErr: string;
LAttempt: Integer;
LDone: Boolean;
begin
LClient := TSyncClient.Create(FUrl, FToken);
LDone := False;
LErr := '';
try
for LAttempt := 1 to 3 do
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
LPlain := DecryptContainer(LGet.Container, FSyncPwd);
LNew := PatchEntryField(LPlain, LUuid, 'password', ANewValue);
except
on E: Exception do
begin
LErr := E.Message;
Break;
end;
end;
LBody := EncryptContainer(LNew, FSyncPwd);
LPut := LClient.Put(LBody, LGet.ETag, 'password change from phone');
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);
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 (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) + ')');
end
else
WebCall('PM.toast(' + JsArg('Not saved - ' + LErr) + ')');
end);
finally
LClient.Free;
TThread.Queue(nil, procedure begin SetBusy(False); end);
end;
end).Start;
end;
/// Essai du chemin d'ecriture, VERS UN AUTRE FICHIER que le coffre. /// 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 /// Le premier PUT est le moment ou une erreur de transport se paie en donnees
@@ -702,6 +817,12 @@ begin
else if LAction = 'sync' then else if LAction = 'sync' then
DoSync(nil) DoSync(nil)
// 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 = 'setpwd' then
SaveEntryPassword(TNetEncoding.URL.Decode(Param('v')))
{$IFDEF DEBUG} {$IFDEF DEBUG}
else if LAction = 'writetest' then else if LAction = 'writetest' then
WriteTest WriteTest