a41eeb8bfa
The reported pain: changing the pattern destroys the local cache every time. That is Android doing exactly what it promises - a key created with setUserAuthenticationRequired is invalidated the moment the screen lock changes, so a new lock cannot open data sealed under the old one. No storage format changes that, which is why SQLite would not have helped: the problem is what the key is tied to, not what the file looks like. So the cache gains a third sealing format, VER_PIN, with two layers: inner - AES-GCM under PBKDF2(code, 100k) : it takes the code outer - the Keystore AES key WITHOUT the auth constraint : it takes the device The outer key is never invalidated, so the file survives any change to the screen lock; the code restores the protection that removing the constraint would otherwise cost. 100k iterations rather than 600k because a four to six digit code carries little entropy - more rounds mostly slow the honest user, and the real barrier is the outer layer requiring this phone. The code lives in a fourth settings field and is NEVER stored: empty at every launch, typed to open, and used to re-seal on every write. That is what separates it from the token, whose persistence is a convenience with no consequence since it only opens ciphertext. Built but NOT installed - the phone went off the network. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1086 lines
43 KiB
ObjectPascal
1086 lines
43 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.
|
|
/// AFreshKey jette la paire existante avant d'ecrire. A poser quand la
|
|
/// protection vient de reapparaitre sur l'appareil : le changement qui l'a
|
|
/// ramenee a tres probablement invalide l'ancienne cle, et ca ne se verrait
|
|
/// qu'a la prochaine lecture - trop tard, le fichier serait deja scelle.
|
|
/// APin non vide : le cache est scelle sous ce code (VER_PIN), et ARequireAuth
|
|
/// est alors ignore - les deux protections ne se cumulent pas, et celle du code
|
|
/// est la seule qui survive a un changement de verrouillage d'ecran.
|
|
procedure SaveVault(const APlainJson, AETag, ASyncPwd: string;
|
|
ARequireAuth: Boolean = False; AFreshKey: Boolean = False;
|
|
const APin: string = '');
|
|
|
|
/// Le fichier local est-il scelle sous un code d'application ? Lu dans
|
|
/// l'en-tete, sans rien dechiffrer.
|
|
function VaultNeedsPin: Boolean;
|
|
|
|
/// 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;
|
|
const APin: 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;
|
|
|
|
/// Reglages du serveur : URL et jeton Bearer.
|
|
///
|
|
/// FICHIER SEPARE, ET PROTECTION PLUS FAIBLE, DELIBEREMENT. Ils sont chiffres
|
|
/// par la cle AES du Keystore - liee au materiel, mais lisible SANS
|
|
/// authentification. Deux raisons :
|
|
/// - il faut pouvoir les relire pour reconstruire un coffre efface, alors
|
|
/// que la cle qui protege le coffre ne sert a rien s'il n'existe plus ;
|
|
/// - le jeton ne donne acces qu'a du CHIFFRE. Sans le mot de passe de sync,
|
|
/// qui lui reste dans le coffre protege, il ne vaut rien.
|
|
/// Retaper un jeton de 64 caracteres apres chaque purge de cache serait le
|
|
/// vrai risque : celui de le noter ailleurs.
|
|
procedure SaveDeviceSettings(const AUrl, AToken: string);
|
|
function LoadDeviceSettings(out AUrl, AToken: string): Boolean;
|
|
|
|
implementation
|
|
|
|
{$IF DEFINED(ANDROID)}
|
|
uses
|
|
// PMA.Crypto pour PBKDF2 et SecureRandomBytes : le code de l'application
|
|
// derive sa cle exactement comme le conteneur de sync, avec la meme
|
|
// implementation eprouvee contre le vecteur de test.
|
|
Androidapi.Jni, Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers,
|
|
PMA.Crypto;
|
|
{$ENDIF}
|
|
|
|
const
|
|
VAULT_FILE = 'vault.bin';
|
|
SETTINGS_FILE = 'settings.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
|
|
// CODE CHOISI DANS L'APPLICATION, deux couches superposees :
|
|
// interieur : AES-GCM sous PBKDF2(code) -> il faut le code
|
|
// exterieur : cle AES du Keystore SANS contrainte -> il faut l'appareil
|
|
//
|
|
// C'est le seul schema qui survive a un changement de verrouillage d'ecran.
|
|
// VER_WRAP y meurt par construction : Android invalide une cle exigeant
|
|
// l'authentification des que le code de l'ecran change, precisement pour
|
|
// qu'un nouveau code n'ouvre pas d'anciennes donnees. La cle sans contrainte,
|
|
// elle, n'est pas invalidee - et le code de l'application rend la protection
|
|
// que cela retire.
|
|
VER_PIN = 4;
|
|
// 100k et non 600k : l'entropie d'un code a quatre ou six chiffres est
|
|
// faible, et multiplier les tours ralentit surtout l'usage honnete. La vraie
|
|
// barriere est la couche exterieure, qui exige l'appareil.
|
|
PIN_ITERS = 100000;
|
|
SALT_LEN = 16;
|
|
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;
|
|
procedure deleteEntry(alias: JString); 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, identiques au chiffrement et au dechiffrement.
|
|
///
|
|
/// DEUX DIGESTS DIFFERENTS, ET C'EST VOULU : SHA-256 pour le message, SHA-1
|
|
/// pour le MGF1.
|
|
///
|
|
/// setDigests() n'autorise que le digest DU MESSAGE. Celui du MGF1 relevait
|
|
/// d'une autorisation distincte, dont le defaut est SHA-1 et le reste tant
|
|
/// qu'on n'appelle pas setMgf1Digests (API 31+). Demander MGF1-SHA-256 au
|
|
/// dechiffrement donne donc une cle qui refuse :
|
|
///
|
|
/// KeymasterUtils: rsp_header->status: -28
|
|
/// javax.crypto.IllegalBlockSizeException
|
|
///
|
|
/// Le chiffrement, lui, passait : la cle publique reconstruite est servie par
|
|
/// le fournisseur logiciel, qui accepte n'importe quel MGF1. D'ou un chiffre
|
|
/// parfaitement valide que le materiel refuse de lire - le pire des symptomes,
|
|
/// puisqu'il n'apparait qu'a la relecture.
|
|
///
|
|
/// SHA-1 en MGF1 n'affaiblit pas OAEP : la fonction de masque n'a pas besoin
|
|
/// de resistance aux collisions.
|
|
function OaepParams: JAlgorithmParameterSpecX;
|
|
begin
|
|
Result := TJOAEPParameterSpec.JavaClass.init(
|
|
StringToJString('SHA-256'),
|
|
StringToJString('MGF1'),
|
|
TJMGF1ParameterSpec.JavaClass.init(StringToJString('SHA-1')),
|
|
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(AForceNew: Boolean = False);
|
|
var
|
|
LStore: JKeyStoreX;
|
|
LGen: JKeyPairGeneratorX;
|
|
LBuilder: JKeyGenParameterSpec_Builder;
|
|
LPaddings, LDigests: TJavaObjectArray<JString>;
|
|
begin
|
|
LStore := OpenKeyStore;
|
|
// AForceNew : la cle existe mais ne fonctionne plus. Retirer le
|
|
// verrouillage d'ecran, ou enroler une nouvelle empreinte, invalide
|
|
// DEFINITIVEMENT une cle protegee - or l'alias, lui, subsiste. Sans cette
|
|
// suppression on la reutiliserait indefiniment, chaque chiffrement
|
|
// echouerait, et on retomberait pour toujours sur la cle simple.
|
|
if AForceNew and LStore.containsAlias(StringToJString(KEY_ALIAS_WRAP)) then
|
|
LStore.deleteEntry(StringToJString(KEY_ALIAS_WRAP))
|
|
else 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('Public key not found in the 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}
|
|
|
|
{$IF DEFINED(ANDROID)}
|
|
/// Chiffre sous la cle AES du Keystore, sans contrainte d'authentification.
|
|
/// Format : [IV][chiffre+tag]. Sert aux reglages, pas au coffre.
|
|
function AesSeal(const APlain: TBytes): TBytes;
|
|
var
|
|
LCipher: JCipherX;
|
|
LIv, LCt: TBytes;
|
|
begin
|
|
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
|
|
LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, EnsureAesKey);
|
|
LIv := FromJava(LCipher.getIV); // genere par le Keystore, jamais choisi ici
|
|
LCt := FromJava(LCipher.doFinal(ToJava(APlain)));
|
|
SetLength(Result, Length(LIv) + Length(LCt));
|
|
Move(LIv[0], Result[0], Length(LIv));
|
|
Move(LCt[0], Result[Length(LIv)], Length(LCt));
|
|
end;
|
|
|
|
function AesOpen(const ARaw: TBytes): TBytes;
|
|
var
|
|
LCipher: JCipherX;
|
|
LIv, LCt: TBytes;
|
|
begin
|
|
if Length(ARaw) < IV_LEN + 16 then Exit(nil);
|
|
SetLength(LIv, IV_LEN);
|
|
Move(ARaw[0], LIv[0], IV_LEN);
|
|
SetLength(LCt, Length(ARaw) - IV_LEN);
|
|
Move(ARaw[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)));
|
|
Result := FromJava(LCipher.doFinal(ToJava(LCt)));
|
|
end;
|
|
{$ENDIF}
|
|
|
|
procedure SaveDeviceSettings(const AUrl, AToken: string);
|
|
{$IF DEFINED(ANDROID)}
|
|
var
|
|
LObj: TJSONObject;
|
|
begin
|
|
LObj := TJSONObject.Create;
|
|
try
|
|
LObj.AddPair('url', AUrl);
|
|
LObj.AddPair('token', AToken);
|
|
try
|
|
TFile.WriteAllBytes(TPath.Combine(TPath.GetHomePath, SETTINGS_FILE),
|
|
AesSeal(TEncoding.UTF8.GetBytes(LObj.ToJSON)));
|
|
except
|
|
// Des reglages non enregistres se retapent ; ce n'est pas une raison
|
|
// d'interrompre une synchronisation qui, elle, a reussi.
|
|
on E: Exception do FLastStoreError := E.ClassName + ': ' + E.Message;
|
|
end;
|
|
finally
|
|
LObj.Free;
|
|
end;
|
|
end;
|
|
{$ELSE}
|
|
begin
|
|
end;
|
|
{$ENDIF}
|
|
|
|
function LoadDeviceSettings(out AUrl, AToken: string): Boolean;
|
|
{$IF DEFINED(ANDROID)}
|
|
var
|
|
LPath: string;
|
|
LObj: TJSONObject;
|
|
begin
|
|
Result := False;
|
|
AUrl := '';
|
|
AToken := '';
|
|
LPath := TPath.Combine(TPath.GetHomePath, SETTINGS_FILE);
|
|
if not TFile.Exists(LPath) then Exit;
|
|
try
|
|
LObj := TJSONObject.ParseJSONValue(
|
|
TEncoding.UTF8.GetString(AesOpen(TFile.ReadAllBytes(LPath)))) as TJSONObject;
|
|
if LObj = nil then Exit;
|
|
try
|
|
AUrl := LObj.GetValue<string>('url', '');
|
|
AToken := LObj.GetValue<string>('token', '');
|
|
Result := AUrl <> '';
|
|
finally
|
|
LObj.Free;
|
|
end;
|
|
except
|
|
Result := False;
|
|
end;
|
|
end;
|
|
{$ELSE}
|
|
begin
|
|
Result := False;
|
|
AUrl := '';
|
|
AToken := '';
|
|
end;
|
|
{$ENDIF}
|
|
|
|
function VaultNeedsPin: Boolean;
|
|
{$IF DEFINED(ANDROID)}
|
|
var
|
|
LRaw: TBytes;
|
|
begin
|
|
Result := False;
|
|
if not TFile.Exists(VaultPath) then Exit;
|
|
try
|
|
LRaw := TFile.ReadAllBytes(VaultPath);
|
|
Result := (Length(LRaw) > 0) and (LRaw[0] = VER_PIN);
|
|
except
|
|
// Illisible : l'appelant le decouvrira en tentant de l'ouvrir.
|
|
end;
|
|
end;
|
|
{$ELSE}
|
|
begin
|
|
Result := False;
|
|
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, AFreshKey: Boolean; const APin: string);
|
|
{$IF DEFINED(ANDROID)}
|
|
var
|
|
LWrap: TJSONObject;
|
|
LPlain, LIv, LCt, LOut, LAesRaw, LWrapped, LSalt, LPinKey, LInner: 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;
|
|
|
|
if APin <> '' then
|
|
begin
|
|
// COUCHE INTERIEURE : le code. Sel neuf a chaque ecriture - il ne coute
|
|
// rien et evite qu'un meme code produise deux fois la meme cle.
|
|
LSalt := SecureRandomBytes(SALT_LEN);
|
|
LPinKey := DeriveKeyPBKDF2Native(APin, LSalt, PIN_ITERS, 32);
|
|
if LPinKey = nil then
|
|
LPinKey := DeriveKeyPBKDF2(APin, LSalt, PIN_ITERS, 32);
|
|
LIv := RandomBytes(IV_LEN);
|
|
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
|
|
LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE,
|
|
TJSecretKeySpecX.JavaClass.init(ToJava(LPinKey), StringToJString('AES')),
|
|
TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv)));
|
|
LCt := FromJava(LCipher.doFinal(ToJava(LPlain)));
|
|
|
|
// Sel et iterations voyagent EN CLAIR avec la charge : ils ne sont pas
|
|
// secrets, et sans eux le code ne redonnerait pas la meme cle.
|
|
SetLength(LInner, SALT_LEN + 4 + IV_LEN + Length(LCt));
|
|
LOfs := 0;
|
|
Move(LSalt[0], LInner[LOfs], SALT_LEN); Inc(LOfs, SALT_LEN);
|
|
LInner[LOfs] := Byte(PIN_ITERS shr 24); LInner[LOfs+1] := Byte(PIN_ITERS shr 16);
|
|
LInner[LOfs+2] := Byte(PIN_ITERS shr 8); LInner[LOfs+3] := Byte(PIN_ITERS);
|
|
Inc(LOfs, 4);
|
|
Move(LIv[0], LInner[LOfs], IV_LEN); Inc(LOfs, IV_LEN);
|
|
Move(LCt[0], LInner[LOfs], Length(LCt));
|
|
|
|
// COUCHE EXTERIEURE : la cle AES du Keystore, sans contrainte. Elle lie le
|
|
// fichier a l'appareil sans jamais etre invalidee par un changement de
|
|
// verrouillage d'ecran - c'est tout l'objet de ce format.
|
|
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
|
|
LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, EnsureAesKey);
|
|
LIv := FromJava(LCipher.getIV);
|
|
LCt := FromJava(LCipher.doFinal(ToJava(LInner)));
|
|
|
|
SetLength(LOut, 1 + Length(LIv) + Length(LCt));
|
|
LOut[0] := VER_PIN;
|
|
Move(LIv[0], LOut[1], Length(LIv));
|
|
Move(LCt[0], LOut[1 + Length(LIv)], Length(LCt));
|
|
TFile.WriteAllBytes(VaultPath, LOut);
|
|
Exit;
|
|
end;
|
|
|
|
LGotAuth := False;
|
|
LWrapped := nil;
|
|
if ARequireAuth then
|
|
begin
|
|
// UN SEUL ESSAI, et AFreshKey decide de tout.
|
|
//
|
|
// Le chiffrement NE PEUT PAS detecter une cle invalidee : la cle publique
|
|
// est reconstruite par KeyFactory, c'est une cle RSA ordinaire sans lien
|
|
// avec le Keystore, et elle chiffre parfaitement meme quand sa moitie
|
|
// privee est morte. Une boucle de reprise ici ne se declencherait jamais -
|
|
// elle produirait un fichier scelle qu'on ne pourrait plus jamais ouvrir.
|
|
//
|
|
// L'invalidation ne se constate qu'a la LECTURE, ou LoadVault jette
|
|
// l'alias. Et l'appelant qui rescelle un cache reste passe True, parce que
|
|
// la protection vient justement de reapparaitre - donc l'ancienne cle a
|
|
// tres probablement ete invalidee par ce meme changement.
|
|
try
|
|
EnsureWrapKey(AFreshKey);
|
|
// 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
|
|
on E: Exception do
|
|
begin
|
|
LWrapped := nil; // appareil sans protection d'ecran configuree
|
|
FLastStoreError := E.ClassName + ': ' + E.Message;
|
|
end;
|
|
end;
|
|
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('Keystore storage is Android-only');
|
|
end;
|
|
{$ENDIF}
|
|
|
|
function LoadVault(out APlainJson, AETag, ASyncPwd: string;
|
|
const APin: string = ''): Boolean;
|
|
{$IF DEFINED(ANDROID)}
|
|
var
|
|
LRaw, LIv, LCt, LPlain, LWrapped, LAesRaw, LInner, LSalt, LPinKey: TBytes;
|
|
LCipher: JCipherX;
|
|
LWrap: TJSONObject;
|
|
LStore: JKeyStoreX;
|
|
LLen, LOfs, LIter: 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_PIN then
|
|
begin
|
|
if APin = '' then
|
|
begin
|
|
FLastStoreError := 'Code requis';
|
|
Exit;
|
|
end;
|
|
// Couche exterieure d'abord : la cle de l'appareil, sans authentification.
|
|
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)));
|
|
LInner := FromJava(LCipher.doFinal(ToJava(LCt)));
|
|
|
|
if Length(LInner) < SALT_LEN + 4 + IV_LEN + 16 then Exit;
|
|
LOfs := 0;
|
|
SetLength(LSalt, SALT_LEN);
|
|
Move(LInner[LOfs], LSalt[0], SALT_LEN); Inc(LOfs, SALT_LEN);
|
|
LIter := (LInner[LOfs] shl 24) or (LInner[LOfs+1] shl 16)
|
|
or (LInner[LOfs+2] shl 8) or LInner[LOfs+3];
|
|
Inc(LOfs, 4);
|
|
SetLength(LIv, IV_LEN);
|
|
Move(LInner[LOfs], LIv[0], IV_LEN); Inc(LOfs, IV_LEN);
|
|
SetLength(LCt, Length(LInner) - LOfs);
|
|
Move(LInner[LOfs], LCt[0], Length(LCt));
|
|
|
|
// Couche interieure : le code. Un code faux echoue ICI, sur le tag GCM,
|
|
// et se lit donc comme une alteration - c'est le meme evenement.
|
|
LPinKey := DeriveKeyPBKDF2Native(APin, LSalt, LIter, 32);
|
|
if LPinKey = nil then
|
|
LPinKey := DeriveKeyPBKDF2(APin, LSalt, LIter, 32);
|
|
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
|
|
LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE,
|
|
TJSecretKeySpecX.JavaClass.init(ToJava(LPinKey), StringToJString('AES')),
|
|
TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv)));
|
|
LPlain := FromJava(LCipher.doFinal(ToJava(LCt)));
|
|
end
|
|
else 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 COFFRE. 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;
|
|
|
|
// ON JETTE EN REVANCHE LA CLE, quand le systeme dit lui-meme qu'elle est
|
|
// morte. C'est le SEUL moment ou l'invalidation se constate : le
|
|
// chiffrement, lui, reussit toujours - la cle publique reconstruite est
|
|
// une cle RSA ordinaire, sans lien avec le Keystore. Ne pas la jeter ici
|
|
// condamnerait toutes les ecritures suivantes a sceller des fichiers que
|
|
// rien ne pourra rouvrir.
|
|
if Pos('KeyPermanentlyInvalidated', E.ClassName + E.Message) > 0 then
|
|
try
|
|
OpenKeyStore.deleteEntry(StringToJString(KEY_ALIAS_WRAP));
|
|
FLastStoreError := FLastStoreError + ' (cle invalidee, jetee)';
|
|
except
|
|
// Rien : le message d'origine reste le plus utile.
|
|
end;
|
|
end;
|
|
end;
|
|
end;
|
|
{$ELSE}
|
|
begin
|
|
Result := False;
|
|
APlainJson := '';
|
|
AETag := '';
|
|
ASyncPwd := '';
|
|
end;
|
|
{$ENDIF}
|
|
|
|
end.
|