From a41eeb8bfa6eeef699d438493ee478315d43ba7a Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:10:51 +0100 Subject: [PATCH] feat(android): an unlock code that survives a screen-lock change The reported pain: changing the pattern destroys the local cache every time. That is Android doing exactly what it promises - a key created with setUserAuthenticationRequired is invalidated the moment the screen lock changes, so a new lock cannot open data sealed under the old one. No storage format changes that, which is why SQLite would not have helped: the problem is what the key is tied to, not what the file looks like. So the cache gains a third sealing format, VER_PIN, with two layers: inner - AES-GCM under PBKDF2(code, 100k) : it takes the code outer - the Keystore AES key WITHOUT the auth constraint : it takes the device The outer key is never invalidated, so the file survives any change to the screen lock; the code restores the protection that removing the constraint would otherwise cost. 100k iterations rather than 600k because a four to six digit code carries little entropy - more rounds mostly slow the honest user, and the real barrier is the outer layer requiring this phone. The code lives in a fourth settings field and is NEVER stored: empty at every launch, typed to open, and used to re-seal on every write. That is what separates it from the token, whose persistence is a convenience with no consequence since it only opens ciphertext. Built but NOT installed - the phone went off the network. Co-Authored-By: Claude Opus 5 --- android-client/PMA.Store.pas | 150 ++++++++++++++++++++++++++++++++--- android-client/web/app.html | 18 ++++- mainApk.pas | 19 +++-- 3 files changed, 170 insertions(+), 17 deletions(-) diff --git a/android-client/PMA.Store.pas b/android-client/PMA.Store.pas index f5c53b0..b67b716 100644 --- a/android-client/PMA.Store.pas +++ b/android-client/PMA.Store.pas @@ -83,8 +83,16 @@ function HasVault: Boolean; /// protection vient de reapparaitre sur l'appareil : le changement qui l'a /// ramenee a tres probablement invalide l'ancienne cle, et ca ne se verrait /// qu'a la prochaine lecture - trop tard, le fichier serait deja scelle. +/// APin non vide : le cache est scelle sous ce code (VER_PIN), et ARequireAuth +/// est alors ignore - les deux protections ne se cumulent pas, et celle du code +/// est la seule qui survive a un changement de verrouillage d'ecran. procedure SaveVault(const APlainJson, AETag, ASyncPwd: string; - ARequireAuth: Boolean = False; AFreshKey: Boolean = False); + ARequireAuth: Boolean = False; AFreshKey: Boolean = False; + const APin: string = ''); + +/// Le fichier local est-il scelle sous un code d'application ? Lu dans +/// l'en-tete, sans rien dechiffrer. +function VaultNeedsPin: Boolean; /// Le coffre local exige-t-il une authentification pour etre lu ? Lu dans /// l'en-tete du fichier, sans dechiffrer quoi que ce soit. @@ -94,7 +102,8 @@ function VaultNeedsAuth: Boolean; /// reinstallation, fichier altere) - dans ce cas il faut retelecharger. /// 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; +function LoadVault(out APlainJson, AETag, ASyncPwd: string; + const APin: string = ''): Boolean; /// Pourquoi la derniere lecture a echoue. Vide apres un succes. /// @@ -129,7 +138,11 @@ implementation {$IF DEFINED(ANDROID)} uses - Androidapi.Jni, Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers; + // PMA.Crypto pour PBKDF2 et SecureRandomBytes : le code de l'application + // derive sa cle exactement comme le conteneur de sync, avec la meme + // implementation eprouvee contre le vecteur de test. + Androidapi.Jni, Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers, + PMA.Crypto; {$ENDIF} const @@ -155,6 +168,22 @@ const // et s'il faut authentifier d'abord. VER_PLAIN = 1; // AES Keystore sans contrainte VER_WRAP = 3; // AES aleatoire enveloppee par la cle publique RSA + // CODE CHOISI DANS L'APPLICATION, deux couches superposees : + // interieur : AES-GCM sous PBKDF2(code) -> il faut le code + // exterieur : cle AES du Keystore SANS contrainte -> il faut l'appareil + // + // C'est le seul schema qui survive a un changement de verrouillage d'ecran. + // VER_WRAP y meurt par construction : Android invalide une cle exigeant + // l'authentification des que le code de l'ecran change, precisement pour + // qu'un nouveau code n'ouvre pas d'anciennes donnees. La cle sans contrainte, + // elle, n'est pas invalidee - et le code de l'application rend la protection + // que cela retire. + VER_PIN = 4; + // 100k et non 600k : l'entropie d'un code a quatre ou six chiffres est + // faible, et multiplier les tours ralentit surtout l'usage honnete. La vraie + // barriere est la couche exterieure, qui exige l'appareil. + PIN_ITERS = 100000; + SALT_LEN = 16; RSA_TRANSFORM = 'RSA/ECB/OAEPWithSHA-256AndMGF1Padding'; var @@ -718,6 +747,26 @@ begin end; {$ENDIF} +function VaultNeedsPin: Boolean; +{$IF DEFINED(ANDROID)} +var + LRaw: TBytes; +begin + Result := False; + if not TFile.Exists(VaultPath) then Exit; + try + LRaw := TFile.ReadAllBytes(VaultPath); + Result := (Length(LRaw) > 0) and (LRaw[0] = VER_PIN); + except + // Illisible : l'appelant le decouvrira en tentant de l'ouvrir. + end; +end; +{$ELSE} +begin + Result := False; +end; +{$ENDIF} + function VaultNeedsAuth: Boolean; var LFs: TFileStream; @@ -736,11 +785,11 @@ begin end; procedure SaveVault(const APlainJson, AETag, ASyncPwd: string; - ARequireAuth, AFreshKey: Boolean); + ARequireAuth, AFreshKey: Boolean; const APin: string); {$IF DEFINED(ANDROID)} var LWrap: TJSONObject; - LPlain, LIv, LCt, LOut, LAesRaw, LWrapped: TBytes; + LPlain, LIv, LCt, LOut, LAesRaw, LWrapped, LSalt, LPinKey, LInner: TBytes; LCipher: JCipherX; LGotAuth: Boolean; LOfs: Integer; @@ -758,6 +807,48 @@ begin LWrap.Free; end; + if APin <> '' then + begin + // COUCHE INTERIEURE : le code. Sel neuf a chaque ecriture - il ne coute + // rien et evite qu'un meme code produise deux fois la meme cle. + LSalt := SecureRandomBytes(SALT_LEN); + LPinKey := DeriveKeyPBKDF2Native(APin, LSalt, PIN_ITERS, 32); + if LPinKey = nil then + LPinKey := DeriveKeyPBKDF2(APin, LSalt, PIN_ITERS, 32); + LIv := RandomBytes(IV_LEN); + LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); + LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, + TJSecretKeySpecX.JavaClass.init(ToJava(LPinKey), StringToJString('AES')), + TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv))); + LCt := FromJava(LCipher.doFinal(ToJava(LPlain))); + + // Sel et iterations voyagent EN CLAIR avec la charge : ils ne sont pas + // secrets, et sans eux le code ne redonnerait pas la meme cle. + SetLength(LInner, SALT_LEN + 4 + IV_LEN + Length(LCt)); + LOfs := 0; + Move(LSalt[0], LInner[LOfs], SALT_LEN); Inc(LOfs, SALT_LEN); + LInner[LOfs] := Byte(PIN_ITERS shr 24); LInner[LOfs+1] := Byte(PIN_ITERS shr 16); + LInner[LOfs+2] := Byte(PIN_ITERS shr 8); LInner[LOfs+3] := Byte(PIN_ITERS); + Inc(LOfs, 4); + Move(LIv[0], LInner[LOfs], IV_LEN); Inc(LOfs, IV_LEN); + Move(LCt[0], LInner[LOfs], Length(LCt)); + + // COUCHE EXTERIEURE : la cle AES du Keystore, sans contrainte. Elle lie le + // fichier a l'appareil sans jamais etre invalidee par un changement de + // verrouillage d'ecran - c'est tout l'objet de ce format. + LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); + LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, EnsureAesKey); + LIv := FromJava(LCipher.getIV); + LCt := FromJava(LCipher.doFinal(ToJava(LInner))); + + SetLength(LOut, 1 + Length(LIv) + Length(LCt)); + LOut[0] := VER_PIN; + Move(LIv[0], LOut[1], Length(LIv)); + Move(LCt[0], LOut[1 + Length(LIv)], Length(LCt)); + TFile.WriteAllBytes(VaultPath, LOut); + Exit; + end; + LGotAuth := False; LWrapped := nil; if ARequireAuth then @@ -839,14 +930,15 @@ begin end; {$ENDIF} -function LoadVault(out APlainJson, AETag, ASyncPwd: string): Boolean; +function LoadVault(out APlainJson, AETag, ASyncPwd: string; + const APin: string = ''): Boolean; {$IF DEFINED(ANDROID)} var - LRaw, LIv, LCt, LPlain, LWrapped, LAesRaw: TBytes; + LRaw, LIv, LCt, LPlain, LWrapped, LAesRaw, LInner, LSalt, LPinKey: TBytes; LCipher: JCipherX; LWrap: TJSONObject; LStore: JKeyStoreX; - LLen, LOfs: Integer; + LLen, LOfs, LIter: Integer; begin Result := False; APlainJson := ''; @@ -859,7 +951,47 @@ begin LRaw := TFile.ReadAllBytes(VaultPath); if Length(LRaw) < 1 then Exit; - if LRaw[0] = VER_WRAP then + if LRaw[0] = VER_PIN then + begin + if APin = '' then + begin + FLastStoreError := 'Code requis'; + Exit; + end; + // Couche exterieure d'abord : la cle de l'appareil, sans authentification. + SetLength(LIv, IV_LEN); + Move(LRaw[1], LIv[0], IV_LEN); + SetLength(LCt, Length(LRaw) - 1 - IV_LEN); + Move(LRaw[1 + IV_LEN], LCt[0], Length(LCt)); + LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); + LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE, EnsureAesKey, + TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv))); + LInner := FromJava(LCipher.doFinal(ToJava(LCt))); + + if Length(LInner) < SALT_LEN + 4 + IV_LEN + 16 then Exit; + LOfs := 0; + SetLength(LSalt, SALT_LEN); + Move(LInner[LOfs], LSalt[0], SALT_LEN); Inc(LOfs, SALT_LEN); + LIter := (LInner[LOfs] shl 24) or (LInner[LOfs+1] shl 16) + or (LInner[LOfs+2] shl 8) or LInner[LOfs+3]; + Inc(LOfs, 4); + SetLength(LIv, IV_LEN); + Move(LInner[LOfs], LIv[0], IV_LEN); Inc(LOfs, IV_LEN); + SetLength(LCt, Length(LInner) - LOfs); + Move(LInner[LOfs], LCt[0], Length(LCt)); + + // Couche interieure : le code. Un code faux echoue ICI, sur le tag GCM, + // et se lit donc comme une alteration - c'est le meme evenement. + LPinKey := DeriveKeyPBKDF2Native(APin, LSalt, LIter, 32); + if LPinKey = nil then + LPinKey := DeriveKeyPBKDF2(APin, LSalt, LIter, 32); + LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); + LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE, + TJSecretKeySpecX.JavaClass.init(ToJava(LPinKey), StringToJString('AES')), + TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv))); + LPlain := FromJava(LCipher.doFinal(ToJava(LCt))); + end + else if LRaw[0] = VER_WRAP then begin if Length(LRaw) < 3 then Exit; LLen := (LRaw[1] shl 8) or LRaw[2]; diff --git a/android-client/web/app.html b/android-client/web/app.html index c1018b8..2aac1a5 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -261,6 +261,14 @@ type="password" autocomplete="off">
×
+ +
+ +
×
+
@@ -345,7 +353,8 @@ var entries = [], folders = [], folder = '', query = '', view = 'unlock'; // --- deverrouillage -------------------------------------------------------- var elUrl = document.getElementById('url'), elTok = document.getElementById('tok'), - elPwd = document.getElementById('pwd'); + elPwd = document.getElementById('pwd'), + elPin = document.getElementById('pin'); // Les trois valeurs remontent A LA PERTE DU FOCUS, pas a chaque touche : c'est // une navigation par champ modifie au lieu d'une par caractere. Delphi les @@ -353,14 +362,16 @@ var elUrl = document.getElementById('url'), function pushConfig() { send('cfg?u=' + encodeURIComponent(elUrl.value.trim()) + '&t=' + encodeURIComponent(elTok.value.trim()) - + '&p=' + encodeURIComponent(elPwd.value)); + + '&p=' + encodeURIComponent(elPwd.value) + + '&c=' + encodeURIComponent(elPin.value)); } -[elUrl, elTok, elPwd].forEach(function (el) { +[elUrl, elTok, elPwd, elPin].forEach(function (el) { el.addEventListener('change', pushConfig); el.addEventListener('blur', pushConfig); }); document.getElementById('btnOpen').addEventListener('click', function () { + pushConfig(); // le code vient peut-etre d'etre tape send('openvault'); }); document.getElementById('btnSync').addEventListener('click', function () { @@ -640,6 +651,7 @@ var PM = { s.style.opacity = busy ? .4 : 1; }, setConfig: function (url, tok, pwd, show) { + // elPin volontairement absent : rien ne le restaure, jamais. elUrl.value = url; elTok.value = tok; elPwd.value = pwd; // Replier des champs vides revient a cacher la seule chose a faire. document.getElementById('cfg').hidden = !show; diff --git a/mainApk.pas b/mainApk.pas index 268f092..8242ba6 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -114,6 +114,14 @@ type /// Coordonnees du serveur. Delphi en est le detenteur ; la page ne fait /// que les editer et les lui renvoyer quand un champ est quitte. FUrl, FToken, FPwd: string; + /// Code d'ouverture du cache local, choisi dans l'application. JAMAIS + /// ENREGISTRE : il est vide a chaque lancement et doit etre retape. C'est + /// ce qui le distingue du jeton, dont la persistance est un confort sans + /// consequence puisqu'il n'ouvre que du chiffre. + /// + /// Il remplace la protection par verrouillage d'ecran, qu'Android detruit + /// des qu'on change le schema - par conception, et sans recours. + FPin: string; // --- l'interface entiere, rendue par le moteur du systeme FLayWeb: TLayout; @@ -639,7 +647,7 @@ begin // ouverture pour retrouver ce qu'on vient d'ecrire. FETag := LPut.ETag; try - SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable); + SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False, FPin); except on E: Exception do Log(' cache not written: ' + E.Message); end; @@ -869,6 +877,7 @@ begin FUrl := TNetEncoding.URL.Decode(Param('u')).Trim; FToken := TNetEncoding.URL.Decode(Param('t')).Trim; FPwd := TNetEncoding.URL.Decode(Param('p')); + FPin := TNetEncoding.URL.Decode(Param('c')); // L'empreinte du mot de passe AUSSI : si une saisie destinee au jeton // atterrit dans le champ voisin - les deux sont masques - c'est celle-ci // qui bouge, et le jeton reste inchange sans que rien ne l'explique. @@ -981,7 +990,7 @@ begin // scelle, la mise en forme vers la page, ou le rafraichissement de fond qui // suit - sinon on cherche au hasard. LSw := TStopwatch.StartNew; - if LoadVault(LPlain, LETag, LPwd) then + if LoadVault(LPlain, LETag, LPwd, FPin) then begin Log('Vault opened from cache in ' + LSw.ElapsedMilliseconds.ToString + ' ms'); FETag := LETag; @@ -1011,7 +1020,7 @@ begin // protection a tres probablement invalide l'ancienne, et le // chiffrement ne saurait pas le voir - on scellerait un fichier // impossible a rouvrir. - SaveVault(LPlain, LETag, LPwd, True, True); + SaveVault(LPlain, LETag, LPwd, True, True, FPin); if VaultNeedsAuth then Log('Cache re-sealed under authentication') else @@ -1194,7 +1203,7 @@ begin 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, LCachedPwd) then + if (FETag = '') and LoadVault(LCached, LCachedETag, LCachedPwd, FPin) then FETag := LCachedETag; LClient := TSyncClient.Create(AUrl, ATok); @@ -1341,7 +1350,7 @@ begin try // 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, LCanAuth); + SaveVault(LPlain, FETag, APwd, LCanAuth, False, FPin); Log(' cache now holds sha: ' + FETag); if VaultNeedsAuth then Log(' cache written - protected by biometrics/PIN')