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
+141 -9
View File
@@ -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];