feat(android): wrap the cache key with an asymmetric Keystore pair

Measured on device: android.security.keystore.UserNotAuthenticatedException on every write. An AES key created with setUserAuthenticationRequired demands a recent authentication to ENCRYPT as well as to decrypt, and the write follows a silent sync with no prompt. The very first write only succeeded because the phone had just been unlocked - a test that passes, then stops passing.

Now an RSA pair: the public half wraps a throwaway AES key with no prompt, the private half - the guarded one - unwraps it. Authentication is required at read time and nowhere else. Two documented traps handled: the public key returned by the Keystore carries the private key's constraints, so it is rebuilt from its encoded form; and that rebuild changes provider, so the OAEP parameters are passed explicitly on both sides - the Keystore defaults MGF1 to SHA-1 where the transformation name suggests SHA-256.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-15 14:00:35 +01:00
parent 09048410e1
commit 1dc4f3bdb6
2 changed files with 407 additions and 130 deletions
+407 -91
View File
@@ -2,7 +2,7 @@ unit PMA.Store;
{ {
Coffre garde localement, chiffre sous une cle du Android Keystore. Coffre garde localement, chiffre sous une cle du Android Keystore.
POURQUOI : ouvrir un conteneur coute ~4 s (PBKDF2 600k, incompressible - le POURQUOI : ouvrir un conteneur coute ~4,5 s (PBKDF2 600k, incompressible - le
nombre d'iterations appartient au format ecrit par le poste Windows). Payer 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 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 contenu n'a pas change. On ne le paie donc qu'a l'arrivee d'un instantane
@@ -10,20 +10,48 @@ unit PMA.Store;
materiellement, donc instantane. materiellement, donc instantane.
LA CLE NE QUITTE JAMAIS LE MATERIEL. Elle est generee dans le Keystore et LA CLE NE QUITTE JAMAIS LE MATERIEL. Elle est generee dans le Keystore et
n'en sort pas : on envoie des donnees a chiffrer, on recoit le resultat. Un n'en sort pas. Un vol du fichier seul est donc inexploitable, meme root -
vol du fichier seul est donc inexploitable, meme root - il faut la puce. 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 CE QUE CE FICHIER CONTIENT : tous les mots de passe en clair une fois
dechiffre, ET le mot de passe de sync (voir SaveVault pour l'arbitrage). dechiffre, ET le mot de passe de sync (voir SaveVault). D'ou trois regles :
D'ou trois regles tenues ici :
- jamais hors du repertoire prive de l'application ; - jamais hors du repertoire prive de l'application ;
- jamais de copie en clair sur le disque, meme temporaire ; - jamais de copie en clair sur le disque, meme temporaire ;
- la sauvegarde automatique Android doit etre desactivee pour l'app - la sauvegarde automatique Android doit rester desactivee pour l'app
(android:allowBackup="false"), sinon Google en recupere une copie. (android:allowBackup="false"), sinon Google en recupere une copie.
La biometrie est demandee a la lecture quand la cle a pu etre creee avec
setUserAuthenticationRequired - deux alias existent pour cela, et l'en-tete
du fichier dit lequel a servi.
} }
interface interface
@@ -47,10 +75,10 @@ function HasVault: Boolean;
/// tous les mots de passe en clair ; celui-ci n'y ajoute presque rien en /// tous les mots de passe en clair ; celui-ci n'y ajoute presque rien en
/// exposition, et rend la synchronisation silencieuse. /// exposition, et rend la synchronisation silencieuse.
/// ///
/// ARequireAuth demande une cle deverrouillee par biometrie ou code. Si /// ARequireAuth demande la protection par authentification. Si l'appareil n'a
/// l'appareil n'a aucune de ces protections, on retombe silencieusement sur /// ni empreinte ni code, on retombe sur une cle AES sans contrainte : mieux
/// une cle sans contrainte : mieux vaut un cache lie au materiel que pas de /// vaut un cache lie au materiel que pas de cache du tout. VaultNeedsAuth dira
/// cache du tout. VaultNeedsAuth dira ensuite ce qui a reellement ete fait. /// ensuite ce qui a reellement ete fait.
procedure SaveVault(const APlainJson, AETag, ASyncPwd: string; procedure SaveVault(const APlainJson, AETag, ASyncPwd: string;
ARequireAuth: Boolean = False); ARequireAuth: Boolean = False);
@@ -79,22 +107,27 @@ uses
const const
VAULT_FILE = 'vault.bin'; VAULT_FILE = 'vault.bin';
// DEUX alias : la contrainte d'authentification est figee a la creation de // Un alias par schema : la contrainte d'authentification est figee a la
// la cle et ne peut pas etre ajoutee apres coup. Basculer d'un mode a // creation de la cle et ne peut pas etre ajoutee apres coup.
// l'autre change donc de cle, ce qui rend l'ancien fichier illisible - il KEY_ALIAS = 'pm_vault_key'; // AES, sans authentification
// sera efface et retelecharge, sans perte. KEY_ALIAS_WRAP = 'pm_vault_wrap'; // RSA, privee sous authentification
KEY_ALIAS = 'pm_vault_key'; // sans authentification // Duree pendant laquelle la cle PRIVEE reste utilisable apres une
KEY_ALIAS_BIO = 'pm_vault_key_bio'; // deverrouillee par biometrie/code // authentification reussie. Ne concerne plus l'ecriture, qui n'en depend
// Duree pendant laquelle la cle reste utilisable apres une authentification // pas : assez pour dechiffrer et afficher, trop court pour qu'un telephone
// reussie. Assez pour dechiffrer et afficher, trop court pour qu'un // laisse sans surveillance reste exploitable.
// telephone laisse sans surveillance reste exploitable.
AUTH_VALIDITY_SECONDS = 30; AUTH_VALIDITY_SECONDS = 30;
// Constantes d'android.security.keystore.KeyProperties, reprises en dur // Constantes d'android.security.keystore.KeyProperties, reprises en dur
// plutot qu'importees : elles sont figees par la plateforme, et cela evite // plutot qu'importees : elles sont figees par la plateforme.
// une declaration JNI de plus.
PURPOSE_ENCRYPT = 1; PURPOSE_ENCRYPT = 1;
PURPOSE_DECRYPT = 2; PURPOSE_DECRYPT = 2;
IV_LEN = 12; // GCM : 12 octets, la longueur recommandee 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';
function VaultPath: string; function VaultPath: string;
begin begin
@@ -124,6 +157,20 @@ type
JGCMParameterSpecX = interface; JGCMParameterSpecX = interface;
JAlgorithmParameterSpecX = interface; JAlgorithmParameterSpecX = interface;
JKeyX = 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) JKeyXClass = interface(IJavaClass)
['{2C1A0B3D-4E5F-6A7B-8C9D-0E1F2A3B4C5D}'] ['{2C1A0B3D-4E5F-6A7B-8C9D-0E1F2A3B4C5D}']
@@ -131,6 +178,8 @@ type
[JavaSignature('java/security/Key')] [JavaSignature('java/security/Key')]
JKeyX = interface(IJavaInstance) JKeyX = interface(IJavaInstance)
['{3D2B1C4E-5F60-7B8C-9DAE-1F2A3B4C5D6E}'] ['{3D2B1C4E-5F60-7B8C-9DAE-1F2A3B4C5D6E}']
function getEncoded: TJavaArray<Byte>; cdecl;
function getAlgorithm: JString; cdecl;
end; end;
TJKeyX = class(TJavaGenericImport<JKeyXClass, JKeyX>) end; TJKeyX = class(TJavaGenericImport<JKeyXClass, JKeyX>) end;
@@ -143,6 +192,83 @@ type
end; end;
TJSecretKeyX = class(TJavaGenericImport<JSecretKeyXClass, JSecretKeyX>) 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) JAlgorithmParameterSpecXClass = interface(IJavaClass)
['{605E4F71-8293-AEBF-C0D1-4C5D6E7F8091}'] ['{605E4F71-8293-AEBF-C0D1-4C5D6E7F8091}']
end; end;
@@ -152,6 +278,70 @@ type
end; end;
TJAlgorithmParameterSpecX = class(TJavaGenericImport<JAlgorithmParameterSpecXClass, JAlgorithmParameterSpecX>) 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) JGCMParameterSpecXClass = interface(JAlgorithmParameterSpecXClass)
['{82706193-A4B5-C0D1-E2F3-6E7F8091A2B3}'] ['{82706193-A4B5-C0D1-E2F3-6E7F8091A2B3}']
function init(tLen: Integer; src: TJavaArray<Byte>): JGCMParameterSpecX; cdecl; function init(tLen: Integer; src: TJavaArray<Byte>): JGCMParameterSpecX; cdecl;
@@ -164,7 +354,7 @@ type
// KeyStore.load a deux surcharges, et celle a un argument prend un // KeyStore.load a deux surcharges, et celle a un argument prend un
// LoadStoreParameter - pas un Object. Declarer JObject faisait chercher au // LoadStoreParameter - pas un Object. Declarer JObject faisait chercher au
// pont une methode inexistante : "methode introuvable" a l'execution, sans // pont une methode inexistante : « methode introuvable » a l'execution, sans
// rien signaler a la compilation. // rien signaler a la compilation.
JLoadStoreParameterClass = interface(IJavaClass) JLoadStoreParameterClass = interface(IJavaClass)
['{1C2D3E4F-5061-7283-94A5-B6C7D8E9F001}'] ['{1C2D3E4F-5061-7283-94A5-B6C7D8E9F001}']
@@ -184,6 +374,7 @@ type
['{B5A394C6-D7E8-F304-1526-91A2B3C4D5E6}'] ['{B5A394C6-D7E8-F304-1526-91A2B3C4D5E6}']
procedure load(param: JLoadStoreParameter); cdecl; procedure load(param: JLoadStoreParameter); cdecl;
function getKey(alias: JString; password: TJavaArray<Char>): JKeyX; cdecl; function getKey(alias: JString; password: TJavaArray<Char>): JKeyX; cdecl;
function getCertificate(alias: JString): JCertificateX; cdecl;
function containsAlias(alias: JString): Boolean; cdecl; function containsAlias(alias: JString): Boolean; cdecl;
end; end;
TJKeyStoreX = class(TJavaGenericImport<JKeyStoreXClass, JKeyStoreX>) end; TJKeyStoreX = class(TJavaGenericImport<JKeyStoreXClass, JKeyStoreX>) end;
@@ -197,6 +388,7 @@ type
['{D7C5B6E8-F90A-1526-3748-B3C4D5E6F708}'] ['{D7C5B6E8-F90A-1526-3748-B3C4D5E6F708}']
function setBlockModes(blockModes: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl; function setBlockModes(blockModes: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl;
function setEncryptionPaddings(paddings: 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 setKeySize(keySize: Integer): JKeyGenParameterSpec_Builder; cdecl;
function setUserAuthenticationRequired(required: Boolean): JKeyGenParameterSpec_Builder; cdecl; function setUserAuthenticationRequired(required: Boolean): JKeyGenParameterSpec_Builder; cdecl;
// Deprecie depuis l'API 30 au profit de setUserAuthenticationParameters, // Deprecie depuis l'API 30 au profit de setUserAuthenticationParameters,
@@ -261,24 +453,35 @@ begin
for I := 0 to A.Length - 1 do Result[I] := A.Items[I]; for I := 0 to A.Length - 1 do Result[I] := A.Items[I];
end; end;
/// La cle du Keystore, creee a la premiere utilisation. Elle n'est jamais function OpenKeyStore: JKeyStoreX;
/// lue : on obtient une poignee vers un objet que seul le materiel peut begin
/// utiliser. Result := TJKeyStoreX.JavaClass.getInstance(StringToJString('AndroidKeyStore'));
function EnsureKey(ARequireAuth: Boolean): JKeyX; 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 var
LStore: JKeyStoreX; LStore: JKeyStoreX;
LGen: JKeyGenerator; LGen: JKeyGenerator;
LBuilder: JKeyGenParameterSpec_Builder; LBuilder: JKeyGenParameterSpec_Builder;
LModes, LPaddings: TJavaObjectArray<JString>; LModes, LPaddings: TJavaObjectArray<JString>;
LAlias: string;
begin begin
if ARequireAuth then LAlias := KEY_ALIAS_BIO else LAlias := KEY_ALIAS; LStore := OpenKeyStore;
if LStore.containsAlias(StringToJString(KEY_ALIAS)) then
LStore := TJKeyStoreX.JavaClass.getInstance(StringToJString('AndroidKeyStore')); Exit(LStore.getKey(StringToJString(KEY_ALIAS), nil));
LStore.load(nil); // le magasin Android ne prend pas de flux
if LStore.containsAlias(StringToJString(LAlias)) then
Exit(LStore.getKey(StringToJString(LAlias), nil));
LModes := TJavaObjectArray<JString>.Create(1); LModes := TJavaObjectArray<JString>.Create(1);
LModes.Items[0] := StringToJString('GCM'); LModes.Items[0] := StringToJString('GCM');
@@ -286,24 +489,77 @@ begin
LPaddings.Items[0] := StringToJString('NoPadding'); LPaddings.Items[0] := StringToJString('NoPadding');
LBuilder := TJKeyGenParameterSpec_Builder.JavaClass.init( LBuilder := TJKeyGenParameterSpec_Builder.JavaClass.init(
StringToJString(LAlias), PURPOSE_ENCRYPT or PURPOSE_DECRYPT); StringToJString(KEY_ALIAS), PURPOSE_ENCRYPT or PURPOSE_DECRYPT);
LBuilder := LBuilder.setBlockModes(LModes); LBuilder := LBuilder.setBlockModes(LModes);
LBuilder := LBuilder.setEncryptionPaddings(LPaddings); LBuilder := LBuilder.setEncryptionPaddings(LPaddings);
LBuilder := LBuilder.setKeySize(256); LBuilder := LBuilder.setKeySize(256);
if ARequireAuth then
begin
LBuilder := LBuilder.setUserAuthenticationRequired(True);
LBuilder := LBuilder.setUserAuthenticationValidityDurationSeconds(AUTH_VALIDITY_SECONDS);
end;
LGen := TJKeyGenerator.JavaClass.getInstance( LGen := TJKeyGenerator.JavaClass.getInstance(
StringToJString('AES'), StringToJString('AndroidKeyStore')); 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); LGen.init(LBuilder.build);
Result := LGen.generateKey; // stockee sous l'alias, pas retournee en clair Result := LGen.generateKey; // stockee sous l'alias, pas retournee en clair
end; 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} {$ENDIF}
function VaultNeedsAuth: Boolean; function VaultNeedsAuth: Boolean;
@@ -317,7 +573,7 @@ begin
try try
if LFs.Size < 1 then Exit; if LFs.Size < 1 then Exit;
LFs.ReadBuffer(LVer, 1); LFs.ReadBuffer(LVer, 1);
Result := LVer = 2; // 1 = cle simple, 2 = cle sous authentification Result := LVer = VER_WRAP;
finally finally
LFs.Free; LFs.Free;
end; end;
@@ -328,14 +584,14 @@ procedure SaveVault(const APlainJson, AETag, ASyncPwd: string;
{$IF DEFINED(ANDROID)} {$IF DEFINED(ANDROID)}
var var
LWrap: TJSONObject; LWrap: TJSONObject;
LPlain, LIv, LCt, LOut: TBytes; LPlain, LIv, LCt, LOut, LAesRaw, LWrapped: TBytes;
LCipher: JCipherX; LCipher: JCipherX;
LKey: JKeyX;
LGotAuth: Boolean; LGotAuth: Boolean;
LOfs: Integer;
begin begin
// ETag et coffre chiffres ENSEMBLE : ils doivent rester coherents. Un ETag // ETag, mot de passe et coffre chiffres ENSEMBLE : ils doivent rester
// qui survivrait a un coffre illisible ferait repondre 304 au serveur alors // coherents. Un ETag qui survivrait a un coffre illisible ferait repondre
// que l'application n'a rien a afficher. // 304 au serveur alors que l'application n'a rien a afficher.
LWrap := TJSONObject.Create; LWrap := TJSONObject.Create;
try try
LWrap.AddPair('etag', AETag); LWrap.AddPair('etag', AETag);
@@ -346,33 +602,61 @@ begin
LWrap.Free; LWrap.Free;
end; end;
// Repli explicite : un appareil sans empreinte NI code ne peut pas porter
// une cle protegee par authentification, et sa creation echouerait.
LGotAuth := False; LGotAuth := False;
LKey := nil; LWrapped := nil;
if ARequireAuth then if ARequireAuth then
try try
LKey := EnsureKey(True); 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; LGotAuth := True;
except except
LKey := nil; LWrapped := nil; // appareil sans protection, ou refus du Keystore
end; end;
if LKey = nil then
LKey := EnsureKey(False);
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); if LGotAuth then
LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, LKey); begin
// L'IV est GENERE par le Keystore, jamais choisi par nous : le reutiliser // AES logicielle sous la cle jetable : rien ici ne passe par le Keystore,
// avec la meme cle romprait GCM. // donc rien n'exige d'authentification.
LIv := FromJava(LCipher.getIV); LIv := RandomBytes(IV_LEN);
LCt := FromJava(LCipher.doFinal(ToJava(LPlain))); 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;
SetLength(LOut, 1 + Length(LIv) + Length(LCt));
// 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); TFile.WriteAllBytes(VaultPath, LOut);
end; end;
{$ELSE} {$ELSE}
@@ -384,9 +668,11 @@ end;
function LoadVault(out APlainJson, AETag, ASyncPwd: string): Boolean; function LoadVault(out APlainJson, AETag, ASyncPwd: string): Boolean;
{$IF DEFINED(ANDROID)} {$IF DEFINED(ANDROID)}
var var
LRaw, LIv, LCt, LPlain: TBytes; LRaw, LIv, LCt, LPlain, LWrapped, LAesRaw: TBytes;
LCipher: JCipherX; LCipher: JCipherX;
LWrap: TJSONObject; LWrap: TJSONObject;
LStore: JKeyStoreX;
LLen, LOfs: Integer;
begin begin
Result := False; Result := False;
APlainJson := ''; APlainJson := '';
@@ -396,22 +682,52 @@ begin
try try
LRaw := TFile.ReadAllBytes(VaultPath); LRaw := TFile.ReadAllBytes(VaultPath);
if (Length(LRaw) < 1 + IV_LEN + 16) then Exit; if Length(LRaw) < 1 then Exit;
if not (LRaw[0] in [1, 2]) then Exit;
SetLength(LIv, IV_LEN); if LRaw[0] = VER_WRAP then
Move(LRaw[1], LIv[0], IV_LEN); begin
SetLength(LCt, Length(LRaw) - 1 - IV_LEN); if Length(LRaw) < 3 then Exit;
Move(LRaw[1 + IV_LEN], LCt[0], Length(LCt)); 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));
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); // C'EST ICI que l'authentification est exigee, et nulle part ailleurs.
// La cle est choisie d'apres l'en-tete. Si elle exige une // Si elle n'a pas eu lieu recemment, l'init leve
// authentification et qu'aucune n'a eu lieu recemment, l'init leve // UserNotAuthenticatedException - attrapee plus bas, sans effacer.
// UserNotAuthenticatedException - attrapee plus bas, et le cache est LStore := OpenKeyStore;
// efface. L'appelant doit donc authentifier AVANT (voir VaultNeedsAuth). LCipher := TJCipherX.JavaClass.getInstance(StringToJString(RSA_TRANSFORM));
LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE, EnsureKey(LRaw[0] = 2), LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE,
TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv))); LStore.getKey(StringToJString(KEY_ALIAS_WRAP), nil),
LPlain := FromJava(LCipher.doFinal(ToJava(LCt))); 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; LWrap := TJSONObject.ParseJSONValue(TEncoding.UTF8.GetString(LPlain)) as TJSONObject;
if LWrap = nil then Exit; if LWrap = nil then Exit;
@@ -428,9 +744,9 @@ begin
begin begin
Result := False; Result := False;
// NE PAS effacer sur un simple defaut d'authentification : le coffre est // 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 // intact, c'est l'utilisateur qui n'a pas presente son schema ou qui a
// a laisse expirer le delai. L'effacer imposerait un retelechargement et // laisse expirer le delai. L'effacer imposerait un retelechargement et
// 4 secondes de derivation pour une empreinte annulee. // 4,5 secondes de derivation pour une empreinte annulee.
// //
// On efface en revanche quand la cle est REELLEMENT perdue // On efface en revanche quand la cle est REELLEMENT perdue
// (reinstallation, Keystore vide, nouvelle empreinte enrolee qui // (reinstallation, Keystore vide, nouvelle empreinte enrolee qui
-39
View File
@@ -1,39 +0,0 @@
.\Android64\Debug\pmserverApk\classes\pmserverApk.classes
.\Android64\Debug\pmserverApk\res\drawable-anydpi-v31\splash_vector.xml
.\Android64\Debug\pmserverApk\res\drawable-anydpi-v26\ic_launcher.xml
.\Android64\Debug\pmserverApk\res\drawable-hdpi\ic_launcher.png
.\Android64\Debug\pmserverApk\res\drawable-night-anydpi-v21\splash_vector.xml
.\Android64\Debug\pmserverApk\AndroidManifest.xml
.\Android64\Debug\pmserverApk\res\drawable-mdpi\ic_launcher.png
.\Android64\Debug\pmserverApk\res\drawable-xxhdpi\ic_launcher.png
.\Android64\Debug\pmserverApk\res\drawable-xxxhdpi\ic_launcher.png
.\Android64\Debug\pmserverApk\res\values-night-v21\colors.xml
.\Android64\Debug\pmserverApk\res\drawable-xhdpi\ic_notification.png
.\Android64\Debug\pmserverApk\res\drawable-large\splash_image.png
.\Android64\Debug\pmserverApk\res\drawable\ic_launcher_monochrome.xml
.\Android64\Debug\pmserverApk\res\drawable-anydpi-v21\splash_image_def.xml
.\Android64\Debug\pmserverApk\library\lib\armeabi\libpmserverApk.so
.\Android64\Debug\pmserverApk\res\values-v31\styles.xml
.\Android64\Debug\pmserverApk\res\values\colors.xml
.\Android64\Debug\pmserverApk\res\drawable\splash_image_def.xml
.\Android64\Debug\pmserverApk\res\drawable-hdpi\ic_notification.png
.\Android64\Debug\pmserverApk\res\drawable-xxxhdpi\ic_notification.png
.\Android64\Debug\pmserverApk\library\lib\mips\libpmserverApk.so
.\Android64\Debug\pmserverApk\res\drawable-xhdpi\ic_launcher.png
.\Android64\Debug\pmserverApk\res\drawable\splash_vector.xml
.\Android64\Debug\pmserverApk\library\lib\armeabi-v7a\libpmserverApk.so
.\Android64\Debug\pmserverApk\res\drawable-mdpi\ic_notification.png
.\Android64\Debug\pmserverApk\res\drawable-ldpi\ic_launcher.png
.\Android64\Debug\pmserverApk\res\drawable-xlarge\splash_image.png
.\Android64\Debug\pmserverApk\res\drawable-night-anydpi-v31\splash_vector.xml
.\Android64\Debug\pmserverApk\res\values\styles.xml
.\Android64\Debug\pmserverApk\res\drawable-small\splash_image.png
.\Android64\Debug\pmserverApk\res\drawable-anydpi-v24\ic_notification.xml
.\Android64\Debug\pmserverApk\res\values\strings.xml
.\Android64\Debug\pmserverApk\res\values-v21\styles.xml
.\Android64\Debug\pmserverApk\library\lib\arm64-v8a\libpmserverApk.so
.\Android64\Debug\pmserverApk\res\drawable-xxhdpi\ic_notification.png
.\Android64\Debug\pmserverApk\res\drawable\ic_launcher_foreground.xml
.\Android64\Debug\pmserverApk\res\drawable\ic_launcher_background.xml
.\Android64\Debug\pmserverApk\res\drawable-anydpi-v33\ic_launcher.xml
.\Android64\Debug\pmserverApk\res\drawable-normal\splash_image.png