Files
Password-Manager/android-client
r-zakarya 9649dac7b2 feat(android): protect the local vault with biometric or device credential
The Keystore key now requires authentication, valid for 30 s afterwards - short enough that an unattended phone is not exploitable, long enough to decrypt and display. Time-bound rather than a per-use CryptoObject: BiometricPrompt's callback would need a Java class compiled into a jar, while FMX already bridges it.

Two aliases, because the auth constraint is fixed when the key is created and cannot be added later. The file header records which key wrote it, so a read knows whether to authenticate first.

A device with neither fingerprint nor lock screen cannot hold such a key, so SaveVault falls back to the plain key rather than giving up the cache. And a failed authentication no longer wipes the cache: the vault is intact, it is the user who cancelled - erasing it would cost a download and 4 s of derivation for a dismissed prompt.
2026-08-15 00:42:50 +01:00
..

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.

Chaîne validée de bout en bout

Sur appareil : GET authentifié (80 ms) → déchiffrement (3893 ms) → lecture du JSON, entrées vivantes et corbeille comptées séparément. Second appel : 304, rien de retéléchargé, rien de re-déchiffré.

L'ETag n'est retenu que si le déchiffrement a réussi. Le conserver dès la réception paraît naturel et c'est un piège : après un échec, le prochain appel répond 304 et l'application se retrouve sans données tout en se croyant à jour. L'ETag appartient au clair détenu, pas au téléchargement.

Diagnostiquer un échec de déchiffrement

try-open.js ouvre un conteneur avec Node, donc avec les primitives du navigateur :

node try-open.js <fichier> <mot-de-passe>

Il sépare deux causes que l'application ne peut pas distinguer — un tag GCM qui ne vérifie pas signifie mot de passe faux OU fichier altéré, sans plus. Si Node ouvre le fichier et pas le client, le défaut est dans le client.

Le mot de passe de sync n'est pas le mot de passe maître. Il n'est affiché nulle part : prefs.bin le garde chiffré par DPAPI, et l'application sait seulement le définir. En cas d'oubli, le redéfinir puis relancer un sync pour rechiffrer l'instantané distant.

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.