Files
2026-09-17 15:13:13 +01:00

1337 lines
53 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.
/// AKey/ASalt/AIterations : la cle DEJA DERIVEE du conteneur, rangee avec le
/// reste. Elle epargne 600 000 iterations - quatre a six secondes - au premier
/// enregistrement de chaque session, qui autrement rederivait un mot de passe
/// que ce meme fichier detient deja en clair juste a cote. C'est ce voisinage
/// qui rend l'ajout acceptable : le blob contient le coffre entier en clair et
/// le mot de passe qui ouvre le conteneur, donc la cle n'y ajoute rien.
///
/// Elle ne vaut que pour un conteneur portant CE sel et CE nombre
/// d'iterations ; les deux voyagent avec, et le lecteur verifie.
procedure SaveVault(const APlainJson, AETag, ASyncPwd: string;
ARequireAuth: Boolean = False; AFreshKey: Boolean = False;
const APin: string = ''; const AKey: TBytes = nil;
const ASalt: TBytes = nil; AIterations: Integer = 0;
AClearPin: Boolean = False);
/// VRAI quand la derniere lecture a echoue parce que la cle protegee a ete
/// DETRUITE par le systeme - verrouillage d'ecran retire ou reinitialise.
///
/// Un drapeau, et non un test sur le texte du message : c'est exactement ce
/// tri-la, fait sur des chaines, qui detruisait autrefois le cache en se
/// trompant. Ici l'information vient du systeme lui-meme, une seule fois, a
/// l'endroit ou elle est certaine.
function LastStoreKeyInvalidated: Boolean;
/// 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;
out AKey, ASalt: TBytes; out AIterations: Integer;
const APin: string = ''): Boolean;
/// AKey rend nil quand le cache a ete ecrit sans cle - un fichier d'avant, ou
/// une ecriture qui n'en tenait pas. L'appelant rederive alors, comme avant.
/// 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;
/// LE CONTENEUR LOCAL - l'autorite quand l'appareil se sert seul.
///
/// Il porte EXACTEMENT ce que porterait le depot : meme format, meme
/// chiffrement sous mot de passe (`pm-encrypted-export-v1`, PBKDF2 600k). Deux
/// raisons, pas une. C'est le modele du poste - le mot de passe maitre protege
/// le coffre au repos, et pas seulement le materiel. Et le format restant celui
/// du depot, la synchronisation continue de marcher sans rien convertir.
///
/// PAS DE KEYSTORE ICI, volontairement : ce fichier est deja chiffre sous le
/// mot de passe, comme sa copie distante. Le lier en plus a l'appareil le
/// rendrait irrecuperable apres une reinstallation, alors que le mot de passe,
/// lui, se retient. `vault.bin` garde ce role - c'est le cache d'ouverture
/// rapide, et lui seul a besoin d'etre scelle.
function HasLocalContainer: Boolean;
function LoadLocalContainer: string;
procedure SaveLocalContainer(const AContainer: string);
/// Efface TOUT ce que l'appareil detient du coffre : le cache, le conteneur
/// local et le drapeau d'attente. Ne touche pas aux reglages du depot - c'est
/// le coffre qu'on jette, pas la facon d'aller le rechercher.
///
/// SANS RETOUR, et sans garde ici : l'appelant est le seul a savoir si
/// quelqu'un l'a demande. Reserve au debogage.
procedure DropLocalVault;
/// Efface le conteneur local. A n'appeler QU'APRES avoir publie son contenu :
/// c'est le geste qui fait passer l'autorite au depot, et il est sans retour.
procedure DropLocalContainer;
/// DES MODIFICATIONS ATTENDENT D'ETRE PUBLIEES.
///
/// UN FICHIER A PART, ET NON UN CHAMP DU CACHE : ce drapeau ne porte aucun
/// secret - sa seule information est "oui" ou "non" -, il n'a donc rien a
/// faire dans un blob chiffre dont l'ecriture coute une derivation et dont la
/// signature est deja longue de neuf parametres.
///
/// IL DOIT SURVIVRE A UN REDEMARRAGE. En memoire seulement, une application
/// tuee entre l'enregistrement hors ligne et la publication oubliait qu'elle
/// avait quelque chose a envoyer - et le rafraichissement suivant remplacait
/// le cache par la version du depot, effacant la modification sans un mot.
function HasPendingFlag: Boolean;
procedure SetPendingFlag(AOn: Boolean);
/// Chemin du fichier, pour le journal et les tests.
function VaultPath: string;
/// COPIE DE SECOURS du cache quand il est scelle par le verrouillage d'ecran.
///
/// Retirer le schema (ou le reinitialiser) fait DETRUIRE par Android la cle qui
/// scelle vault.bin : le coffre devenait illisible et il fallait le
/// retelecharger. SaveVault ecrit donc a cote une seconde copie scellee sous le
/// mot de passe (meme format que le code d'app, VER_PIN : cle de l'appareil
/// sans contrainte + PBKDF2 du mot de passe), que rien dans Android n'invalide.
/// Elle n'existe QUE pour un cache VER_WRAP ; tout autre scellement l'efface.
function HasRecoveryVault: Boolean;
function LoadRecoveryVault(out APlainJson, AETag, ASyncPwd: string;
out AKey, ASalt: TBytes; out AIterations: Integer;
const APwd: string): Boolean;
/// 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,
// System.NetEncoding : la cle derivee voyage en base64 dans le blob, comme
// tout ce que le conteneur transporte.
System.NetEncoding, PMA.Crypto;
{$ENDIF}
const
VAULT_FILE = 'vault.bin';
LOCAL_FILE = 'vault-local.json';
PENDING_FILE = 'pending.flag';
SETTINGS_FILE = 'settings.bin';
RECOVER_FILE = 'vault-recover.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 = '';
FLastStoreKeyInvalidated: Boolean = False;
function LastStoreError: string;
begin
Result := FLastStoreError;
end;
function LastStoreKeyInvalidated: Boolean;
begin
Result := FLastStoreKeyInvalidated;
end;
function LocalContainerPath: string;
begin
Result := TPath.Combine(TPath.GetHomePath, LOCAL_FILE);
end;
function HasLocalContainer: Boolean;
begin
Result := TFile.Exists(LocalContainerPath);
end;
function LoadLocalContainer: string;
begin
Result := '';
if TFile.Exists(LocalContainerPath) then
Result := TFile.ReadAllText(LocalContainerPath, TEncoding.UTF8);
end;
procedure SaveLocalContainer(const AContainer: string);
begin
TFile.WriteAllText(LocalContainerPath, AContainer, TEncoding.UTF8);
end;
procedure DropLocalContainer;
begin
if TFile.Exists(LocalContainerPath) then
TFile.Delete(LocalContainerPath);
end;
function RecoverPath: string;
begin
Result := TPath.Combine(TPath.GetHomePath, RECOVER_FILE);
end;
procedure DropLocalVault;
begin
// Chacun sous son propre try : un fichier absent ou verrouille ne doit pas
// empecher les deux autres de partir - un effacement a moitie fait laisse
// l'appareil dans un etat que personne n'a voulu.
try if TFile.Exists(VaultPath) then TFile.Delete(VaultPath); except end;
try if TFile.Exists(RecoverPath) then TFile.Delete(RecoverPath); except end;
try DropLocalContainer; except end;
try SetPendingFlag(False); except end;
end;
function PendingPath: string;
begin
Result := TPath.Combine(TPath.GetHomePath, PENDING_FILE);
end;
function HasPendingFlag: Boolean;
begin
Result := TFile.Exists(PendingPath);
end;
procedure SetPendingFlag(AOn: Boolean);
begin
try
if AOn then TFile.WriteAllText(PendingPath, '1')
else if TFile.Exists(PendingPath) then TFile.Delete(PendingPath);
except
// Un drapeau qu'on n'a pas pu ecrire ne doit pas faire echouer un
// enregistrement qui, lui, a reussi.
end;
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 HasRecoveryVault: Boolean;
begin
Result := TFile.Exists(RecoverPath);
end;
function HasVault: Boolean;
begin
Result := TFile.Exists(VaultPath);
end;
procedure ClearVault;
begin
if TFile.Exists(VaultPath) then
TFile.Delete(VaultPath);
if TFile.Exists(RecoverPath) then
TFile.Delete(RecoverPath);
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 SaveVaultAt(const APath, APlainJson, AETag, ASyncPwd: string;
ARequireAuth, AFreshKey: Boolean; const APin: string;
const AKey, ASalt: TBytes; AIterations: Integer; AClearPin: Boolean);
{$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.
// UN CACHE SCELLE SOUS UN CODE NE SE REECRIT JAMAIS SANS LUI. Sans cette
// regle, le premier rafraichissement venu le reecrivait dans l'ancienne
// forme - le code etait pose, puis defait en silence quelques secondes plus
// tard, sans que rien ne le dise. Le refuser fait perdre un instantane, ce
// que la prochaine ouverture rattrape ; l'accepter fait perdre la
// protection, ce que rien ne rattrape.
//
// Retirer le code demanderait donc un geste explicite, qui n'existe pas
// encore : aujourd'hui on le remplace en le retapant.
// AClearPin EST LA SEULE SORTIE, et elle se demande. Le refus ci-dessous
// protege tous les autres chemins - c'est lui qui a empeche une
// synchronisation de defaire un code en silence. Retirer un code reste
// possible, mais seulement quand quelqu'un l'a decide et vient de prouver
// qu'il le connaissait en ouvrant le coffre.
if (APin = '') and VaultNeedsPin and not AClearPin then
begin
FLastStoreError := 'cache sealed under an unlock code; refusing to ' +
'rewrite it without one';
Exit;
end;
LWrap := TJSONObject.Create;
try
LWrap.AddPair('etag', AETag);
LWrap.AddPair('pwd', ASyncPwd);
LWrap.AddPair('vault', APlainJson);
// Les trois ensemble ou aucun : une cle sans son sel ne dit pas a quoi
// elle convient, et serait donc inutilisable - ou pire, utilisee a tort.
if (AKey <> nil) and (ASalt <> nil) and (AIterations > 0) then
begin
LWrap.AddPair('kdf_key', TNetEncoding.Base64.EncodeBytesToString(AKey));
LWrap.AddPair('kdf_salt', TNetEncoding.Base64.EncodeBytesToString(ASalt));
LWrap.AddPair('kdf_iterations', TJSONNumber.Create(AIterations));
end;
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(APath, 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(APath, LOut);
end;
{$ELSE}
begin
raise EPMAStore.Create('Keystore storage is Android-only');
end;
{$ENDIF}
procedure SaveVault(const APlainJson, AETag, ASyncPwd: string;
ARequireAuth, AFreshKey: Boolean; const APin: string;
const AKey, ASalt: TBytes; AIterations: Integer; AClearPin: Boolean);
begin
SaveVaultAt(VaultPath, APlainJson, AETag, ASyncPwd, ARequireAuth, AFreshKey,
APin, AKey, ASalt, AIterations, AClearPin);
// Suit le scellement REEL (lu dans l'en-tete), pas celui demande : un
// appareil sans verrouillage retombe sur VER_PLAIN, et une ecriture refusee
// laisse l'ancien en place.
try
if VaultNeedsAuth and (ASyncPwd <> '') then
SaveVaultAt(RecoverPath, APlainJson, AETag, ASyncPwd, False, False,
ASyncPwd, AKey, ASalt, AIterations, False)
else if TFile.Exists(RecoverPath) then
TFile.Delete(RecoverPath);
except
on E: Exception do
FLastStoreError := 'recovery copy not written: ' + E.Message;
end;
end;
function LoadVaultAt(const APath: string; out APlainJson, AETag, ASyncPwd: string;
out AKey, ASalt: TBytes; out AIterations: Integer;
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 := '';
AKey := nil;
ASalt := nil;
AIterations := 0;
FLastStoreError := '';
if not TFile.Exists(APath) then Exit;
try
LRaw := TFile.ReadAllBytes(APath);
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', '');
// Absents d'un cache ecrit avant que la cle y soit rangee : on rend nil,
// et l'appelant rederive comme il le faisait.
AIterations := LWrap.GetValue<Integer>('kdf_iterations', 0);
if AIterations > 0 then
begin
AKey := TNetEncoding.Base64.DecodeStringToBytes(
LWrap.GetValue<string>('kdf_key', ''));
ASalt := TNetEncoding.Base64.DecodeStringToBytes(
LWrap.GetValue<string>('kdf_salt', ''));
end;
if (AKey = nil) or (ASalt = nil) then
begin
AKey := nil;
ASalt := nil;
AIterations := 0;
end;
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
FLastStoreKeyInvalidated := True;
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}
function LoadVault(out APlainJson, AETag, ASyncPwd: string;
out AKey, ASalt: TBytes; out AIterations: Integer;
const APin: string = ''): Boolean;
begin
Result := LoadVaultAt(VaultPath, APlainJson, AETag, ASyncPwd, AKey, ASalt,
AIterations, APin);
end;
function LoadRecoveryVault(out APlainJson, AETag, ASyncPwd: string;
out AKey, ASalt: TBytes; out AIterations: Integer;
const APwd: string): Boolean;
begin
Result := (APwd <> '') and
LoadVaultAt(RecoverPath, APlainJson, AETag, ASyncPwd, AKey, ASalt,
AIterations, APwd);
end;
end.