diff --git a/android-client/PLAN.md b/android-client/PLAN.md index 71dbfd2..1841171 100644 --- a/android-client/PLAN.md +++ b/android-client/PLAN.md @@ -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. diff --git a/android-client/PMA.Store.pas b/android-client/PMA.Store.pas index e701c81..642c02f 100644 --- a/android-client/PMA.Store.pas +++ b/android-client/PMA.Store.pas @@ -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('etag', ''); + ASyncPwd := LWrap.GetValue('pwd', ''); APlainJson := LWrap.GetValue('vault', ''); Result := APlainJson <> ''; finally @@ -431,6 +447,7 @@ begin Result := False; APlainJson := ''; AETag := ''; + ASyncPwd := ''; end; {$ENDIF} diff --git a/android-client/README.md b/android-client/README.md index 501dd87..d5e00de 100644 --- a/android-client/README.md +++ b/android-client/README.md @@ -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 diff --git a/mainApk.pas b/mainApk.pas index 7d366ea..be1f73d 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -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