From 8269d99b5becf21201cafa1b21597ca6418ae6ec Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:13:13 +0100 Subject: [PATCH] feat(apk): copie de secours au mot de passe, le coffre survit au retrait du schema Co-Authored-By: Claude Opus 5 --- android-client/PMA.Store.pas | 79 ++++++++++++++++++++++++++++++++---- mainApk.pas | 76 ++++++++++++++++++++++++++++++++-- 2 files changed, 144 insertions(+), 11 deletions(-) diff --git a/android-client/PMA.Store.pas b/android-client/PMA.Store.pas index 7536ce1..0309cf2 100644 --- a/android-client/PMA.Store.pas +++ b/android-client/PMA.Store.pas @@ -186,6 +186,19 @@ procedure SetPendingFlag(AOn: Boolean); /// Chemin du fichier, pour le journal et les tests. function VaultPath: string; +/// COPIE DE SECOURS du cache quand il est scelle par le verrouillage d'ecran. +/// +/// Retirer le schema (ou le reinitialiser) fait DETRUIRE par Android la cle qui +/// scelle vault.bin : le coffre devenait illisible et il fallait le +/// retelecharger. SaveVault ecrit donc a cote une seconde copie scellee sous le +/// mot de passe (meme format que le code d'app, VER_PIN : cle de l'appareil +/// sans contrainte + PBKDF2 du mot de passe), que rien dans Android n'invalide. +/// Elle n'existe QUE pour un cache VER_WRAP ; tout autre scellement l'efface. +function HasRecoveryVault: Boolean; +function LoadRecoveryVault(out APlainJson, AETag, ASyncPwd: string; + out AKey, ASalt: TBytes; out AIterations: Integer; + const APwd: string): Boolean; + /// Reglages du serveur : URL et jeton Bearer. /// /// FICHIER SEPARE, ET PROTECTION PLUS FAIBLE, DELIBEREMENT. Ils sont chiffres @@ -218,6 +231,7 @@ const LOCAL_FILE = 'vault-local.json'; PENDING_FILE = 'pending.flag'; SETTINGS_FILE = 'settings.bin'; + RECOVER_FILE = 'vault-recover.bin'; // Un alias par schema : la contrainte d'authentification est figee a la // creation de la cle et ne peut pas etre ajoutee apres coup. KEY_ALIAS = 'pm_vault_key'; // AES, sans authentification @@ -298,12 +312,18 @@ begin TFile.Delete(LocalContainerPath); end; +function RecoverPath: string; +begin + Result := TPath.Combine(TPath.GetHomePath, RECOVER_FILE); +end; + procedure DropLocalVault; begin // Chacun sous son propre try : un fichier absent ou verrouille ne doit pas // empecher les deux autres de partir - un effacement a moitie fait laisse // l'appareil dans un etat que personne n'a voulu. try if TFile.Exists(VaultPath) then TFile.Delete(VaultPath); except end; + try if TFile.Exists(RecoverPath) then TFile.Delete(RecoverPath); except end; try DropLocalContainer; except end; try SetPendingFlag(False); except end; end; @@ -335,6 +355,11 @@ begin Result := TPath.Combine(TPath.GetHomePath, VAULT_FILE); end; +function HasRecoveryVault: Boolean; +begin + Result := TFile.Exists(RecoverPath); +end; + function HasVault: Boolean; begin Result := TFile.Exists(VaultPath); @@ -344,6 +369,8 @@ procedure ClearVault; begin if TFile.Exists(VaultPath) then TFile.Delete(VaultPath); + if TFile.Exists(RecoverPath) then + TFile.Delete(RecoverPath); end; {$IF DEFINED(ANDROID)} @@ -919,7 +946,7 @@ begin end; end; -procedure SaveVault(const APlainJson, AETag, ASyncPwd: string; +procedure SaveVaultAt(const APath, APlainJson, AETag, ASyncPwd: string; ARequireAuth, AFreshKey: Boolean; const APin: string; const AKey, ASalt: TBytes; AIterations: Integer; AClearPin: Boolean); {$IF DEFINED(ANDROID)} @@ -1010,7 +1037,7 @@ begin LOut[0] := VER_PIN; Move(LIv[0], LOut[1], Length(LIv)); Move(LCt[0], LOut[1 + Length(LIv)], Length(LCt)); - TFile.WriteAllBytes(VaultPath, LOut); + TFile.WriteAllBytes(APath, LOut); Exit; end; @@ -1087,7 +1114,7 @@ begin Move(LCt[0], LOut[1 + Length(LIv)], Length(LCt)); end; - TFile.WriteAllBytes(VaultPath, LOut); + TFile.WriteAllBytes(APath, LOut); end; {$ELSE} begin @@ -1095,9 +1122,30 @@ begin end; {$ENDIF} -function LoadVault(out APlainJson, AETag, ASyncPwd: string; +procedure SaveVault(const APlainJson, AETag, ASyncPwd: string; + ARequireAuth, AFreshKey: Boolean; const APin: string; + const AKey, ASalt: TBytes; AIterations: Integer; AClearPin: Boolean); +begin + SaveVaultAt(VaultPath, APlainJson, AETag, ASyncPwd, ARequireAuth, AFreshKey, + APin, AKey, ASalt, AIterations, AClearPin); + // Suit le scellement REEL (lu dans l'en-tete), pas celui demande : un + // appareil sans verrouillage retombe sur VER_PLAIN, et une ecriture refusee + // laisse l'ancien en place. + try + if VaultNeedsAuth and (ASyncPwd <> '') then + SaveVaultAt(RecoverPath, APlainJson, AETag, ASyncPwd, False, False, + ASyncPwd, AKey, ASalt, AIterations, False) + else if TFile.Exists(RecoverPath) then + TFile.Delete(RecoverPath); + except + on E: Exception do + FLastStoreError := 'recovery copy not written: ' + E.Message; + end; +end; + +function LoadVaultAt(const APath: string; out APlainJson, AETag, ASyncPwd: string; out AKey, ASalt: TBytes; out AIterations: Integer; - const APin: string = ''): Boolean; + const APin: string): Boolean; {$IF DEFINED(ANDROID)} var LRaw, LIv, LCt, LPlain, LWrapped, LAesRaw, LInner, LSalt, LPinKey: TBytes; @@ -1114,10 +1162,10 @@ begin ASalt := nil; AIterations := 0; FLastStoreError := ''; - if not TFile.Exists(VaultPath) then Exit; + if not TFile.Exists(APath) then Exit; try - LRaw := TFile.ReadAllBytes(VaultPath); + LRaw := TFile.ReadAllBytes(APath); if Length(LRaw) < 1 then Exit; if LRaw[0] = VER_PIN then @@ -1268,4 +1316,21 @@ begin end; {$ENDIF} +function LoadVault(out APlainJson, AETag, ASyncPwd: string; + out AKey, ASalt: TBytes; out AIterations: Integer; + const APin: string = ''): Boolean; +begin + Result := LoadVaultAt(VaultPath, APlainJson, AETag, ASyncPwd, AKey, ASalt, + AIterations, APin); +end; + +function LoadRecoveryVault(out APlainJson, AETag, ASyncPwd: string; + out AKey, ASalt: TBytes; out AIterations: Integer; + const APwd: string): Boolean; +begin + Result := (APwd <> '') and + LoadVaultAt(RecoverPath, APlainJson, AETag, ASyncPwd, AKey, ASalt, + AIterations, APwd); +end; + end. diff --git a/mainApk.pas b/mainApk.pas index 0a05ef8..588a66b 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -130,6 +130,9 @@ type /// ouverture qui ne peut pas aboutir, et nomme le telechargement pour ce /// qu'il est : un remplacement. FVaultUnreadable: Boolean; + // La cle du verrouillage d'ecran est morte (schema retire ou reinitialise) : + // on ouvre la copie de secours, scellee sous le mot de passe. + FRecover: Boolean; // Mot de passe de sync tel qu'il a ete range avec le coffre. Il rend le // rafraichissement de fond silencieux : sans lui, chaque modification // faite sur le poste ferait apparaitre une invite au lancement. @@ -508,7 +511,8 @@ begin // coffre scelle sous un code s'ouvrait donc sans code, echouait, et l'echec // etait pris pour une corruption - l'appareil proposait de retelecharger un // coffre qu'il avait parfaitement en main. - WebCall(Format('PM.setPinNeeded(%s)', [LowerCase(BoolToStr(VaultNeedsPin, True))])); + WebCall(Format('PM.setPinNeeded(%s)', + [LowerCase(BoolToStr(VaultNeedsPin or FRecover, True))])); // LES TEMOINS SE REPOSENT ICI, A CHAQUE RETOUR SUR L'ACCUEIL. // @@ -540,7 +544,9 @@ begin LCanOpen := HasVault and not FVaultUnreadable; if LCanOpen then begin - if VaultNeedsPin then + if FRecover then + Status('Screen lock changed - type your vault password') + else if VaultNeedsPin then Status('Vault on this device - unlock code required') else if VaultNeedsAuth then Status('Vault on this device - protected') @@ -1787,6 +1793,7 @@ begin FPending := False; FETag := ''; FVaultUnreadable := False; + FRecover := False; Log('DEBUG: local vault dropped'); ShowUnlock; WebCall('PM.toast("Local vault deleted")'); @@ -1904,6 +1911,7 @@ begin Log(' ' + FVault.LiveCount.ToString + ' entries, ' + FVault.TrashCount.ToString + ' in trash'); FVaultUnreadable := False; + FRecover := False; Touch; FIdleTimer.Enabled := True; // Deja dans l'application : reconstruire la liste SANS y ramener @@ -2125,7 +2133,7 @@ procedure TForm8.OpenLocalNow; var LPlain, LETag, LPwd: string; LSw: TStopwatch; - LChainSync: Boolean; + LChainSync, LOk: Boolean; begin // LU ET CONSOMME ICI, une fois pour toutes : un drapeau qui survivrait a un // code faux ferait synchroniser la prochaine ouverture, sans rapport. @@ -2140,8 +2148,28 @@ begin // LA CLE REVIENT AVEC LE CACHE, et c'est tout l'objet de l'y avoir mise : // ouvrir depuis le cache ne derive rien, donc sans cela le PREMIER // enregistrement de chaque session repayait les quatre secondes et demie. - if LoadVault(LPlain, LETag, LPwd, FKey, FKeySalt, FKeyIters, FPin) then + if FRecover then + LOk := LoadRecoveryVault(LPlain, LETag, LPwd, FKey, FKeySalt, FKeyIters, FPin) + else + LOk := LoadVault(LPlain, LETag, LPwd, FKey, FKeySalt, FKeyIters, FPin); + if LOk then begin + if FRecover then + begin + FRecover := False; + // RESCELLER TOUT DE SUITE, sous ce qui existe maintenant : un nouveau + // verrouillage d'ecran s'il y en a un (cle neuve, l'ancienne est morte), + // sinon le mot de passe qu'on vient de taper - jamais en clair. + try + if BiometricAvailable then + SaveVault(LPlain, LETag, LPwd, True, True, '', FKey, FKeySalt, FKeyIters) + else + SaveVault(LPlain, LETag, LPwd, False, False, LPwd, FKey, FKeySalt, FKeyIters); + Log('Opened with the vault password after a screen lock change; cache re-sealed'); + except + on E: Exception do Log('Re-seal after recovery failed: ' + E.Message); + end; + end; Log('Vault opened from cache in ' + LSw.ElapsedMilliseconds.ToString + ' ms'); FETag := LETag; if LETag = '' then @@ -2272,6 +2300,25 @@ begin // l'appareil detient, et ecraserait donc l'explication. // Un code faux se retape ; le cache, lui, n'a rien perdu. Le marquer // illisible retirerait le bouton d'ouverture pour le reste de la session. + if FRecover then + begin + Log('Recovery copy did not open under this password'); + ShowUnlock; + StatusErr('Wrong vault password'); + WebCall('PM.focusPin()'); + Exit; + end; + // LA CLE VIENT DE MOURIR SOUS NOS YEUX, mais la copie de secours est la : + // on demande le mot de passe au lieu de declarer le coffre illisible. + if LastStoreKeyInvalidated and HasRecoveryVault then + begin + FRecover := True; + Log('Screen lock key destroyed - recovery copy available'); + ShowUnlock; + StatusErr('Screen lock changed - type your vault password'); + WebCall('PM.focusPin()'); + Exit; + end; if VaultNeedsPin then begin Log('Vault did not open under this unlock code'); @@ -2319,6 +2366,27 @@ begin WebCall('PM.focusPin()'); Exit; end; + // PLUS DE VERROUILLAGE D'ECRAN : la cle qui scelle le cache est morte, et + // demander l'empreinte ne pourrait que repondre "indisponible". La copie de + // secours s'ouvre au mot de passe. + if (not FRecover) and VaultNeedsAuth and HasRecoveryVault and + (not BiometricAvailable) then + begin + FRecover := True; + Log('No screen lock any more - recovery copy available'); + ShowUnlock; + end; + if FRecover then + begin + if FPin = '' then + begin + StatusErr('Screen lock changed - type your vault password'); + WebCall('PM.focusPin()'); + Exit; + end; + OpenLocalNow; + Exit; + end; // L'authentification affiche un dialogue systeme : elle doit rester sur le // thread principal, et PRECEDER toute lecture du coffre. if not VaultNeedsAuth then