feat(android): adopt the platform PBKDF2, record the timing and what follows from it

Measured on device: Android derives the same key as WebCrypto on a non-ASCII password, so PBEKeySpec does encode as UTF-8 - the doubt that justified the Pascal implementation was unfounded. It is barely faster though, 3869 ms against 4139 ms: PBKDF2 gets no hardware acceleration.

So 4 seconds per container is incompressible, and the iteration count belongs to the desktop format. The answer is not to speed up the derivation but to stop repeating it: derive once per snapshot, then keep the vault locally under an Android Keystore key. Written up in android-client/README.md.
This commit is contained in:
r-zakarya
2026-08-14 23:30:15 +01:00
parent 307bbeae83
commit c684d9073f
2 changed files with 73 additions and 1 deletions
+7
View File
@@ -374,6 +374,13 @@ begin
if (Length(LSalt) = 0) or (Length(LIv) = 0) or (Length(LCt) = 0) then if (Length(LSalt) = 0) or (Length(LIv) = 0) or (Length(LCt) = 0) then
raise EPMACrypto.Create('Container is missing salt, iv or ciphertext'); raise EPMACrypto.Create('Container is missing salt, iv or ciphertext');
// Android's provider first — measured identical to WebCrypto on a
// non-ASCII password (so it does encode as UTF-8), and it is platform
// code rather than ours. Barely faster though: 3869 ms against 4139 ms,
// PBKDF2 gets no hardware acceleration. The Pascal path stays as the
// fallback, and is the only one outside Android.
LKey := DeriveKeyPBKDF2Native(APassword, LSalt, LIterations, 32);
if LKey = nil then
LKey := DeriveKeyPBKDF2(APassword, LSalt, LIterations, 32); LKey := DeriveKeyPBKDF2(APassword, LSalt, LIterations, 32);
LPlain := AesGcmDecrypt(LKey, LIv, LCt); LPlain := AesGcmDecrypt(LKey, LIv, LCt);
Result := TEncoding.UTF8.GetString(LPlain); Result := TEncoding.UTF8.GetString(LPlain);
+65
View File
@@ -0,0 +1,65 @@
# Client Android — état et décisions
Lecture seule de l'instantané chiffré du coffre. La crypto est **validée sur
appareil réel** contre un vecteur produit par WebCrypto.
## Ce qui est mesuré, pas supposé
| Question | Réponse | Mesure |
|---|---|---|
| Le conteneur s'ouvre-t-il en Delphi ? | oui | clair de 572 octets, empreinte identique |
| AES-GCM par JNI fonctionne-t-il ? | oui | `javax.crypto`, tag concaténé, rien à découper |
| Android encode-t-il le `char[]` en UTF-8 ? | **oui** | clé identique sur un mot de passe non-ASCII |
| Le PBKDF2 natif est-il plus rapide ? | **à peine** | 3869 ms contre 4139 ms |
Le natif est adopté — c'est du code de plateforme plutôt que le nôtre — avec le
Pascal en repli, seul disponible hors Android.
## La conséquence architecturale
**4 secondes pour ouvrir un instantané, et c'est incompressible.** PBKDF2 ne
bénéficie d'aucune accélération matérielle, et on ne peut pas baisser les
600 000 itérations : elles sont inscrites dans le conteneur produit par le
poste Windows.
Le piège serait de payer ces 4 secondes à **chaque ouverture de
l'application**. Ce n'est pas nécessaire, parce que le coût est lié au
**conteneur**, pas à la session — et chaque export génère un **nouveau sel**,
donc une mise en cache par mot de passe ne servirait à rien.
L'architecture qui en découle :
1. **À la synchronisation** — télécharger, dériver (4 s, une fois), déchiffrer.
2. **Puis stocker le coffre localement** chiffré sous une clé du **Android
Keystore**, adossée à la biométrie. AES est accéléré matériellement : la
relecture est instantanée.
3. **À l'ouverture** — biométrie, puis lecture locale. Aucune dérivation.
Les 4 secondes ne se paient donc qu'à l'arrivée d'un instantané **nouveau**,
ce qui est acceptable et compréhensible pour l'utilisateur.
> Le coffre déchiffré contient **tous les mots de passe en clair**. Il ne doit
> jamais toucher le disque autrement que chiffré sous la clé du Keystore, et la
> sauvegarde automatique Android doit être désactivée pour l'application.
## Fichiers
| | |
|---|---|
| `PMA.Crypto.pas` | PBKDF2 (Pascal + natif JNI), AES-GCM, ouverture du conteneur |
| `PMA.TestVector.pas` | le vecteur embarqué, pour le banc d'essai |
| `make-testvector.js` | régénère le vecteur avec WebCrypto |
| `testvector.md` | valeurs attendues, étage par étage |
Le banc d'essai est `mainApk.pas`, à la racine du dépôt.
## Piège de compilation rencontré
Cette installation de Delphi ne produisait **aucun** APK FireMonkey
fonctionnel : `fmx.dex.jar` avait été renommé sur disque **et** retiré de la
configuration de l'EDI. Symptôme : l'application s'installe puis se ferme
aussitôt, avec `ClassNotFoundException: FMXNativeActivity` dans `adb logcat`.
Le projet référence désormais explicitement `fmx.dex.jar` dans `EnabledSysJars`.
Après un tel changement, **supprimer les `classes*.dex` intermédiaires** : la
compilation les réutilise sans les régénérer, et le correctif semble sans effet.