feat(android): show first, sync after

The local vault now opens by itself at launch and the GET runs behind it,
silently. A 304 shows nothing; a network failure shows nothing either -
offline is a normal state, and an alert on every launch only teaches people
to dismiss alerts. Only a new snapshot manifests, by refreshing the list.

That silence is what puts the sync password inside the Keystore-encrypted
file next to the vault: without it a new snapshot cannot be opened, so every
edit made on the desktop would raise a password prompt at launch. The file
already holds every password in plaintext; this one adds almost nothing to
its exposure. A typed password still wins over the stored one, which is how
you fix one that no longer works.

Adopting a snapshot while the app is already open rebuilds the list in place
instead of navigating - mandatory even from the detail screen, since list
rows carry indices into the old vault and a tap would open the wrong entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-15 11:14:44 +01:00
parent ba0d3c1d3c
commit 3bb6f6f903
4 changed files with 119 additions and 38 deletions
+4 -5
View File
@@ -75,11 +75,10 @@ Par ordre de gravité, pas de taille.
4. **Marquer le presse-papiers sensible** — depuis Android 13 le système 4. **Marquer le presse-papiers sensible** — depuis Android 13 le système
affiche un aperçu de ce qui vient d'être copié, mot de passe compris. affiche un aperçu de ce qui vient d'être copié, mot de passe compris.
`ClipDescription` + extra `android.content.extra.IS_SENSITIVE`. `ClipDescription` + extra `android.content.extra.IS_SENSITIVE`.
5. **Sortir les paramètres du code source.** URL, jeton et mot de passe de 5. **Sortir l'URL et le jeton du code source.** Ce sont encore des constantes.
sync sont des constantes, et le mot de passe vient d'un fichier hors dépôt Le mot de passe de sync, lui, est déjà rangé dans le fichier chiffré par le
([PMA.LocalConfig.inc](PMA.LocalConfig.template.inc)). Un vrai client les Keystore ; reste à en faire autant pour les deux autres, et à supprimer le
demande une fois et les range dans le même fichier chiffré par le Keystore préremplissage depuis [PMA.LocalConfig.inc](PMA.LocalConfig.template.inc).
que le coffre.
6. **Verrouillage sur inactivité au premier plan.** Aujourd'hui seul le 6. **Verrouillage sur inactivité au premier plan.** Aujourd'hui seul le
passage en arrière-plan verrouille ; le poste Windows a en plus une minuterie. passage en arrière-plan verrouille ; le poste Windows a en plus une minuterie.
+28 -11
View File
@@ -13,17 +13,17 @@ unit PMA.Store;
n'en sort pas : on envoie des donnees a chiffrer, on recoit le resultat. Un n'en sort pas : on envoie des donnees a chiffrer, on recoit le resultat. Un
vol du fichier seul est donc inexploitable, meme root - il faut la puce. vol du fichier seul est donc inexploitable, meme root - il faut la puce.
CE QUE CE FICHIER CONTIENT : tous les mots de passe en clair, une fois CE QUE CE FICHIER CONTIENT : tous les mots de passe en clair une fois
dechiffre. D'ou trois regles tenues ici : dechiffre, ET le mot de passe de sync (voir SaveVault pour l'arbitrage).
D'ou trois regles tenues ici :
- jamais hors du repertoire prive de l'application ; - jamais hors du repertoire prive de l'application ;
- jamais de copie en clair sur le disque, meme temporaire ; - jamais de copie en clair sur le disque, meme temporaire ;
- la sauvegarde automatique Android doit etre desactivee pour l'app - la sauvegarde automatique Android doit etre desactivee pour l'app
(android:allowBackup="false"), sinon Google en recupere une copie. (android:allowBackup="false"), sinon Google en recupere une copie.
PAS ENCORE FAIT : exiger la biometrie a chaque lecture La biometrie est demandee a la lecture quand la cle a pu etre creee avec
(setUserAuthenticationRequired). La cle est deja liee au materiel ; la setUserAuthenticationRequired - deux alias existent pour cela, et l'en-tete
biometrie ajouterait une barriere si le telephone est deverrouille et qu'un du fichier dit lequel a servi.
tiers ouvre l'application. A faire dans un second temps, avec BiometricPrompt.
} }
interface interface
@@ -37,12 +37,22 @@ type
/// Le coffre local existe-t-il ? /// Le coffre local existe-t-il ?
function HasVault: Boolean; function HasVault: Boolean;
/// Ecrit le coffre et l'ETag qui l'accompagne, chiffres ensemble. /// Ecrit le coffre, l'ETag qui l'accompagne et le mot de passe de sync,
/// chiffres ensemble.
///
/// POURQUOI LE MOT DE PASSE EST LA : sans lui, un instantane nouveau ne peut
/// pas etre ouvert sans intervention, et chaque modification faite sur le
/// poste ferait apparaitre une demande de mot de passe au lancement - que
/// l'utilisateur finirait par expedier sans la lire. Le fichier contient deja
/// tous les mots de passe en clair ; celui-ci n'y ajoute presque rien en
/// exposition, et rend la synchronisation silencieuse.
///
/// ARequireAuth demande une cle deverrouillee par biometrie ou code. Si /// ARequireAuth demande une cle deverrouillee par biometrie ou code. Si
/// l'appareil n'a aucune de ces protections, on retombe silencieusement sur /// l'appareil n'a aucune de ces protections, on retombe silencieusement sur
/// une cle sans contrainte : mieux vaut un cache lie au materiel que pas de /// une cle sans contrainte : mieux vaut un cache lie au materiel que pas de
/// cache du tout. VaultNeedsAuth dira ensuite ce qui a reellement ete fait. /// cache du tout. VaultNeedsAuth dira ensuite ce qui a reellement ete fait.
procedure SaveVault(const APlainJson, AETag: string; ARequireAuth: Boolean = False); procedure SaveVault(const APlainJson, AETag, ASyncPwd: string;
ARequireAuth: Boolean = False);
/// Le coffre local exige-t-il une authentification pour etre lu ? Lu dans /// Le coffre local exige-t-il une authentification pour etre lu ? Lu dans
/// l'en-tete du fichier, sans dechiffrer quoi que ce soit. /// l'en-tete du fichier, sans dechiffrer quoi que ce soit.
@@ -50,7 +60,9 @@ function VaultNeedsAuth: Boolean;
/// Relit le coffre. False si absent ou illisible (cle perdue apres une /// Relit le coffre. False si absent ou illisible (cle perdue apres une
/// reinstallation, fichier altere) - dans ce cas il faut retelecharger. /// reinstallation, fichier altere) - dans ce cas il faut retelecharger.
function LoadVault(out APlainJson, AETag: string): Boolean; /// ASyncPwd est vide pour un fichier ecrit avant que le mot de passe y soit
/// range : l'appelant redemande alors, et le prochain enregistrement le pose.
function LoadVault(out APlainJson, AETag, ASyncPwd: string): Boolean;
/// Efface le coffre local. A appeler au moindre doute : il se retelecharge. /// Efface le coffre local. A appeler au moindre doute : il se retelecharge.
procedure ClearVault; procedure ClearVault;
@@ -311,7 +323,8 @@ begin
end; end;
end; end;
procedure SaveVault(const APlainJson, AETag: string; ARequireAuth: Boolean); procedure SaveVault(const APlainJson, AETag, ASyncPwd: string;
ARequireAuth: Boolean);
{$IF DEFINED(ANDROID)} {$IF DEFINED(ANDROID)}
var var
LWrap: TJSONObject; LWrap: TJSONObject;
@@ -326,6 +339,7 @@ begin
LWrap := TJSONObject.Create; LWrap := TJSONObject.Create;
try try
LWrap.AddPair('etag', AETag); LWrap.AddPair('etag', AETag);
LWrap.AddPair('pwd', ASyncPwd);
LWrap.AddPair('vault', APlainJson); LWrap.AddPair('vault', APlainJson);
LPlain := TEncoding.UTF8.GetBytes(LWrap.ToJSON); LPlain := TEncoding.UTF8.GetBytes(LWrap.ToJSON);
finally finally
@@ -367,7 +381,7 @@ begin
end; end;
{$ENDIF} {$ENDIF}
function LoadVault(out APlainJson, AETag: string): Boolean; function LoadVault(out APlainJson, AETag, ASyncPwd: string): Boolean;
{$IF DEFINED(ANDROID)} {$IF DEFINED(ANDROID)}
var var
LRaw, LIv, LCt, LPlain: TBytes; LRaw, LIv, LCt, LPlain: TBytes;
@@ -377,6 +391,7 @@ begin
Result := False; Result := False;
APlainJson := ''; APlainJson := '';
AETag := ''; AETag := '';
ASyncPwd := '';
if not TFile.Exists(VaultPath) then Exit; if not TFile.Exists(VaultPath) then Exit;
try try
@@ -402,6 +417,7 @@ begin
if LWrap = nil then Exit; if LWrap = nil then Exit;
try try
AETag := LWrap.GetValue<string>('etag', ''); AETag := LWrap.GetValue<string>('etag', '');
ASyncPwd := LWrap.GetValue<string>('pwd', '');
APlainJson := LWrap.GetValue<string>('vault', ''); APlainJson := LWrap.GetValue<string>('vault', '');
Result := APlainJson <> ''; Result := APlainJson <> '';
finally finally
@@ -431,6 +447,7 @@ begin
Result := False; Result := False;
APlainJson := ''; APlainJson := '';
AETag := ''; AETag := '';
ASyncPwd := '';
end; end;
{$ENDIF} {$ENDIF}
+15
View File
@@ -61,6 +61,21 @@ Trois écrans dans une seule forme, construits en code (`mainApk.pas`) :
déverrouillage, liste, détail. Une forme par écran aurait coûté trois `.fmx` déverrouillage, liste, détail. Une forme par écran aurait coûté trois `.fmx`
pour des dispositions triviales. pour des dispositions triviales.
**Afficher d'abord, synchroniser ensuite.** Au lancement, le coffre local
s'ouvre tout seul (biométrie si la clé l'exige) et la liste apparaît sans
réseau ni dérivation ; le `GET` part derrière, en silence. Un 304 ne se voit
pas, un échec réseau non plus — hors ligne est un état normal, et une alerte à
chaque lancement apprendrait surtout à ignorer les alertes. Seul un instantané
**nouveau** se manifeste, en rafraîchissant la liste.
C'est ce qui justifie de ranger le **mot de passe de sync** dans le fichier
chiffré, à côté du coffre : sans lui, un instantané nouveau ne peut pas être
ouvert, et chaque modification faite sur le poste ferait apparaître une invite
au lancement. Le fichier contient déjà tous les mots de passe en clair ;
celui-ci n'y ajoute presque rien en exposition. Ce qui est tapé dans le champ
l'emporte sur ce qui est rangé — c'est ainsi qu'on corrige un mot de passe qui
ne convient plus.
L'écran de déverrouillage s'adapte à ce que l'appareil détient : coffre local L'écran de déverrouillage s'adapte à ce que l'appareil détient : coffre local
présent → *Ouvrir* (biométrie si la clé l'exige) ; absent → seul le présent → *Ouvrir* (biométrie si la clé l'exige) ; absent → seul le
téléchargement peut en produire un. La liste filtre à la frappe sur le nom, le téléchargement peut en produire un. La liste filtre à la frappe sur le nom, le
+63 -13
View File
@@ -75,6 +75,10 @@ type
// economiser un telechargement pour se retrouver sans donnees. // economiser un telechargement pour se retrouver sans donnees.
FETag: string; FETag: string;
FBusy: Boolean; FBusy: Boolean;
// Mot de passe de sync tel qu'il a ete range avec le coffre. Il rend le
// rafraichissement de fond silencieux : sans lui, chaque modification
// faite sur le poste ferait apparaitre une invite au lancement.
FSyncPwd: string;
// --- ecran 1 : deverrouillage // --- ecran 1 : deverrouillage
FLayUnlock: TLayout; FLayUnlock: TLayout;
@@ -126,7 +130,11 @@ type
// procedure imbriquee (E2555), et le rappel de la biometrie en est une. // procedure imbriquee (E2555), et le rappel de la biometrie en est une.
procedure OpenLocalNow; procedure OpenLocalNow;
procedure DoSync(Sender: TObject); procedure DoSync(Sender: TObject);
procedure FetchAndDecrypt(const AUrl, ATok, APwd: string); /// ASilent : rafraichissement de fond. Le coffre local est deja affiche,
/// donc un echec reseau ne doit rien reclamer a l'utilisateur - il garde
/// ce qu'il a. Seul un instantane NOUVEAU se voit.
procedure FetchAndDecrypt(const AUrl, ATok, APwd: string;
ASilent: Boolean = False);
procedure AdoptVault(const APlainJson: string); procedure AdoptVault(const APlainJson: string);
procedure LockVault; procedure LockVault;
@@ -202,6 +210,12 @@ begin
LEvents.SetApplicationEventHandler(HandleAppEvent); LEvents.SetApplicationEventHandler(HandleAppEvent);
ShowUnlock; ShowUnlock;
// Ouverture automatique du coffre local. Differee apres la construction :
// l'invite biometrique s'afficherait sinon avant la premiere peinture de la
// fenetre, sur un fond vide.
if HasVault then
TThread.ForceQueue(nil, procedure begin DoOpenLocal(nil); end);
end; end;
destructor TForm8.Destroy; destructor TForm8.Destroy;
@@ -635,18 +649,31 @@ begin
FVault.LoadFromJson(APlainJson); FVault.LoadFromJson(APlainJson);
Log(' ' + FVault.LiveCount.ToString + ' entrees, ' + Log(' ' + FVault.LiveCount.ToString + ' entrees, ' +
FVault.TrashCount.ToString + ' en corbeille'); FVault.TrashCount.ToString + ' en corbeille');
// Deja dans l'application : reconstruire la liste SANS y ramener
// l'utilisateur. Obligatoire meme s'il est dans le detail - les lignes de
// la liste portent des indices vers l'ancien coffre, et un appui ouvrirait
// la mauvaise entree.
if FLayList.Visible or FLayDetail.Visible then
OnSearchChanged(nil)
else
ShowList; ShowList;
end; end;
procedure TForm8.OpenLocalNow; procedure TForm8.OpenLocalNow;
var var
LPlain, LETag: string; LPlain, LETag, LPwd: string;
begin begin
if LoadVault(LPlain, LETag) then if LoadVault(LPlain, LETag, LPwd) then
begin begin
FETag := LETag; FETag := LETag;
if LPwd <> '' then FSyncPwd := LPwd;
try try
AdoptVault(LPlain); AdoptVault(LPlain);
// Afficher d'abord, synchroniser ensuite : le coffre est a l'ecran, le
// reseau se debrouille derriere. Sans mot de passe range, on ne peut
// rien ouvrir de nouveau - inutile d'aller voir.
if FSyncPwd <> '' then
FetchAndDecrypt(FUrl.Text.Trim, FToken.Text.Trim, FSyncPwd, True);
except except
on E: Exception do on E: Exception do
begin begin
@@ -705,7 +732,10 @@ begin
// thread de travail est un plantage differe, pas une erreur de compilation. // thread de travail est un plantage differe, pas une erreur de compilation.
LUrl := FUrl.Text.Trim; LUrl := FUrl.Text.Trim;
LTok := FToken.Text.Trim; LTok := FToken.Text.Trim;
// Ce qui est tape l'emporte : c'est ainsi qu'on corrige un mot de passe
// range qui ne convient plus.
LPwd := FPwd.Text; LPwd := FPwd.Text;
if LPwd = '' then LPwd := FSyncPwd;
if LPwd = '' then if LPwd = '' then
begin begin
Status('Mot de passe de sync requis'); Status('Mot de passe de sync requis');
@@ -715,21 +745,22 @@ begin
FetchAndDecrypt(LUrl, LTok, LPwd); FetchAndDecrypt(LUrl, LTok, LPwd);
end; end;
procedure TForm8.FetchAndDecrypt(const AUrl, ATok, APwd: string); procedure TForm8.FetchAndDecrypt(const AUrl, ATok, APwd: string;
ASilent: Boolean);
begin begin
SetBusy(True); SetBusy(True);
Status('Telechargement...'); if not ASilent then Status('Telechargement...');
TThread.CreateAnonymousThread( TThread.CreateAnonymousThread(
procedure procedure
var var
LClient: TSyncClient; LClient: TSyncClient;
LRes: TSyncResult; LRes: TSyncResult;
LPlain, LCached, LCachedETag: string; LPlain, LCached, LCachedETag, LCachedPwd: string;
LSw: TStopwatch; LSw: TStopwatch;
begin begin
// Le coffre local d'abord : s'il est la, on connait deja l'ETag et on // Le coffre local d'abord : s'il est la, on connait deja l'ETag et on
// evite les 4 secondes de derivation quand rien n'a change. // evite les 4 secondes de derivation quand rien n'a change.
if (FETag = '') and LoadVault(LCached, LCachedETag) then if (FETag = '') and LoadVault(LCached, LCachedETag, LCachedPwd) then
FETag := LCachedETag; FETag := LCachedETag;
LClient := TSyncClient.Create(AUrl, ATok); LClient := TSyncClient.Create(AUrl, ATok);
@@ -760,10 +791,11 @@ begin
procedure procedure
begin begin
if FVault.Count > 0 then if FVault.Count > 0 then
begin // Rien a faire : ce qui est affiche est deja a jour. En
Log(' 304 : inchange, coffre deja ouvert'); // rafraichissement de fond c'est le cas courant, et il
ShowList; // doit passer inapercu - surtout pas de retour a la liste
end // alors que l'utilisateur consulte une entree.
Log(' 304 : inchange, coffre deja ouvert')
else if LCached <> '' then else if LCached <> '' then
begin begin
Log(' 304 : inchange, coffre servi depuis le cache'); Log(' 304 : inchange, coffre servi depuis le cache');
@@ -778,6 +810,7 @@ begin
// Un ETag sans clair correspondant : l'invariant est rompu. // Un ETag sans clair correspondant : l'invariant est rompu.
// On le jette pour que l'appel suivant retelecharge. // On le jette pour que l'appel suivant retelecharge.
FETag := ''; FETag := '';
if not ASilent then
Status('Cache absent - appuyer a nouveau pour telecharger'); Status('Cache absent - appuyer a nouveau pour telecharger');
end; end;
end); end);
@@ -787,13 +820,18 @@ begin
begin begin
// Le serveur repond pareil dans les deux cas, par conception. // Le serveur repond pareil dans les deux cas, par conception.
Log(' 404 : pas d''instantane OU jeton invalide'); Log(' 404 : pas d''instantane OU jeton invalide');
if not ASilent then
TThread.Queue(nil, procedure TThread.Queue(nil, procedure
begin Status('Rien a telecharger (404)'); end); begin Status('Rien a telecharger (404)'); end);
Exit; Exit;
end; end;
ssRefused, ssHttpError, ssNetworkError: ssRefused, ssHttpError, ssNetworkError:
begin begin
// En fond, un echec reseau ne se signale pas : le coffre local
// est affiche, hors ligne est un etat normal, et une alerte ici
// apprendrait a l'utilisateur a ignorer les alertes.
Log(' echec : ' + LRes.Error); Log(' echec : ' + LRes.Error);
if not ASilent then
TThread.Queue(nil, procedure TThread.Queue(nil, procedure
begin Status('Echec reseau'); end); begin Status('Echec reseau'); end);
Exit; Exit;
@@ -801,6 +839,7 @@ begin
end; end;
Log(' recu ' + Length(LRes.Container).ToString + ' octets'); Log(' recu ' + Length(LRes.Container).ToString + ' octets');
if not ASilent then
TThread.Queue(nil, procedure begin Status('Dechiffrement...'); end); TThread.Queue(nil, procedure begin Status('Dechiffrement...'); end);
LSw := TStopwatch.StartNew; LSw := TStopwatch.StartNew;
@@ -817,7 +856,9 @@ begin
// True : on DEMANDE la protection par authentification. PMA.Store // True : on DEMANDE la protection par authentification. PMA.Store
// retombe tout seul sur une cle simple si l'appareil n'a ni // retombe tout seul sur une cle simple si l'appareil n'a ni
// empreinte ni code, plutot que de renoncer au cache. // empreinte ni code, plutot que de renoncer au cache.
SaveVault(LPlain, FETag, True); // Le mot de passe part avec le coffre : c'est lui qui permettra au
// prochain lancement de rafraichir sans rien demander.
SaveVault(LPlain, FETag, APwd, True);
if VaultNeedsAuth then if VaultNeedsAuth then
Log(' cache ecrit - protege par biometrie/code') Log(' cache ecrit - protege par biometrie/code')
else else
@@ -832,10 +873,13 @@ begin
TThread.Queue(nil, TThread.Queue(nil,
procedure procedure
begin begin
FSyncPwd := APwd; // il vient d'ouvrir un conteneur : il est bon
try try
AdoptVault(LPlain); AdoptVault(LPlain);
except except
on E: Exception do Status('Instantane illisible : ' + E.Message); on E: Exception do
if not ASilent then
Status('Instantane illisible : ' + E.Message);
end; end;
end); end);
except except
@@ -844,6 +888,12 @@ begin
Log('ERREUR ' + E.ClassName + ' : ' + E.Message); Log('ERREUR ' + E.ClassName + ' : ' + E.Message);
// Le cas de loin le plus frequent, et le message par defaut ne le // Le cas de loin le plus frequent, et le message par defaut ne le
// dit pas : GCM echoue a la verification quand la cle est fausse. // dit pas : GCM echoue a la verification quand la cle est fausse.
//
// En fond, cela veut dire que le mot de passe range ne convient
// plus - le poste a change de mot de passe de sync. On ne le jette
// PAS : il faudra le retaper, mais garder l'ancien laisse au moins
// le champ prerempli, et le coffre local reste consultable.
if not ASilent then
TThread.Queue(nil, procedure TThread.Queue(nil, procedure
begin Status('Echec - mot de passe de sync incorrect ?'); end); begin Status('Echec - mot de passe de sync incorrect ?'); end);
end; end;