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 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-21 12:10:51 +01:00
parent 3e020acf24
commit a41eeb8bfa
3 changed files with 170 additions and 17 deletions
+14 -5
View File
@@ -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')