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