From e4a50df64d284836d94d0401b0527aea3d3f78aa Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:11:48 +0100 Subject: [PATCH] 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 --- android-client/PMA.Store.pas | 54 ++++++++++++++++++++++++------------ mainApk.pas | 19 +++++++++++++ 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/android-client/PMA.Store.pas b/android-client/PMA.Store.pas index 7948158..20154c5 100644 --- a/android-client/PMA.Store.pas +++ b/android-client/PMA.Store.pas @@ -408,6 +408,7 @@ type function getKey(alias: JString; password: TJavaArray): JKeyX; cdecl; function getCertificate(alias: JString): JCertificateX; cdecl; function containsAlias(alias: JString): Boolean; cdecl; + procedure deleteEntry(alias: JString); cdecl; end; TJKeyStoreX = class(TJavaGenericImport) 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; 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.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,21 +757,30 @@ begin LGotAuth := False; LWrapped := nil; if ARequireAuth then - try - EnsureWrapKey; - // 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 - // porte plus les contraintes de la privee. C'est tout l'interet. - LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, UnrestrictedPublicKey, - OaepParams); - LWrapped := FromJava(LCipher.doFinal(ToJava(LAesRaw))); - LGotAuth := True; - except - LWrapped := nil; // appareil sans protection, ou refus du Keystore - end; + begin + // Deux essais : le second apres avoir jete une cle devenue inutilisable. + for LAttempt := 0 to 1 do + try + 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 + // porte plus les contraintes de la privee. C'est tout l'interet. + LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, UnrestrictedPublicKey, + OaepParams); + LWrapped := FromJava(LCipher.doFinal(ToJava(LAesRaw))); + LGotAuth := True; + Break; + except + on E: Exception do + begin + LWrapped := nil; // appareil sans protection, ou cle morte + FLastStoreError := E.ClassName + ': ' + E.Message; + end; + end; + end; if LGotAuth then begin diff --git a/mainApk.pas b/mainApk.pas index c1fc5e2..1fbb169 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -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