fix(android): protection catches up with the cache, and a dead key gets replaced

After the screen lock was removed and put back, the app stopped asking for the
pattern. Two gaps, both mine.

The cache had been rewritten while the device had no lock, so under the plain
key - and nothing ever moves it back. Protection was decided at write time
only, and a write happens only when a new snapshot arrives; a 304 left the
file in the clear indefinitely. It is now re-sealed on open, where the
plaintext is already in hand.

And the invalidated RSA key was never replaced. Removing a lock, or enrolling
a new fingerprint, permanently invalidates a protected key while its alias
survives. EnsureWrapKey only created the pair when the alias was absent, so it
kept reusing a dead key: every encryption failed and every write fell back to
the plain key, forever. It now deletes and regenerates on a second attempt.

Measured on device: header 1 before, 3 after the first launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-15 18:11:48 +01:00
parent fa424a3bc7
commit e4a50df64d
2 changed files with 55 additions and 18 deletions
+25 -7
View File
@@ -408,6 +408,7 @@ type
function getKey(alias: JString; password: TJavaArray<Char>): JKeyX; cdecl;
function getCertificate(alias: JString): JCertificateX; cdecl;
function containsAlias(alias: JString): Boolean; cdecl;
procedure deleteEntry(alias: JString); cdecl;
end;
TJKeyStoreX = class(TJavaGenericImport<JKeyStoreXClass, JKeyStoreX>) end;
@@ -552,7 +553,7 @@ end;
/// Cree la paire RSA si elle manque. Leve si l'appareil n'a NI empreinte NI
/// code : une cle privee protegee par authentification n'aurait alors rien
/// pour la deverrouiller. L'appelant retombe sur la cle AES.
procedure EnsureWrapKey;
procedure EnsureWrapKey(AForceNew: Boolean = False);
var
LStore: JKeyStoreX;
LGen: JKeyPairGeneratorX;
@@ -560,7 +561,15 @@ var
LPaddings, LDigests: TJavaObjectArray<JString>;
begin
LStore := OpenKeyStore;
if LStore.containsAlias(StringToJString(KEY_ALIAS_WRAP)) then Exit;
// AForceNew : la cle existe mais ne fonctionne plus. Retirer le
// verrouillage d'ecran, ou enroler une nouvelle empreinte, invalide
// DEFINITIVEMENT une cle protegee - or l'alias, lui, subsiste. Sans cette
// suppression on la reutiliserait indefiniment, chaque chiffrement
// echouerait, et on retomberait pour toujours sur la cle simple.
if AForceNew and LStore.containsAlias(StringToJString(KEY_ALIAS_WRAP)) then
LStore.deleteEntry(StringToJString(KEY_ALIAS_WRAP))
else if LStore.containsAlias(StringToJString(KEY_ALIAS_WRAP)) then
Exit;
LPaddings := TJavaObjectArray<JString>.Create(1);
LPaddings.Items[0] := StringToJString('OAEPPadding');
@@ -730,7 +739,7 @@ var
LPlain, LIv, LCt, LOut, LAesRaw, LWrapped: TBytes;
LCipher: JCipherX;
LGotAuth: Boolean;
LOfs: Integer;
LOfs, LAttempt: Integer;
begin
// ETag, mot de passe et coffre chiffres ENSEMBLE : ils doivent rester
// coherents. Un ETag qui survivrait a un coffre illisible ferait repondre
@@ -748,10 +757,13 @@ begin
LGotAuth := False;
LWrapped := nil;
if ARequireAuth then
begin
// Deux essais : le second apres avoir jete une cle devenue inutilisable.
for LAttempt := 0 to 1 do
try
EnsureWrapKey;
// Cle AES jetable, tiree du generateur cryptographique de la plateforme
// - jamais de Random ici.
EnsureWrapKey(LAttempt = 1);
// Cle AES jetable, tiree du generateur cryptographique de la
// plateforme - jamais de Random ici.
LAesRaw := RandomBytes(AES_LEN);
LCipher := TJCipherX.JavaClass.getInstance(StringToJString(RSA_TRANSFORM));
// AUCUNE authentification requise : la cle publique reconstruite ne
@@ -760,8 +772,14 @@ begin
OaepParams);
LWrapped := FromJava(LCipher.doFinal(ToJava(LAesRaw)));
LGotAuth := True;
Break;
except
LWrapped := nil; // appareil sans protection, ou refus du Keystore
on E: Exception do
begin
LWrapped := nil; // appareil sans protection, ou cle morte
FLastStoreError := E.ClassName + ': ' + E.Message;
end;
end;
end;
if LGotAuth then
+19
View File
@@ -834,6 +834,25 @@ begin
begin
FETag := LETag;
if LPwd <> '' then FSyncPwd := LPwd;
// LA PROTECTION RATTRAPE LE CACHE. Elle se decidait a l'ecriture seule,
// et une ecriture n'arrive qu'avec un instantane nouveau : un cache pose
// pendant que l'appareil n'avait pas de verrouillage restait en clair
// indefiniment, meme apres en avoir remis un. On le rescelle ici, ou l'on
// tient justement le clair.
if (not VaultNeedsAuth) and BiometricAvailable then
begin
try
SaveVault(LPlain, LETag, LPwd, True);
if VaultNeedsAuth then
Log('Cache re-sealed under authentication')
else
Log('Re-seal refused: ' + LastStoreError);
except
on E: Exception do Log('Re-seal failed: ' + E.Message);
end;
end;
try
AdoptVault(LPlain);
// Afficher d'abord, synchroniser ensuite : le coffre est a l'ecran, le