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:
@@ -75,11 +75,10 @@ Par ordre de gravité, pas de taille.
|
||||
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.
|
||||
`ClipDescription` + extra `android.content.extra.IS_SENSITIVE`.
|
||||
5. **Sortir les paramètres du code source.** URL, jeton et mot de passe de
|
||||
sync sont des constantes, et le mot de passe vient d'un fichier hors dépôt
|
||||
([PMA.LocalConfig.inc](PMA.LocalConfig.template.inc)). Un vrai client les
|
||||
demande une fois et les range dans le même fichier chiffré par le Keystore
|
||||
que le coffre.
|
||||
5. **Sortir l'URL et le jeton du code source.** Ce sont encore des constantes.
|
||||
Le mot de passe de sync, lui, est déjà rangé dans le fichier chiffré par le
|
||||
Keystore ; reste à en faire autant pour les deux autres, et à supprimer le
|
||||
préremplissage depuis [PMA.LocalConfig.inc](PMA.LocalConfig.template.inc).
|
||||
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.
|
||||
|
||||
|
||||
@@ -13,17 +13,17 @@ unit PMA.Store;
|
||||
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.
|
||||
|
||||
CE QUE CE FICHIER CONTIENT : tous les mots de passe en clair, une fois
|
||||
dechiffre. D'ou trois regles tenues ici :
|
||||
CE QUE CE FICHIER CONTIENT : tous les mots de passe en clair une fois
|
||||
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 de copie en clair sur le disque, meme temporaire ;
|
||||
- la sauvegarde automatique Android doit etre desactivee pour l'app
|
||||
(android:allowBackup="false"), sinon Google en recupere une copie.
|
||||
|
||||
PAS ENCORE FAIT : exiger la biometrie a chaque lecture
|
||||
(setUserAuthenticationRequired). La cle est deja liee au materiel ; la
|
||||
biometrie ajouterait une barriere si le telephone est deverrouille et qu'un
|
||||
tiers ouvre l'application. A faire dans un second temps, avec BiometricPrompt.
|
||||
La biometrie est demandee a la lecture quand la cle a pu etre creee avec
|
||||
setUserAuthenticationRequired - deux alias existent pour cela, et l'en-tete
|
||||
du fichier dit lequel a servi.
|
||||
}
|
||||
|
||||
interface
|
||||
@@ -37,12 +37,22 @@ type
|
||||
/// Le coffre local existe-t-il ?
|
||||
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
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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.
|
||||
procedure ClearVault;
|
||||
@@ -311,7 +323,8 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure SaveVault(const APlainJson, AETag: string; ARequireAuth: Boolean);
|
||||
procedure SaveVault(const APlainJson, AETag, ASyncPwd: string;
|
||||
ARequireAuth: Boolean);
|
||||
{$IF DEFINED(ANDROID)}
|
||||
var
|
||||
LWrap: TJSONObject;
|
||||
@@ -326,6 +339,7 @@ begin
|
||||
LWrap := TJSONObject.Create;
|
||||
try
|
||||
LWrap.AddPair('etag', AETag);
|
||||
LWrap.AddPair('pwd', ASyncPwd);
|
||||
LWrap.AddPair('vault', APlainJson);
|
||||
LPlain := TEncoding.UTF8.GetBytes(LWrap.ToJSON);
|
||||
finally
|
||||
@@ -367,7 +381,7 @@ begin
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
function LoadVault(out APlainJson, AETag: string): Boolean;
|
||||
function LoadVault(out APlainJson, AETag, ASyncPwd: string): Boolean;
|
||||
{$IF DEFINED(ANDROID)}
|
||||
var
|
||||
LRaw, LIv, LCt, LPlain: TBytes;
|
||||
@@ -377,6 +391,7 @@ begin
|
||||
Result := False;
|
||||
APlainJson := '';
|
||||
AETag := '';
|
||||
ASyncPwd := '';
|
||||
if not TFile.Exists(VaultPath) then Exit;
|
||||
|
||||
try
|
||||
@@ -402,6 +417,7 @@ begin
|
||||
if LWrap = nil then Exit;
|
||||
try
|
||||
AETag := LWrap.GetValue<string>('etag', '');
|
||||
ASyncPwd := LWrap.GetValue<string>('pwd', '');
|
||||
APlainJson := LWrap.GetValue<string>('vault', '');
|
||||
Result := APlainJson <> '';
|
||||
finally
|
||||
@@ -431,6 +447,7 @@ begin
|
||||
Result := False;
|
||||
APlainJson := '';
|
||||
AETag := '';
|
||||
ASyncPwd := '';
|
||||
end;
|
||||
{$ENDIF}
|
||||
|
||||
|
||||
@@ -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`
|
||||
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
|
||||
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
|
||||
|
||||
+72
-22
@@ -75,6 +75,10 @@ type
|
||||
// economiser un telechargement pour se retrouver sans donnees.
|
||||
FETag: string;
|
||||
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
|
||||
FLayUnlock: TLayout;
|
||||
@@ -126,7 +130,11 @@ type
|
||||
// procedure imbriquee (E2555), et le rappel de la biometrie en est une.
|
||||
procedure OpenLocalNow;
|
||||
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 LockVault;
|
||||
|
||||
@@ -202,6 +210,12 @@ begin
|
||||
LEvents.SetApplicationEventHandler(HandleAppEvent);
|
||||
|
||||
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;
|
||||
|
||||
destructor TForm8.Destroy;
|
||||
@@ -635,18 +649,31 @@ begin
|
||||
FVault.LoadFromJson(APlainJson);
|
||||
Log(' ' + FVault.LiveCount.ToString + ' entrees, ' +
|
||||
FVault.TrashCount.ToString + ' en corbeille');
|
||||
ShowList;
|
||||
// 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;
|
||||
end;
|
||||
|
||||
procedure TForm8.OpenLocalNow;
|
||||
var
|
||||
LPlain, LETag: string;
|
||||
LPlain, LETag, LPwd: string;
|
||||
begin
|
||||
if LoadVault(LPlain, LETag) then
|
||||
if LoadVault(LPlain, LETag, LPwd) then
|
||||
begin
|
||||
FETag := LETag;
|
||||
if LPwd <> '' then FSyncPwd := LPwd;
|
||||
try
|
||||
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
|
||||
on E: Exception do
|
||||
begin
|
||||
@@ -705,7 +732,10 @@ begin
|
||||
// thread de travail est un plantage differe, pas une erreur de compilation.
|
||||
LUrl := FUrl.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;
|
||||
if LPwd = '' then LPwd := FSyncPwd;
|
||||
if LPwd = '' then
|
||||
begin
|
||||
Status('Mot de passe de sync requis');
|
||||
@@ -715,21 +745,22 @@ begin
|
||||
FetchAndDecrypt(LUrl, LTok, LPwd);
|
||||
end;
|
||||
|
||||
procedure TForm8.FetchAndDecrypt(const AUrl, ATok, APwd: string);
|
||||
procedure TForm8.FetchAndDecrypt(const AUrl, ATok, APwd: string;
|
||||
ASilent: Boolean);
|
||||
begin
|
||||
SetBusy(True);
|
||||
Status('Telechargement...');
|
||||
if not ASilent then Status('Telechargement...');
|
||||
TThread.CreateAnonymousThread(
|
||||
procedure
|
||||
var
|
||||
LClient: TSyncClient;
|
||||
LRes: TSyncResult;
|
||||
LPlain, LCached, LCachedETag: string;
|
||||
LPlain, LCached, LCachedETag, LCachedPwd: string;
|
||||
LSw: TStopwatch;
|
||||
begin
|
||||
// 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.
|
||||
if (FETag = '') and LoadVault(LCached, LCachedETag) then
|
||||
if (FETag = '') and LoadVault(LCached, LCachedETag, LCachedPwd) then
|
||||
FETag := LCachedETag;
|
||||
|
||||
LClient := TSyncClient.Create(AUrl, ATok);
|
||||
@@ -760,10 +791,11 @@ begin
|
||||
procedure
|
||||
begin
|
||||
if FVault.Count > 0 then
|
||||
begin
|
||||
Log(' 304 : inchange, coffre deja ouvert');
|
||||
ShowList;
|
||||
end
|
||||
// Rien a faire : ce qui est affiche est deja a jour. En
|
||||
// rafraichissement de fond c'est le cas courant, et il
|
||||
// doit passer inapercu - surtout pas de retour a la liste
|
||||
// alors que l'utilisateur consulte une entree.
|
||||
Log(' 304 : inchange, coffre deja ouvert')
|
||||
else if LCached <> '' then
|
||||
begin
|
||||
Log(' 304 : inchange, coffre servi depuis le cache');
|
||||
@@ -778,7 +810,8 @@ begin
|
||||
// Un ETag sans clair correspondant : l'invariant est rompu.
|
||||
// On le jette pour que l'appel suivant retelecharge.
|
||||
FETag := '';
|
||||
Status('Cache absent - appuyer a nouveau pour telecharger');
|
||||
if not ASilent then
|
||||
Status('Cache absent - appuyer a nouveau pour telecharger');
|
||||
end;
|
||||
end);
|
||||
Exit;
|
||||
@@ -787,21 +820,27 @@ begin
|
||||
begin
|
||||
// Le serveur repond pareil dans les deux cas, par conception.
|
||||
Log(' 404 : pas d''instantane OU jeton invalide');
|
||||
TThread.Queue(nil, procedure
|
||||
begin Status('Rien a telecharger (404)'); end);
|
||||
if not ASilent then
|
||||
TThread.Queue(nil, procedure
|
||||
begin Status('Rien a telecharger (404)'); end);
|
||||
Exit;
|
||||
end;
|
||||
ssRefused, ssHttpError, ssNetworkError:
|
||||
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);
|
||||
TThread.Queue(nil, procedure
|
||||
begin Status('Echec reseau'); end);
|
||||
if not ASilent then
|
||||
TThread.Queue(nil, procedure
|
||||
begin Status('Echec reseau'); end);
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
Log(' recu ' + Length(LRes.Container).ToString + ' octets');
|
||||
TThread.Queue(nil, procedure begin Status('Dechiffrement...'); end);
|
||||
if not ASilent then
|
||||
TThread.Queue(nil, procedure begin Status('Dechiffrement...'); end);
|
||||
|
||||
LSw := TStopwatch.StartNew;
|
||||
// Volontairement AVANT de retenir l'ETag : un echec ici doit laisser
|
||||
@@ -817,7 +856,9 @@ begin
|
||||
// True : on DEMANDE la protection par authentification. PMA.Store
|
||||
// retombe tout seul sur une cle simple si l'appareil n'a ni
|
||||
// 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
|
||||
Log(' cache ecrit - protege par biometrie/code')
|
||||
else
|
||||
@@ -832,10 +873,13 @@ begin
|
||||
TThread.Queue(nil,
|
||||
procedure
|
||||
begin
|
||||
FSyncPwd := APwd; // il vient d'ouvrir un conteneur : il est bon
|
||||
try
|
||||
AdoptVault(LPlain);
|
||||
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);
|
||||
except
|
||||
@@ -844,8 +888,14 @@ begin
|
||||
Log('ERREUR ' + E.ClassName + ' : ' + E.Message);
|
||||
// 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.
|
||||
TThread.Queue(nil, procedure
|
||||
begin Status('Echec - mot de passe de sync incorrect ?'); end);
|
||||
//
|
||||
// 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
|
||||
begin Status('Echec - mot de passe de sync incorrect ?'); end);
|
||||
end;
|
||||
end;
|
||||
finally
|
||||
|
||||
Reference in New Issue
Block a user