diff --git a/android-client/PMA.Biometric.pas b/android-client/PMA.Biometric.pas new file mode 100644 index 0000000..13f93e7 --- /dev/null +++ b/android-client/PMA.Biometric.pas @@ -0,0 +1,103 @@ +unit PMA.Biometric; +{ + Fine couche au-dessus de FMX.BiometricAuth. + + On passe par le service FMX plutot que par BiometricPrompt en JNI direct : + le rappel de BiometricPrompt exige une classe Java implementant une + interface, donc un .jar a compiler et embarquer. FMX fournit deja ce pont. + + MODELE RETENU : la cle du Keystore est creee avec une validite APRES + authentification (quelques dizaines de secondes). L'utilisateur s'authentifie, + puis la cle devient utilisable pendant ce delai. C'est plus simple qu'un + CryptoObject lie a chaque operation, et suffisant ici : la fenetre est courte + et l'application est au premier plan pendant ce temps. + + L'APPEL DOIT SE FAIRE SUR LE THREAD PRINCIPAL - il affiche un dialogue + systeme. Le travail lourd (dechiffrement) part ensuite dans un thread. +} + +interface + +uses + System.SysUtils; + +type + TBioOutcome = (boOK, boUnavailable, boFailed, boCancelled); + +/// L'appareil peut-il authentifier (empreinte, visage, ou code de +/// verrouillage) ? False signifie qu'aucune protection n'est configuree. +function BiometricAvailable: Boolean; + +/// Demande l'authentification puis appelle AThen. Non bloquant : le resultat +/// arrive par le rappel, sur le thread principal. +/// Si l'appareil ne sait pas authentifier, AThen est appele avec boUnavailable +/// SANS refuser l'acces - a l'appelant de decider. Un telephone sans +/// verrouillage ne peut de toute facon pas proteger la cle. +procedure AuthenticateThen(const APrompt: string; const AThen: TProc); + +implementation + +{$IF DEFINED(ANDROID)} +uses + FMX.BiometricAuth, FMX.Platform; +{$ENDIF} + +function BiometricAvailable: Boolean; +{$IF DEFINED(ANDROID)} +var + LSvc: IFMXBiometricAuthService; +begin + Result := TPlatformServices.Current.SupportsPlatformService( + IFMXBiometricAuthService, LSvc) and LSvc.CanAuthenticate; +end; +{$ELSE} +begin + Result := False; +end; +{$ENDIF} + +procedure AuthenticateThen(const APrompt: string; const AThen: TProc); +{$IF DEFINED(ANDROID)} +var + LSvc: IFMXBiometricAuthService; + LProps: TBiometricPromptProperties; +begin + if not Assigned(AThen) then Exit; + + if not (TPlatformServices.Current.SupportsPlatformService( + IFMXBiometricAuthService, LSvc) and LSvc.CanAuthenticate) then + begin + AThen(boUnavailable); + Exit; + end; + + LProps := Default(TBiometricPromptProperties); + LProps.Title := 'Deverrouiller le coffre'; + LProps.Description := APrompt; + LProps.NegativeButtonText := 'Annuler'; + LSvc.SetPromptProperties(LProps); + // DeviceCredential inclus : un telephone sans empreinte enrolee mais avec + // un code de verrouillage reste utilisable, au lieu d'etre bloque. + LSvc.SetBiometricStrengths([TBiometricStrength.Strong, + TBiometricStrength.DeviceCredential]); + + LSvc.Authenticate( + procedure + begin + AThen(boOK); + end, + procedure(const AFailReason: TBiometricFailReason; const AMessage: string) + begin + if AFailReason = TBiometricFailReason.Cancelled then + AThen(boCancelled) + else + AThen(boFailed); + end); +end; +{$ELSE} +begin + if Assigned(AThen) then AThen(boUnavailable); +end; +{$ENDIF} + +end. diff --git a/android-client/PMA.Store.pas b/android-client/PMA.Store.pas index 3ac61de..a28aad8 100644 --- a/android-client/PMA.Store.pas +++ b/android-client/PMA.Store.pas @@ -38,7 +38,15 @@ type function HasVault: Boolean; /// Ecrit le coffre et l'ETag qui l'accompagne, chiffres ensemble. -procedure SaveVault(const APlainJson, AETag: string); +/// ARequireAuth demande une cle deverrouillee par biometrie ou code. Si +/// l'appareil n'a aucune de ces protections, on retombe silencieusement sur +/// une cle sans contrainte : mieux vaut un cache lie au materiel que pas de +/// cache du tout. VaultNeedsAuth dira ensuite ce qui a reellement ete fait. +procedure SaveVault(const APlainJson, AETag: string; ARequireAuth: Boolean = False); + +/// Le coffre local exige-t-il une authentification pour etre lu ? Lu dans +/// l'en-tete du fichier, sans dechiffrer quoi que ce soit. +function VaultNeedsAuth: Boolean; /// Relit le coffre. False si absent ou illisible (cle perdue apres une /// reinstallation, fichier altere) - dans ce cas il faut retelecharger. @@ -59,7 +67,16 @@ uses const VAULT_FILE = 'vault.bin'; - KEY_ALIAS = 'pm_vault_key'; + // DEUX alias : la contrainte d'authentification est figee a la creation de + // la cle et ne peut pas etre ajoutee apres coup. Basculer d'un mode a + // l'autre change donc de cle, ce qui rend l'ancien fichier illisible - il + // sera efface et retelecharge, sans perte. + KEY_ALIAS = 'pm_vault_key'; // sans authentification + KEY_ALIAS_BIO = 'pm_vault_key_bio'; // deverrouillee par biometrie/code + // Duree pendant laquelle la cle reste utilisable apres une authentification + // reussie. Assez pour dechiffrer et afficher, trop court pour qu'un + // telephone laisse sans surveillance reste exploitable. + AUTH_VALIDITY_SECONDS = 30; // Constantes d'android.security.keystore.KeyProperties, reprises en dur // plutot qu'importees : elles sont figees par la plateforme, et cela evite // une declaration JNI de plus. @@ -156,6 +173,10 @@ type function setBlockModes(blockModes: TJavaObjectArray): JKeyGenParameterSpec_Builder; cdecl; function setEncryptionPaddings(paddings: TJavaObjectArray): JKeyGenParameterSpec_Builder; cdecl; function setKeySize(keySize: Integer): JKeyGenParameterSpec_Builder; cdecl; + function setUserAuthenticationRequired(required: Boolean): JKeyGenParameterSpec_Builder; cdecl; + // Deprecie depuis l'API 30 au profit de setUserAuthenticationParameters, + // mais toujours fonctionnel et nettement plus simple a declarer. + function setUserAuthenticationValidityDurationSeconds(seconds: Integer): JKeyGenParameterSpec_Builder; cdecl; function build: JKeyGenParameterSpec; cdecl; end; TJKeyGenParameterSpec_Builder = class(TJavaGenericImport) end; @@ -216,18 +237,21 @@ end; /// La cle du Keystore, creee a la premiere utilisation. Elle n'est jamais /// lue : on obtient une poignee vers un objet que seul le materiel peut /// utiliser. -function EnsureKey: JKeyX; +function EnsureKey(ARequireAuth: Boolean): JKeyX; var LStore: JKeyStoreX; LGen: JKeyGenerator; LBuilder: JKeyGenParameterSpec_Builder; LModes, LPaddings: TJavaObjectArray; + LAlias: string; begin + if ARequireAuth then LAlias := KEY_ALIAS_BIO else LAlias := KEY_ALIAS; + LStore := TJKeyStoreX.JavaClass.getInstance(StringToJString('AndroidKeyStore')); LStore.load(nil); // le magasin Android ne prend pas de flux - if LStore.containsAlias(StringToJString(KEY_ALIAS)) then - Exit(LStore.getKey(StringToJString(KEY_ALIAS), nil)); + if LStore.containsAlias(StringToJString(LAlias)) then + Exit(LStore.getKey(StringToJString(LAlias), nil)); LModes := TJavaObjectArray.Create(1); LModes.Items[0] := StringToJString('GCM'); @@ -235,24 +259,51 @@ begin LPaddings.Items[0] := StringToJString('NoPadding'); LBuilder := TJKeyGenParameterSpec_Builder.JavaClass.init( - StringToJString(KEY_ALIAS), PURPOSE_ENCRYPT or PURPOSE_DECRYPT); + StringToJString(LAlias), PURPOSE_ENCRYPT or PURPOSE_DECRYPT); LBuilder := LBuilder.setBlockModes(LModes); LBuilder := LBuilder.setEncryptionPaddings(LPaddings); LBuilder := LBuilder.setKeySize(256); + if ARequireAuth then + begin + LBuilder := LBuilder.setUserAuthenticationRequired(True); + LBuilder := LBuilder.setUserAuthenticationValidityDurationSeconds(AUTH_VALIDITY_SECONDS); + end; LGen := TJKeyGenerator.JavaClass.getInstance( StringToJString('AES'), StringToJString('AndroidKeyStore')); + // Leve si l'appareil n'a NI empreinte enrolee NI code de verrouillage : une + // cle protegee par authentification n'aurait alors rien pour la deverrouiller. + // L'appelant retombe sur la cle sans contrainte. LGen.init(LBuilder.build); Result := LGen.generateKey; // stockee sous l'alias, pas retournee en clair end; {$ENDIF} -procedure SaveVault(const APlainJson, AETag: string); +function VaultNeedsAuth: Boolean; +var + LFs: TFileStream; + LVer: Byte; +begin + Result := False; + if not TFile.Exists(VaultPath) then Exit; + LFs := TFileStream.Create(VaultPath, fmOpenRead or fmShareDenyWrite); + try + if LFs.Size < 1 then Exit; + LFs.ReadBuffer(LVer, 1); + Result := LVer = 2; // 1 = cle simple, 2 = cle sous authentification + finally + LFs.Free; + end; +end; + +procedure SaveVault(const APlainJson, AETag: string; ARequireAuth: Boolean); {$IF DEFINED(ANDROID)} var LWrap: TJSONObject; LPlain, LIv, LCt, LOut: TBytes; LCipher: JCipherX; + LKey: JKeyX; + LGotAuth: Boolean; begin // ETag et coffre chiffres ENSEMBLE : ils doivent rester coherents. Un ETag // qui survivrait a un coffre illisible ferait repondre 304 au serveur alors @@ -266,15 +317,31 @@ begin LWrap.Free; end; + // Repli explicite : un appareil sans empreinte NI code ne peut pas porter + // une cle protegee par authentification, et sa creation echouerait. + LGotAuth := False; + LKey := nil; + if ARequireAuth then + try + LKey := EnsureKey(True); + LGotAuth := True; + except + LKey := nil; + end; + if LKey = nil then + LKey := EnsureKey(False); + LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); - LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, EnsureKey); + LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, LKey); // L'IV est GENERE par le Keystore, jamais choisi par nous : le reutiliser // avec la meme cle romprait GCM. LIv := FromJava(LCipher.getIV); LCt := FromJava(LCipher.doFinal(ToJava(LPlain))); SetLength(LOut, 1 + Length(LIv) + Length(LCt)); - LOut[0] := 1; // version de format + // La version dit quelle cle a servi : sans elle, la relecture ne saurait + // pas s'il faut authentifier d'abord. + if LGotAuth then LOut[0] := 2 else LOut[0] := 1; Move(LIv[0], LOut[1], Length(LIv)); Move(LCt[0], LOut[1 + Length(LIv)], Length(LCt)); TFile.WriteAllBytes(VaultPath, LOut); @@ -299,7 +366,8 @@ begin try LRaw := TFile.ReadAllBytes(VaultPath); - if (Length(LRaw) < 1 + IV_LEN + 16) or (LRaw[0] <> 1) then Exit; + if (Length(LRaw) < 1 + IV_LEN + 16) then Exit; + if not (LRaw[0] in [1, 2]) then Exit; SetLength(LIv, IV_LEN); Move(LRaw[1], LIv[0], IV_LEN); @@ -307,7 +375,11 @@ begin Move(LRaw[1 + IV_LEN], LCt[0], Length(LCt)); LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); - LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE, EnsureKey, + // La cle est choisie d'apres l'en-tete. Si elle exige une + // authentification et qu'aucune n'a eu lieu recemment, l'init leve + // UserNotAuthenticatedException - attrapee plus bas, et le cache est + // efface. L'appelant doit donc authentifier AVANT (voir VaultNeedsAuth). + LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE, EnsureKey(LRaw[0] = 2), TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv))); LPlain := FromJava(LCipher.doFinal(ToJava(LCt))); @@ -321,12 +393,21 @@ begin LWrap.Free; end; except - // Cle disparue (reinstallation, effacement du Keystore) ou fichier - // altere : on ne tente rien, l'appelant retelechargera. on E: Exception do begin Result := False; - ClearVault; + // NE PAS effacer sur un simple defaut d'authentification : le coffre est + // intact, c'est l'utilisateur qui n'a pas presente son empreinte ou qui + // a laisse expirer le delai. L'effacer imposerait un retelechargement et + // 4 secondes de derivation pour une empreinte annulee. + // + // On efface en revanche quand la cle est REELLEMENT perdue + // (reinstallation, Keystore vide, nouvelle empreinte enrolee qui + // invalide la cle) ou quand le fichier est altere : dans ces cas il ne + // redeviendra jamais lisible. + if (Pos('UserNotAuthenticated', E.Message) = 0) and + (Pos('NotAuthenticated', E.ClassName) = 0) then + ClearVault; end; end; end; diff --git a/mainApk.pas b/mainApk.pas index 3e928d7..5191361 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -53,6 +53,7 @@ type FETag: string; procedure RunSelfTest(Sender: TObject); procedure RunFetch(Sender: TObject); + procedure DoFetch(const LUrl, LTok, LPwd: string); procedure CountEntries(const APlainJson: string); procedure Log(const AMsg: string); public @@ -65,7 +66,7 @@ var implementation uses - System.JSON, PMA.Crypto, PMA.TestVector, PMA.SyncClient, PMA.Store; + System.JSON, PMA.Crypto, PMA.TestVector, PMA.SyncClient, PMA.Store, PMA.Biometric; {$R *.fmx} @@ -170,6 +171,39 @@ begin LTok := FToken.Text.Trim; LPwd := FPwd.Text; + // L'authentification affiche un dialogue systeme : elle doit rester sur le + // thread principal, et PRECEDER toute lecture du coffre. La declencher + // depuis le thread de travail ne montrerait rien. + if VaultNeedsAuth then + begin + Log('Coffre local protege - authentification requise'); + AuthenticateThen('Acceder au coffre', + procedure(AOutcome: TBioOutcome) + begin + case AOutcome of + boOK: begin Log(' authentifie'); DoFetch(LUrl, LTok, LPwd); end; + boCancelled: begin Log(' annule'); FBtnFetch.Enabled := True; end; + boUnavailable: + begin + // La protection a ete retiree du telephone depuis l'ecriture du + // cache : il est devenu illisible pour toujours. + Log(' plus aucune protection sur l''appareil - cache efface'); + ClearVault; + DoFetch(LUrl, LTok, LPwd); + end; + else + Log(' echec de l''authentification'); + FBtnFetch.Enabled := True; + end; + end); + Exit; + end; + + DoFetch(LUrl, LTok, LPwd); +end; + +procedure TForm8.DoFetch(const LUrl, LTok, LPwd: string); +begin TThread.CreateAnonymousThread( procedure var @@ -247,8 +281,14 @@ begin // Ecrit APRES un dechiffrement reussi seulement, et avec son ETag : // les deux doivent rester coherents. try - SaveVault(LPlain, FETag); - Log(' coffre enregistre localement (chiffre par le Keystore)'); + // True : on DEMANDE la protection par authentification. PMA.Store + // retombe tout seul sur une cle simple si l'appareil n'a ni + // empreinte ni code, plutot que de renoncer au cache. + SaveVault(LPlain, FETag, True); + if VaultNeedsAuth then + Log(' coffre enregistre - protege par biometrie/code') + else + Log(' coffre enregistre - AUCUNE protection sur cet appareil'); except on E: Exception do Log(' cache non ecrit : ' + E.Message); diff --git a/pmserverApk.dpr b/pmserverApk.dpr index e01cd25..6fd6914 100644 --- a/pmserverApk.dpr +++ b/pmserverApk.dpr @@ -7,7 +7,8 @@ uses PMA.Crypto in 'android-client\PMA.Crypto.pas', PMA.TestVector in 'android-client\PMA.TestVector.pas', PMA.SyncClient in 'android-client\PMA.SyncClient.pas', - PMA.Store in 'android-client\PMA.Store.pas'; + PMA.Store in 'android-client\PMA.Store.pas', + PMA.Biometric in 'android-client\PMA.Biometric.pas'; {$R *.res}