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
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.
+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
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}
+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`
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