From 1dc4f3bdb6f51f67751db28965b1345f8605658d Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:00:35 +0100 Subject: [PATCH] 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 --- android-client/PMA.Store.pas | 498 ++++++++++++++++++++++++++++------- pmserverApk._@emb_.tmp | 39 --- 2 files changed, 407 insertions(+), 130 deletions(-) delete mode 100644 pmserverApk._@emb_.tmp diff --git a/android-client/PMA.Store.pas b/android-client/PMA.Store.pas index 642c02f..1348cb4 100644 --- a/android-client/PMA.Store.pas +++ b/android-client/PMA.Store.pas @@ -2,7 +2,7 @@ unit PMA.Store; { 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 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 @@ -10,20 +10,48 @@ unit PMA.Store; materiellement, donc instantane. 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 - vol du fichier seul est donc inexploitable, meme root - il faut la puce. + 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 pour l'arbitrage). - D'ou trois regles tenues ici : + 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 etre desactivee pour l'app + - la sauvegarde automatique Android doit rester desactivee pour l'app (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 @@ -47,10 +75,10 @@ function HasVault: Boolean; /// tous les mots de passe en clair ; celui-ci n'y ajoute presque rien en /// exposition, et rend la synchronisation silencieuse. /// -/// 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. +/// 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); @@ -79,22 +107,27 @@ uses const VAULT_FILE = 'vault.bin'; - // 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. + // 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, et cela evite - // une declaration JNI de plus. + // plutot qu'importees : elles sont figees par la plateforme. PURPOSE_ENCRYPT = 1; 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; begin @@ -124,6 +157,20 @@ type 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}'] @@ -131,6 +178,8 @@ type [JavaSignature('java/security/Key')] JKeyX = interface(IJavaInstance) ['{3D2B1C4E-5F60-7B8C-9DAE-1F2A3B4C5D6E}'] + function getEncoded: TJavaArray; cdecl; + function getAlgorithm: JString; cdecl; end; TJKeyX = class(TJavaGenericImport) end; @@ -143,6 +192,83 @@ type end; TJSecretKeyX = class(TJavaGenericImport) end; + JSecretKeySpecXClass = interface(JSecretKeyXClass) + ['{11223344-5566-7788-99AA-BBCCDDEEFF00}'] + function init(key: TJavaArray; algorithm: JString): JSecretKeySpecX; cdecl; + end; + [JavaSignature('javax/crypto/spec/SecretKeySpec')] + JSecretKeySpecX = interface(JSecretKeyX) + ['{22334455-6677-8899-AABB-CCDDEEFF0011}'] + end; + TJSecretKeySpecX = class(TJavaGenericImport) 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) 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) 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) 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) end; + + JX509EncodedKeySpecClass = interface(JKeySpecXClass) + ['{BBCCDDEE-FF00-1122-3344-5566778899AA}'] + function init(encodedKey: TJavaArray): JX509EncodedKeySpec; cdecl; + end; + [JavaSignature('java/security/spec/X509EncodedKeySpec')] + JX509EncodedKeySpec = interface(JKeySpecX) + ['{CCDDEEFF-0011-2233-4455-66778899AABB}'] + end; + TJX509EncodedKeySpec = class(TJavaGenericImport) 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) 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) end; + JAlgorithmParameterSpecXClass = interface(IJavaClass) ['{605E4F71-8293-AEBF-C0D1-4C5D6E7F8091}'] end; @@ -152,6 +278,70 @@ type end; TJAlgorithmParameterSpecX = class(TJavaGenericImport) 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) 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); cdecl; + end; + TJSecureRandomX = class(TJavaGenericImport) 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) 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) 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) 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) end; + JGCMParameterSpecXClass = interface(JAlgorithmParameterSpecXClass) ['{82706193-A4B5-C0D1-E2F3-6E7F8091A2B3}'] function init(tLen: Integer; src: TJavaArray): JGCMParameterSpecX; cdecl; @@ -164,7 +354,7 @@ type // 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 + // pont une methode inexistante : « methode introuvable » a l'execution, sans // rien signaler a la compilation. JLoadStoreParameterClass = interface(IJavaClass) ['{1C2D3E4F-5061-7283-94A5-B6C7D8E9F001}'] @@ -184,6 +374,7 @@ type ['{B5A394C6-D7E8-F304-1526-91A2B3C4D5E6}'] procedure load(param: JLoadStoreParameter); cdecl; function getKey(alias: JString; password: TJavaArray): JKeyX; cdecl; + function getCertificate(alias: JString): JCertificateX; cdecl; function containsAlias(alias: JString): Boolean; cdecl; end; TJKeyStoreX = class(TJavaGenericImport) end; @@ -197,6 +388,7 @@ type ['{D7C5B6E8-F90A-1526-3748-B3C4D5E6F708}'] function setBlockModes(blockModes: TJavaObjectArray): JKeyGenParameterSpec_Builder; cdecl; function setEncryptionPaddings(paddings: TJavaObjectArray): JKeyGenParameterSpec_Builder; cdecl; + function setDigests(digests: 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, @@ -261,24 +453,35 @@ begin for I := 0 to A.Length - 1 do Result[I] := A.Items[I]; 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(ARequireAuth: Boolean): JKeyX; +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; - 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(LAlias)) then - Exit(LStore.getKey(StringToJString(LAlias), nil)); + LStore := OpenKeyStore; + if LStore.containsAlias(StringToJString(KEY_ALIAS)) then + Exit(LStore.getKey(StringToJString(KEY_ALIAS), nil)); LModes := TJavaObjectArray.Create(1); LModes.Items[0] := StringToJString('GCM'); @@ -286,24 +489,77 @@ begin LPaddings.Items[0] := StringToJString('NoPadding'); 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.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; + +/// 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; +begin + LStore := OpenKeyStore; + if LStore.containsAlias(StringToJString(KEY_ALIAS_WRAP)) then Exit; + + LPaddings := TJavaObjectArray.Create(1); + LPaddings.Items[0] := StringToJString('OAEPPadding'); + LDigests := TJavaObjectArray.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; +begin + LArr := TJavaArray.Create(ACount); + TJSecureRandomX.JavaClass.init.nextBytes(LArr); + Result := FromJava(LArr); +end; {$ENDIF} function VaultNeedsAuth: Boolean; @@ -317,7 +573,7 @@ begin try if LFs.Size < 1 then Exit; LFs.ReadBuffer(LVer, 1); - Result := LVer = 2; // 1 = cle simple, 2 = cle sous authentification + Result := LVer = VER_WRAP; finally LFs.Free; end; @@ -328,14 +584,14 @@ procedure SaveVault(const APlainJson, AETag, ASyncPwd: string; {$IF DEFINED(ANDROID)} var LWrap: TJSONObject; - LPlain, LIv, LCt, LOut: TBytes; + LPlain, LIv, LCt, LOut, LAesRaw, LWrapped: TBytes; LCipher: JCipherX; - LKey: JKeyX; LGotAuth: Boolean; + LOfs: Integer; begin - // ETag 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. + // 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); @@ -346,33 +602,61 @@ 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; + LWrapped := nil; if ARequireAuth then 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; except - LKey := nil; + LWrapped := nil; // appareil sans protection, ou refus du Keystore end; - if LKey = nil then - LKey := EnsureKey(False); - LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); - 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))); + 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; - 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); end; {$ELSE} @@ -384,9 +668,11 @@ end; function LoadVault(out APlainJson, AETag, ASyncPwd: string): Boolean; {$IF DEFINED(ANDROID)} var - LRaw, LIv, LCt, LPlain: TBytes; + LRaw, LIv, LCt, LPlain, LWrapped, LAesRaw: TBytes; LCipher: JCipherX; LWrap: TJSONObject; + LStore: JKeyStoreX; + LLen, LOfs: Integer; begin Result := False; APlainJson := ''; @@ -396,22 +682,52 @@ begin try LRaw := TFile.ReadAllBytes(VaultPath); - if (Length(LRaw) < 1 + IV_LEN + 16) then Exit; - if not (LRaw[0] in [1, 2]) then Exit; + if Length(LRaw) < 1 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)); + 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)); - LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); - // 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))); + // 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; @@ -428,9 +744,9 @@ begin begin Result := False; // 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. + // intact, c'est l'utilisateur qui n'a pas presente son schema ou qui a + // laisse expirer le delai. L'effacer imposerait un retelechargement et + // 4,5 secondes de derivation pour une empreinte annulee. // // On efface en revanche quand la cle est REELLEMENT perdue // (reinstallation, Keystore vide, nouvelle empreinte enrolee qui diff --git a/pmserverApk._@emb_.tmp b/pmserverApk._@emb_.tmp deleted file mode 100644 index 6fad206..0000000 --- a/pmserverApk._@emb_.tmp +++ /dev/null @@ -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