Files
Password-Manager/android-client/PMA.Store.pas
T
r-zakarya 8dc0840e03 fix(android): declare FMX's biometric activity, and stop LoadVault from deleting the cache
The prompt raised ActivityNotFoundException: FMX does not show BiometricPrompt from our NativeActivity, it launches its own BiometricFragmentActivity, which lives in fmx.dex.jar but has to be declared in the manifest. Given a translucent framework theme - it hosts the system dialog and draws nothing itself, and the generated resources contain no AppCompat.

Then the read failed and the cache vanished with no trace. LoadVault sorted 'key lost' from 'not authenticated' on the exception message and deleted the file when it guessed the former - so a wrong guess destroyed the cache and the only record of why. It now reports through LastStoreError and deletes nothing; the caller has the log and the context. OpenLocalNow shows the reason, after ShowUnlock rather than before, which was overwriting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:11:20 +01:00

782 lines
30 KiB
ObjectPascal

unit PMA.Store;
{
Coffre garde localement, chiffre sous une cle du Android Keystore.
POURQUOI : ouvrir un conteneur coute ~4,5 s (PBKDF2 600k, incompressible - le
nombre d'iterations appartient au format ecrit par le poste Windows). Payer
ce prix a chaque ouverture de l'application serait absurde alors que le
contenu n'a pas change. On ne le paie donc qu'a l'arrivee d'un instantane
NOUVEAU, et on relit ensuite un fichier local chiffre en AES - accelere
materiellement, donc instantane.
LA CLE NE QUITTE JAMAIS LE MATERIEL. Elle est generee dans le Keystore et
n'en sort pas. Un vol du fichier seul est donc inexploitable, meme root -
il faut la puce.
---------------------------------------------------------------------------
POURQUOI UNE PAIRE DE CLES ET NON UNE CLE AES
Mesure sur appareil, 2026-08-15 :
cache non ecrit : EJNIException -
android.security.keystore.UserNotAuthenticatedException: User not authenticated
Une cle AES creee avec setUserAuthenticationRequired exige une
authentification recente **pour chiffrer autant que pour dechiffrer**. Or
l'ecriture suit une synchronisation silencieuse, sans invite : passe la
fenetre de validite, Cipher.init(ENCRYPT_MODE) leve, et le cache ne s'ecrit
jamais. La toute premiere ecriture ne reussissait que parce que le telephone
venait d'etre deverrouille - un test qui passe, puis ne passe plus.
Le motif documente par Android pour « ecrire a tout moment, lire sous
authentification » est une paire de cles : la partie publique chiffre sans
invite, la partie privee - elle protegee - dechiffre. RSA ne chiffrant pas
16 Ko, c'est un chiffrement hybride : une cle AES aleatoire par ecriture,
enveloppee par la cle publique.
DEUX PIEGES, tous deux corriges ici :
1. La cle publique rendue par le Keystore PORTE LES CONTRAINTES de la privee :
s'en servir telle quelle pour chiffrer releve la meme exception. Il faut la
reconstruire depuis sa forme encodee (KeyFactory + X509EncodedKeySpec), ce
qui donne une cle publique ordinaire, sans contrainte.
2. Cette reconstruction change de fournisseur, et « OAEPWithSHA-256AndMGF1
Padding » ne designe PAS le meme MGF1 des deux cotes - le Keystore prend
SHA-1 par defaut la ou le nom suggere SHA-256. Les parametres OAEP sont
donc passes EXPLICITEMENT au chiffrement comme au dechiffrement.
---------------------------------------------------------------------------
CE QUE CE FICHIER CONTIENT : tous les mots de passe en clair une fois
dechiffre, ET le mot de passe de sync (voir SaveVault). D'ou trois regles :
- jamais hors du repertoire prive de l'application ;
- jamais de copie en clair sur le disque, meme temporaire ;
- la sauvegarde automatique Android doit rester desactivee pour l'app
(android:allowBackup="false"), sinon Google en recupere une copie.
}
interface
uses
System.SysUtils, System.Classes, System.JSON, System.IOUtils;
type
EPMAStore = class(Exception);
/// Le coffre local existe-t-il ?
function HasVault: Boolean;
/// Ecrit le coffre, l'ETag qui l'accompagne et le mot de passe de sync,
/// chiffres ensemble.
///
/// POURQUOI LE MOT DE PASSE EST LA : sans lui, un instantane nouveau ne peut
/// pas etre ouvert sans intervention, et chaque modification faite sur le
/// poste ferait apparaitre une demande de mot de passe au lancement - que
/// l'utilisateur finirait par expedier sans la lire. Le fichier contient deja
/// tous les mots de passe en clair ; celui-ci n'y ajoute presque rien en
/// exposition, et rend la synchronisation silencieuse.
///
/// ARequireAuth demande la protection par authentification. Si l'appareil n'a
/// ni empreinte ni code, on retombe sur une cle AES 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, ASyncPwd: 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.
/// 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;
/// Pourquoi la derniere lecture a echoue. Vide apres un succes.
///
/// LoadVault N'EFFACE RIEN de lui-meme. Un cache detruit par une routine de
/// lecture, c'est un diagnostic errone qui coute un retelechargement et
/// 4,5 s de derivation - et qui emporte la cause avec lui. Elle rapporte,
/// l'appelant decide.
function LastStoreError: string;
/// Efface le coffre local. Seul un geste explicite de l'utilisateur, ou une
/// certitude, devrait l'appeler : il se retelecharge, mais pas gratuitement.
procedure ClearVault;
/// Chemin du fichier, pour le journal et les tests.
function VaultPath: string;
implementation
{$IF DEFINED(ANDROID)}
uses
Androidapi.Jni, Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers;
{$ENDIF}
const
VAULT_FILE = 'vault.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
KEY_ALIAS_WRAP = 'pm_vault_wrap'; // RSA, privee sous authentification
// Duree pendant laquelle la cle PRIVEE reste utilisable apres une
// authentification reussie. Ne concerne plus l'ecriture, qui n'en depend
// pas : 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.
PURPOSE_ENCRYPT = 1;
PURPOSE_DECRYPT = 2;
IV_LEN = 12; // GCM : 12 octets, la longueur recommandee
AES_LEN = 32; // AES-256
RSA_BITS = 2048;
// Octet d'en-tete : dit quel schema a ecrit le fichier, donc comment le lire
// 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
RSA_TRANSFORM = 'RSA/ECB/OAEPWithSHA-256AndMGF1Padding';
var
FLastStoreError: string = '';
function LastStoreError: string;
begin
Result := FLastStoreError;
end;
function VaultPath: string;
begin
// GetHomePath sur Android = /data/data/<paquet>/files, prive a l'app.
Result := TPath.Combine(TPath.GetHomePath, VAULT_FILE);
end;
function HasVault: Boolean;
begin
Result := TFile.Exists(VaultPath);
end;
procedure ClearVault;
begin
if TFile.Exists(VaultPath) then
TFile.Delete(VaultPath);
end;
{$IF DEFINED(ANDROID)}
type
JKeyStoreX = interface;
JSecretKeyX = interface;
JKeyGenerator = interface;
JKeyGenParameterSpec = interface;
JKeyGenParameterSpec_Builder = interface;
JCipherX = interface;
JGCMParameterSpecX = interface;
JAlgorithmParameterSpecX = interface;
JKeyX = interface;
JPublicKeyX = interface;
JPrivateKeyX = interface;
JCertificateX = interface;
JKeySpecX = interface;
JX509EncodedKeySpec = interface;
JKeyFactoryX = interface;
JKeyPairX = interface;
JKeyPairGeneratorX = interface;
JSecureRandomX = interface;
JSecretKeySpecX = interface;
JMGF1ParameterSpec = interface;
JPSourceX = interface;
JPSource_PSpecified = interface;
JOAEPParameterSpec = interface;
JKeyXClass = interface(IJavaClass)
['{2C1A0B3D-4E5F-6A7B-8C9D-0E1F2A3B4C5D}']
end;
[JavaSignature('java/security/Key')]
JKeyX = interface(IJavaInstance)
['{3D2B1C4E-5F60-7B8C-9DAE-1F2A3B4C5D6E}']
function getEncoded: TJavaArray<Byte>; cdecl;
function getAlgorithm: JString; cdecl;
end;
TJKeyX = class(TJavaGenericImport<JKeyXClass, JKeyX>) end;
JSecretKeyXClass = interface(JKeyXClass)
['{4E3C2D5F-6071-8C9D-AEBF-2A3B4C5D6E7F}']
end;
[JavaSignature('javax/crypto/SecretKey')]
JSecretKeyX = interface(JKeyX)
['{5F4D3E60-7182-9DAE-BFC0-3B4C5D6E7F80}']
end;
TJSecretKeyX = class(TJavaGenericImport<JSecretKeyXClass, JSecretKeyX>) end;
JSecretKeySpecXClass = interface(JSecretKeyXClass)
['{11223344-5566-7788-99AA-BBCCDDEEFF00}']
function init(key: TJavaArray<Byte>; algorithm: JString): JSecretKeySpecX; cdecl;
end;
[JavaSignature('javax/crypto/spec/SecretKeySpec')]
JSecretKeySpecX = interface(JSecretKeyX)
['{22334455-6677-8899-AABB-CCDDEEFF0011}']
end;
TJSecretKeySpecX = class(TJavaGenericImport<JSecretKeySpecXClass, JSecretKeySpecX>) end;
JPublicKeyXClass = interface(JKeyXClass)
['{33445566-7788-99AA-BBCC-DDEEFF001122}']
end;
[JavaSignature('java/security/PublicKey')]
JPublicKeyX = interface(JKeyX)
['{44556677-8899-AABB-CCDD-EEFF00112233}']
end;
TJPublicKeyX = class(TJavaGenericImport<JPublicKeyXClass, JPublicKeyX>) end;
JPrivateKeyXClass = interface(JKeyXClass)
['{55667788-99AA-BBCC-DDEE-FF0011223344}']
end;
[JavaSignature('java/security/PrivateKey')]
JPrivateKeyX = interface(JKeyX)
['{66778899-AABB-CCDD-EEFF-001122334455}']
end;
TJPrivateKeyX = class(TJavaGenericImport<JPrivateKeyXClass, JPrivateKeyX>) end;
JCertificateXClass = interface(JObjectClass)
['{778899AA-BBCC-DDEE-FF00-112233445566}']
end;
[JavaSignature('java/security/cert/Certificate')]
JCertificateX = interface(JObject)
['{8899AABB-CCDD-EEFF-0011-223344556677}']
function getPublicKey: JPublicKeyX; cdecl;
end;
TJCertificateX = class(TJavaGenericImport<JCertificateXClass, JCertificateX>) end;
JKeySpecXClass = interface(IJavaClass)
['{99AABBCC-DDEE-FF00-1122-334455667788}']
end;
[JavaSignature('java/security/spec/KeySpec')]
JKeySpecX = interface(IJavaInstance)
['{AABBCCDD-EEFF-0011-2233-445566778899}']
end;
TJKeySpecX = class(TJavaGenericImport<JKeySpecXClass, JKeySpecX>) end;
JX509EncodedKeySpecClass = interface(JKeySpecXClass)
['{BBCCDDEE-FF00-1122-3344-5566778899AA}']
function init(encodedKey: TJavaArray<Byte>): JX509EncodedKeySpec; cdecl;
end;
[JavaSignature('java/security/spec/X509EncodedKeySpec')]
JX509EncodedKeySpec = interface(JKeySpecX)
['{CCDDEEFF-0011-2233-4455-66778899AABB}']
end;
TJX509EncodedKeySpec = class(TJavaGenericImport<JX509EncodedKeySpecClass, JX509EncodedKeySpec>) end;
JKeyFactoryXClass = interface(JObjectClass)
['{DDEEFF00-1122-3344-5566-778899AABBCC}']
function getInstance(algorithm: JString): JKeyFactoryX; cdecl;
end;
[JavaSignature('java/security/KeyFactory')]
JKeyFactoryX = interface(JObject)
['{EEFF0011-2233-4455-6677-8899AABBCCDD}']
function generatePublic(keySpec: JKeySpecX): JPublicKeyX; cdecl;
end;
TJKeyFactoryX = class(TJavaGenericImport<JKeyFactoryXClass, JKeyFactoryX>) end;
JKeyPairXClass = interface(JObjectClass)
['{FF001122-3344-5566-7788-99AABBCCDDEE}']
end;
[JavaSignature('java/security/KeyPair')]
JKeyPairX = interface(JObject)
['{00112233-4455-6677-8899-AABBCCDDEEFF}']
end;
TJKeyPairX = class(TJavaGenericImport<JKeyPairXClass, JKeyPairX>) end;
JAlgorithmParameterSpecXClass = interface(IJavaClass)
['{605E4F71-8293-AEBF-C0D1-4C5D6E7F8091}']
end;
[JavaSignature('java/security/spec/AlgorithmParameterSpec')]
JAlgorithmParameterSpecX = interface(IJavaInstance)
['{716F5082-93A4-BFC0-D1E2-5D6E7F8091A2}']
end;
TJAlgorithmParameterSpecX = class(TJavaGenericImport<JAlgorithmParameterSpecXClass, JAlgorithmParameterSpecX>) end;
JKeyPairGeneratorXClass = interface(JObjectClass)
['{12233445-5667-7889-9AAB-BCCDDEEFF001}']
function getInstance(algorithm: JString; provider: JString): JKeyPairGeneratorX; cdecl;
end;
[JavaSignature('java/security/KeyPairGenerator')]
JKeyPairGeneratorX = interface(JObject)
['{23344556-6778-899A-ABBC-CDDEEFF00112}']
procedure initialize(params: JAlgorithmParameterSpecX); cdecl;
function generateKeyPair: JKeyPairX; cdecl;
end;
TJKeyPairGeneratorX = class(TJavaGenericImport<JKeyPairGeneratorXClass, JKeyPairGeneratorX>) end;
JSecureRandomXClass = interface(JObjectClass)
['{34455667-7889-9AAB-BCCD-DEEFF0011223}']
function init: JSecureRandomX; cdecl;
end;
[JavaSignature('java/security/SecureRandom')]
JSecureRandomX = interface(JObject)
['{45566778-899A-ABBC-CDDE-EFF001122334}']
procedure nextBytes(bytes: TJavaArray<Byte>); cdecl;
end;
TJSecureRandomX = class(TJavaGenericImport<JSecureRandomXClass, JSecureRandomX>) end;
JMGF1ParameterSpecClass = interface(JAlgorithmParameterSpecXClass)
['{56677889-9AAB-BCCD-DEEF-F00112233445}']
function init(mdName: JString): JMGF1ParameterSpec; cdecl;
end;
[JavaSignature('java/security/spec/MGF1ParameterSpec')]
JMGF1ParameterSpec = interface(JAlgorithmParameterSpecX)
['{6778899A-ABBC-CDDE-EFF0-011223344556}']
end;
TJMGF1ParameterSpec = class(TJavaGenericImport<JMGF1ParameterSpecClass, JMGF1ParameterSpec>) end;
JPSourceXClass = interface(JObjectClass)
['{78899AAB-BCCD-DEEF-F001-122334455667}']
end;
[JavaSignature('javax/crypto/spec/PSource')]
JPSourceX = interface(JObject)
['{8899AABB-CCDD-EEFF-0011-223344556678}']
end;
TJPSourceX = class(TJavaGenericImport<JPSourceXClass, JPSourceX>) end;
JPSource_PSpecifiedClass = interface(JPSourceXClass)
['{99AABBCC-DDEE-FF00-1122-334455667789}']
function _GetDEFAULT: JPSource_PSpecified; cdecl;
property DEFAULT: JPSource_PSpecified read _GetDEFAULT;
end;
[JavaSignature('javax/crypto/spec/PSource$PSpecified')]
JPSource_PSpecified = interface(JPSourceX)
['{AABBCCDD-EEFF-0011-2233-44556677889A}']
end;
TJPSource_PSpecified = class(TJavaGenericImport<JPSource_PSpecifiedClass, JPSource_PSpecified>) end;
JOAEPParameterSpecClass = interface(JAlgorithmParameterSpecXClass)
['{BBCCDDEE-FF00-1122-3344-556677889AAB}']
function init(mdName: JString; mgfName: JString;
mgfSpec: JAlgorithmParameterSpecX; pSrc: JPSourceX): JOAEPParameterSpec; cdecl;
end;
[JavaSignature('javax/crypto/spec/OAEPParameterSpec')]
JOAEPParameterSpec = interface(JAlgorithmParameterSpecX)
['{CCDDEEFF-0011-2233-4455-66778899AABC}']
end;
TJOAEPParameterSpec = class(TJavaGenericImport<JOAEPParameterSpecClass, JOAEPParameterSpec>) end;
JGCMParameterSpecXClass = interface(JAlgorithmParameterSpecXClass)
['{82706193-A4B5-C0D1-E2F3-6E7F8091A2B3}']
function init(tLen: Integer; src: TJavaArray<Byte>): JGCMParameterSpecX; cdecl;
end;
[JavaSignature('javax/crypto/spec/GCMParameterSpec')]
JGCMParameterSpecX = interface(JAlgorithmParameterSpecX)
['{938172A4-B5C6-D1E2-F304-7F8091A2B3C4}']
end;
TJGCMParameterSpecX = class(TJavaGenericImport<JGCMParameterSpecXClass, JGCMParameterSpecX>) end;
// KeyStore.load a deux surcharges, et celle a un argument prend un
// LoadStoreParameter - pas un Object. Declarer JObject faisait chercher au
// pont une methode inexistante : « methode introuvable » a l'execution, sans
// rien signaler a la compilation.
JLoadStoreParameterClass = interface(IJavaClass)
['{1C2D3E4F-5061-7283-94A5-B6C7D8E9F001}']
end;
[JavaSignature('java/security/KeyStore$LoadStoreParameter')]
JLoadStoreParameter = interface(IJavaInstance)
['{2D3E4F50-6172-8394-A5B6-C7D8E9F00112}']
end;
TJLoadStoreParameter = class(TJavaGenericImport<JLoadStoreParameterClass, JLoadStoreParameter>) end;
JKeyStoreXClass = interface(JObjectClass)
['{A49283B5-C6D7-E2F3-0415-8091A2B3C4D5}']
function getInstance(&type: JString): JKeyStoreX; cdecl;
end;
[JavaSignature('java/security/KeyStore')]
JKeyStoreX = interface(JObject)
['{B5A394C6-D7E8-F304-1526-91A2B3C4D5E6}']
procedure load(param: JLoadStoreParameter); cdecl;
function getKey(alias: JString; password: TJavaArray<Char>): JKeyX; cdecl;
function getCertificate(alias: JString): JCertificateX; cdecl;
function containsAlias(alias: JString): Boolean; cdecl;
end;
TJKeyStoreX = class(TJavaGenericImport<JKeyStoreXClass, JKeyStoreX>) end;
JKeyGenParameterSpec_BuilderClass = interface(JObjectClass)
['{C6B4A5D7-E8F9-0415-2637-A2B3C4D5E6F7}']
function init(keystoreAlias: JString; purposes: Integer): JKeyGenParameterSpec_Builder; cdecl;
end;
[JavaSignature('android/security/keystore/KeyGenParameterSpec$Builder')]
JKeyGenParameterSpec_Builder = interface(JObject)
['{D7C5B6E8-F90A-1526-3748-B3C4D5E6F708}']
function setBlockModes(blockModes: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl;
function setEncryptionPaddings(paddings: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl;
function setDigests(digests: TJavaObjectArray<JString>): 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<JKeyGenParameterSpec_BuilderClass, JKeyGenParameterSpec_Builder>) end;
JKeyGenParameterSpecClass = interface(JAlgorithmParameterSpecXClass)
['{E8D6C7F9-0A1B-2637-4859-C4D5E6F70819}']
end;
[JavaSignature('android/security/keystore/KeyGenParameterSpec')]
JKeyGenParameterSpec = interface(JAlgorithmParameterSpecX)
['{F9E7D80A-1B2C-3748-5960-D5E6F7081920}']
end;
TJKeyGenParameterSpec = class(TJavaGenericImport<JKeyGenParameterSpecClass, JKeyGenParameterSpec>) end;
JKeyGeneratorClass = interface(JObjectClass)
['{0AF8E91B-2C3D-4859-6A71-E6F708192031}']
function getInstance(algorithm: JString; provider: JString): JKeyGenerator; cdecl;
end;
[JavaSignature('javax/crypto/KeyGenerator')]
JKeyGenerator = interface(JObject)
['{1B09FA2C-3D4E-5960-7B82-F70819203142}']
procedure init(params: JAlgorithmParameterSpecX); cdecl;
function generateKey: JSecretKeyX; cdecl;
end;
TJKeyGenerator = class(TJavaGenericImport<JKeyGeneratorClass, JKeyGenerator>) end;
JCipherXClass = interface(JObjectClass)
['{2C1A0B3D-4E5F-6A71-8C93-081920314253}']
function _GetENCRYPT_MODE: Integer; cdecl;
function _GetDECRYPT_MODE: Integer; cdecl;
function getInstance(transformation: JString): JCipherX; cdecl;
property ENCRYPT_MODE: Integer read _GetENCRYPT_MODE;
property DECRYPT_MODE: Integer read _GetDECRYPT_MODE;
end;
[JavaSignature('javax/crypto/Cipher')]
JCipherX = interface(JObject)
['{3D2B1C4E-5F60-7B82-9DA4-192031425364}']
// Les DEUX surcharges doivent porter la directive : Delphi refuse une
// seconde declaration si la premiere ne l'a pas.
procedure init(opmode: Integer; key: JKeyX); cdecl; overload;
procedure init(opmode: Integer; key: JKeyX; params: JAlgorithmParameterSpecX); cdecl; overload;
function doFinal(input: TJavaArray<Byte>): TJavaArray<Byte>; cdecl;
function getIV: TJavaArray<Byte>; cdecl;
end;
TJCipherX = class(TJavaGenericImport<JCipherXClass, JCipherX>) end;
function ToJava(const B: TBytes): TJavaArray<Byte>;
var I: Integer;
begin
Result := TJavaArray<Byte>.Create(Length(B));
for I := 0 to High(B) do Result.Items[I] := B[I];
end;
function FromJava(const A: TJavaArray<Byte>): TBytes;
var I: Integer;
begin
SetLength(Result, A.Length);
for I := 0 to A.Length - 1 do Result[I] := A.Items[I];
end;
function OpenKeyStore: JKeyStoreX;
begin
Result := TJKeyStoreX.JavaClass.getInstance(StringToJString('AndroidKeyStore'));
Result.load(nil); // le magasin Android ne prend pas de flux
end;
/// Parametres OAEP explicites. Sans eux, le Keystore et le fournisseur par
/// defaut ne s'accordent pas sur le MGF1 - SHA-1 d'un cote, SHA-256 de
/// l'autre - et le dechiffrement echoue sur un chiffre pourtant valide.
function OaepParams: JAlgorithmParameterSpecX;
begin
Result := TJOAEPParameterSpec.JavaClass.init(
StringToJString('SHA-256'),
StringToJString('MGF1'),
TJMGF1ParameterSpec.JavaClass.init(StringToJString('SHA-256')),
TJPSource_PSpecified.JavaClass.DEFAULT);
end;
/// La cle AES sans contrainte, pour les appareils sans protection d'ecran.
function EnsureAesKey: JKeyX;
var
LStore: JKeyStoreX;
LGen: JKeyGenerator;
LBuilder: JKeyGenParameterSpec_Builder;
LModes, LPaddings: TJavaObjectArray<JString>;
begin
LStore := OpenKeyStore;
if LStore.containsAlias(StringToJString(KEY_ALIAS)) then
Exit(LStore.getKey(StringToJString(KEY_ALIAS), nil));
LModes := TJavaObjectArray<JString>.Create(1);
LModes.Items[0] := StringToJString('GCM');
LPaddings := TJavaObjectArray<JString>.Create(1);
LPaddings.Items[0] := StringToJString('NoPadding');
LBuilder := TJKeyGenParameterSpec_Builder.JavaClass.init(
StringToJString(KEY_ALIAS), PURPOSE_ENCRYPT or PURPOSE_DECRYPT);
LBuilder := LBuilder.setBlockModes(LModes);
LBuilder := LBuilder.setEncryptionPaddings(LPaddings);
LBuilder := LBuilder.setKeySize(256);
LGen := TJKeyGenerator.JavaClass.getInstance(
StringToJString('AES'), StringToJString('AndroidKeyStore'));
LGen.init(LBuilder.build);
Result := LGen.generateKey; // stockee sous l'alias, pas retournee en clair
end;
/// Cree la paire RSA si elle manque. Leve si l'appareil n'a NI empreinte NI
/// code : une cle privee protegee par authentification n'aurait alors rien
/// pour la deverrouiller. L'appelant retombe sur la cle AES.
procedure EnsureWrapKey;
var
LStore: JKeyStoreX;
LGen: JKeyPairGeneratorX;
LBuilder: JKeyGenParameterSpec_Builder;
LPaddings, LDigests: TJavaObjectArray<JString>;
begin
LStore := OpenKeyStore;
if LStore.containsAlias(StringToJString(KEY_ALIAS_WRAP)) then Exit;
LPaddings := TJavaObjectArray<JString>.Create(1);
LPaddings.Items[0] := StringToJString('OAEPPadding');
LDigests := TJavaObjectArray<JString>.Create(1);
LDigests.Items[0] := StringToJString('SHA-256');
LBuilder := TJKeyGenParameterSpec_Builder.JavaClass.init(
StringToJString(KEY_ALIAS_WRAP), PURPOSE_ENCRYPT or PURPOSE_DECRYPT);
LBuilder := LBuilder.setEncryptionPaddings(LPaddings);
LBuilder := LBuilder.setDigests(LDigests);
LBuilder := LBuilder.setKeySize(RSA_BITS);
LBuilder := LBuilder.setUserAuthenticationRequired(True);
LBuilder := LBuilder.setUserAuthenticationValidityDurationSeconds(AUTH_VALIDITY_SECONDS);
LGen := TJKeyPairGeneratorX.JavaClass.getInstance(
StringToJString('RSA'), StringToJString('AndroidKeyStore'));
LGen.initialize(LBuilder.build);
LGen.generateKeyPair;
end;
/// La cle publique DEBARRASSEE des contraintes de sa jumelle privee.
/// Telle que rendue par le Keystore elle les porte, et chiffrer avec elle
/// leverait UserNotAuthenticatedException - exactement ce qu'on fuit.
function UnrestrictedPublicKey: JPublicKeyX;
var
LStore: JKeyStoreX;
LCert: JCertificateX;
LPub: JPublicKeyX;
LFactory: JKeyFactoryX;
begin
LStore := OpenKeyStore;
LCert := LStore.getCertificate(StringToJString(KEY_ALIAS_WRAP));
if LCert = nil then
raise EPMAStore.Create('Cle publique introuvable dans le Keystore');
LPub := LCert.getPublicKey;
LFactory := TJKeyFactoryX.JavaClass.getInstance(LPub.getAlgorithm);
Result := LFactory.generatePublic(
TJX509EncodedKeySpec.JavaClass.init(LPub.getEncoded));
end;
function RandomBytes(ACount: Integer): TBytes;
var
LArr: TJavaArray<Byte>;
begin
LArr := TJavaArray<Byte>.Create(ACount);
TJSecureRandomX.JavaClass.init.nextBytes(LArr);
Result := FromJava(LArr);
end;
{$ENDIF}
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 = VER_WRAP;
finally
LFs.Free;
end;
end;
procedure SaveVault(const APlainJson, AETag, ASyncPwd: string;
ARequireAuth: Boolean);
{$IF DEFINED(ANDROID)}
var
LWrap: TJSONObject;
LPlain, LIv, LCt, LOut, LAesRaw, LWrapped: TBytes;
LCipher: JCipherX;
LGotAuth: Boolean;
LOfs: Integer;
begin
// ETag, mot de passe et coffre chiffres ENSEMBLE : ils doivent rester
// coherents. Un ETag qui survivrait a un coffre illisible ferait repondre
// 304 au serveur alors que l'application n'a rien a afficher.
LWrap := TJSONObject.Create;
try
LWrap.AddPair('etag', AETag);
LWrap.AddPair('pwd', ASyncPwd);
LWrap.AddPair('vault', APlainJson);
LPlain := TEncoding.UTF8.GetBytes(LWrap.ToJSON);
finally
LWrap.Free;
end;
LGotAuth := False;
LWrapped := nil;
if ARequireAuth then
try
EnsureWrapKey;
// Cle AES jetable, tiree du generateur cryptographique de la plateforme
// - jamais de Random ici.
LAesRaw := RandomBytes(AES_LEN);
LCipher := TJCipherX.JavaClass.getInstance(StringToJString(RSA_TRANSFORM));
// AUCUNE authentification requise : la cle publique reconstruite ne
// porte plus les contraintes de la privee. C'est tout l'interet.
LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, UnrestrictedPublicKey,
OaepParams);
LWrapped := FromJava(LCipher.doFinal(ToJava(LAesRaw)));
LGotAuth := True;
except
LWrapped := nil; // appareil sans protection, ou refus du Keystore
end;
if LGotAuth then
begin
// AES logicielle sous la cle jetable : rien ici ne passe par le Keystore,
// donc rien n'exige d'authentification.
LIv := RandomBytes(IV_LEN);
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE,
TJSecretKeySpecX.JavaClass.init(ToJava(LAesRaw), StringToJString('AES')),
TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv)));
LCt := FromJava(LCipher.doFinal(ToJava(LPlain)));
SetLength(LOut, 1 + 2 + Length(LWrapped) + IV_LEN + Length(LCt));
LOut[0] := VER_WRAP;
LOut[1] := Byte(Length(LWrapped) shr 8);
LOut[2] := Byte(Length(LWrapped) and $FF);
LOfs := 3;
Move(LWrapped[0], LOut[LOfs], Length(LWrapped)); Inc(LOfs, Length(LWrapped));
Move(LIv[0], LOut[LOfs], IV_LEN); Inc(LOfs, IV_LEN);
Move(LCt[0], LOut[LOfs], Length(LCt));
end
else
begin
// Repli : cle AES du Keystore, sans contrainte. Liee au materiel, mais
// pas a l'utilisateur.
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, EnsureAesKey);
// L'IV est GENERE par le Keystore ici, jamais choisi par nous.
LIv := FromJava(LCipher.getIV);
LCt := FromJava(LCipher.doFinal(ToJava(LPlain)));
SetLength(LOut, 1 + Length(LIv) + Length(LCt));
LOut[0] := VER_PLAIN;
Move(LIv[0], LOut[1], Length(LIv));
Move(LCt[0], LOut[1 + Length(LIv)], Length(LCt));
end;
TFile.WriteAllBytes(VaultPath, LOut);
end;
{$ELSE}
begin
raise EPMAStore.Create('Le stockage Keystore est propre a Android');
end;
{$ENDIF}
function LoadVault(out APlainJson, AETag, ASyncPwd: string): Boolean;
{$IF DEFINED(ANDROID)}
var
LRaw, LIv, LCt, LPlain, LWrapped, LAesRaw: TBytes;
LCipher: JCipherX;
LWrap: TJSONObject;
LStore: JKeyStoreX;
LLen, LOfs: Integer;
begin
Result := False;
APlainJson := '';
AETag := '';
ASyncPwd := '';
FLastStoreError := '';
if not TFile.Exists(VaultPath) then Exit;
try
LRaw := TFile.ReadAllBytes(VaultPath);
if Length(LRaw) < 1 then Exit;
if LRaw[0] = VER_WRAP then
begin
if Length(LRaw) < 3 then Exit;
LLen := (LRaw[1] shl 8) or LRaw[2];
if Length(LRaw) < 3 + LLen + IV_LEN + 16 then Exit;
LOfs := 3;
SetLength(LWrapped, LLen);
Move(LRaw[LOfs], LWrapped[0], LLen); Inc(LOfs, LLen);
SetLength(LIv, IV_LEN);
Move(LRaw[LOfs], LIv[0], IV_LEN); Inc(LOfs, IV_LEN);
SetLength(LCt, Length(LRaw) - LOfs);
Move(LRaw[LOfs], LCt[0], Length(LCt));
// C'EST ICI que l'authentification est exigee, et nulle part ailleurs.
// Si elle n'a pas eu lieu recemment, l'init leve
// UserNotAuthenticatedException - attrapee plus bas, sans effacer.
LStore := OpenKeyStore;
LCipher := TJCipherX.JavaClass.getInstance(StringToJString(RSA_TRANSFORM));
LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE,
LStore.getKey(StringToJString(KEY_ALIAS_WRAP), nil),
OaepParams);
LAesRaw := FromJava(LCipher.doFinal(ToJava(LWrapped)));
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE,
TJSecretKeySpecX.JavaClass.init(ToJava(LAesRaw), StringToJString('AES')),
TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv)));
LPlain := FromJava(LCipher.doFinal(ToJava(LCt)));
end
else if LRaw[0] = VER_PLAIN then
begin
if Length(LRaw) < 1 + IV_LEN + 16 then Exit;
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)));
LPlain := FromJava(LCipher.doFinal(ToJava(LCt)));
end
else
Exit; // en-tete inconnu, y compris l'ancien schema AES protege (2)
LWrap := TJSONObject.ParseJSONValue(TEncoding.UTF8.GetString(LPlain)) as TJSONObject;
if LWrap = nil then Exit;
try
AETag := LWrap.GetValue<string>('etag', '');
ASyncPwd := LWrap.GetValue<string>('pwd', '');
APlainJson := LWrap.GetValue<string>('vault', '');
Result := APlainJson <> '';
finally
LWrap.Free;
end;
except
on E: Exception do
begin
Result := False;
// ON N'EFFACE PAS. Le tri « cle perdue / pas authentifie » se faisait
// ici sur le texte du message, et une erreur de tri detruisait le cache
// en emportant la seule trace de sa cause. L'appelant a le journal et le
// contexte - lui seul sait si l'utilisateur a demande quelque chose.
FLastStoreError := E.ClassName + ': ' + E.Message;
end;
end;
end;
{$ELSE}
begin
Result := False;
APlainJson := '';
AETag := '';
ASyncPwd := '';
end;
{$ENDIF}
end.