Files
Password-Manager/android-client/README.md
T

256 lines
13 KiB
Markdown

# Client Android — état et décisions
> Ce qui reste à faire est dans [PLAN.md](PLAN.md).
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.
## L'interface
Trois écrans dans une seule forme, construits en code (`mainApk.pas`) :
déverrouillage, liste, détail. Une forme par écran aurait coûté trois `.fmx`
pour des dispositions triviales.
**Afficher d'abord, synchroniser ensuite.** Au lancement, le coffre local
s'ouvre tout seul (biométrie si la clé l'exige) et la liste apparaît sans
réseau ni dérivation ; le `GET` part derrière, en silence. Un 304 ne se voit
pas, un échec réseau non plus — hors ligne est un état normal, et une alerte à
chaque lancement apprendrait surtout à ignorer les alertes. Seul un instantané
**nouveau** se manifeste, en rafraîchissant la liste.
C'est ce qui justifie de ranger le **mot de passe de sync** dans le fichier
chiffré, à côté du coffre : sans lui, un instantané nouveau ne peut pas être
ouvert, et chaque modification faite sur le poste ferait apparaître une invite
au lancement. Le fichier contient déjà tous les mots de passe en clair ;
celui-ci n'y ajoute presque rien en exposition. Ce qui est tapé dans le champ
l'emporte sur ce qui est rangé — c'est ainsi qu'on corrige un mot de passe qui
ne convient plus.
L'écran de déverrouillage s'adapte à ce que l'appareil détient : coffre local
présent → *Ouvrir* (biométrie si la clé l'exige) ; absent → seul le
téléchargement peut en produire un. La liste filtre à la frappe sur le nom, le
site, l'identifiant et les étiquettes — **pas** sur le mot de passe : personne
ne cherche par mot de passe, et le comparer allongerait son séjour en mémoire.
Le détail est construit **par entrée**, pas d'après un gabarit fixe : site,
identifiant, mot de passe et champs personnalisés se ramènent tous à un
intitulé, une valeur, et le fait qu'elle soit secrète. Les traiter séparément
aurait dupliqué révélation et copie autant de fois qu'il y a de sortes de
champ. Deux exceptions assumées : le corps d'une **note**, qui est stocké dans
le champ mot de passe et s'affiche donc sous un autre intitulé, sans masque ;
et le **code 2FA**, seule ligne vivante, rafraîchie chaque seconde et placée en
tête — c'est ce qu'on vient chercher sur un téléphone.
La liste est triée par nom — l'ordre de l'instantané est celui de la base,
arbitraire à l'œil — et porte l'icône de l'entrée quand elle en a une, décodée
une fois par coffre et non à chaque frappe. Une liste déroulante filtre par
dossier ; il n'y a **pas** de filtre d'étiquette, la recherche texte les
couvrant déjà.
Le TOTP ([PMA.Totp.pas](PMA.Totp.pas)) accepte le base32 nu comme l'URI
`otpauth://`, les deux circulant dans les instantanés selon la façon dont
l'entrée a été créée. Vérifié contre les six vecteurs de la RFC 6238.
Deux règles tenues dans l'interface, pas ailleurs :
- **Le clair est jeté quand l'application passe en arrière-plan.** L'écran est
masqué sur `WillBecomeInactive`, qui précède la capture de l'aperçu des
applications récentes — sans quoi la liste des comptes y resterait visible —
et le coffre est réellement effacé sur `EnteredBackground`.
- **Le presse-papiers se vide après 45 s**, et seulement si son contenu est
toujours celui qu'on y a mis : l'utilisateur a pu copier autre chose
entre-temps.
L'écriture n'existe pas. Le poste Windows reste la source ; modifier depuis le
téléphone demanderait le `PUT` côté serveur et une fusion côté client.
### Tester sans téléphone
Tout sauf le Keystore et la biométrie tourne en **Win64**. `PMA.Crypto` a une
branche Windows adossée à **CNG** (`bcrypt.dll`) — même primitive, aucune DLL à
livrer, et la clé dérivée est identique à celle d'Android (vérifié sur le
vecteur, mot de passe non-ASCII compris). `PMA.Store` répond « pas de coffre »
hors Android, donc chaque lancement retélécharge et redérive.
```bash
"C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dcc64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\Win64\release" pmserverApk.dpr
```
`selftest.dpr` vérifie la chaîne crypto contre le vecteur en console — c'est ce
qui a remplacé le bouton *Test crypto* du banc d'essai. Code de sortie = nombre
d'échecs :
```bash
"C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dcc64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\Win64\release" android-client\selftest.dpr
```
Mesures sur ce poste : PBKDF2 Pascal 1670 ms, CNG **528 ms**, clés identiques.
## Diagnostiquer un échec de déchiffrement
`try-open.js` ouvre un conteneur avec Node, donc avec les primitives du
navigateur :
```bash
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.SyncClient.pas` | `GET` authentifié, ETag, statuts distingués |
| `PMA.Store.pas` | coffre local chiffré sous une clé du Keystore |
| `PMA.Biometric.pas` | authentification via `FMX.BiometricAuth` |
| `PMA.Vault.pas` | l'instantané en mémoire : lecture et recherche |
| `PMA.Totp.pas` | codes TOTP (RFC 6238), base32 ou `otpauth://` |
| `PMA.ScreenGuard.pas` | `FLAG_SECURE` (Release seulement) |
| `selftest.dpr` | vérification console : conteneur + TOTP |
| `PMA.TestVector.pas` | le vecteur embarqué, plus référencé par l'interface |
| `make-testvector.js` | régénère le vecteur avec WebCrypto |
| `testvector.md` | valeurs attendues, étage par étage |
L'interface est `mainApk.pas`, à la racine du dépôt.
## Fabriquer l'APK en ligne de commande
L'EDI y arrive seul ; MSBuild non, et l'erreur ne dit pas pourquoi.
Le `.dproj` déclare les **classes** de déploiement Android (`AndroidSplashStyles`,
`Android_LauncherIcon96`…) mais **aucun fichier** rattaché à ces classes : l'EDI
les fournit implicitement, depuis son gestionnaire de déploiement. Hors EDI,
`aapt` est donc appelé sur un dossier `res` qui n'a jamais été rempli, et se
plaint d'un répertoire absent — pas d'une configuration manquante.
De quoi il a besoin, mis en place à la main sous `Android64\Debug\pmserverApk\` :
| Destination | Source |
|---|---|
| `res\` (arbre complet) | produit par **une compilation depuis l'EDI**, n'importe quelle plateforme Android — c'est la seule chose que MSBuild ne sait pas régénérer. Il ne pèse que 60 Ko ; s'il disparaît d'un nettoyage, une compilation EDI le refait |
| `res\values\styles.xml`, `values-v21`, `values-v31` | `Android64\Debug\styles*.xml` |
| `res\values\colors.xml`, `values-night-v21\colors.xml` | `Android64\Debug\colors*.xml` |
| `res\values\strings.xml` | `Android64\Debug\strings.xml` |
| `res\drawable\splash_image_def.xml`, `drawable-anydpi-v21` | `Android64\Debug\splash_image_def*.xml` |
| `res\drawable-anydpi-v26\ic_launcher.xml`, `-v33` | `Android64\Debug\ic_launcher*.xml` |
| `AndroidManifest.xml` | `Android64\Debug\AndroidManifest.xml` |
| `classes\*.dex` | `Android64\Debug\pmserverApk.classes\*.dex` |
| `library\lib\arm64-v8a\libpmserverApk.so` | `Android64\Debug\libpmserverApk.so` |
Ensuite `msbuild pmserverApk.dproj /p:Platform=Android64 /p:Config=Debug
/t:Deploy` produit et signe `bin\pmserverApk.apk`, et `adb install -r` l'installe.
Le correctif durable serait d'écrire ces entrées une fois dans le `.dproj`.
> **Place disque.** Le `.so` de debug pèse **91 Mo** (symboles), et le volume
> `Z:` n'en fait que 2 Go. Le dépouiller avec le `strip` du NDK le ramène à
> 54 Mo, et l'APK final à 16 Mo — mais `strip` écrit sa copie temporaire **à
> côté de la cible**, donc il faut écrire vers un autre volume puis recopier.
> Prévoir ~200 Mo libres avant une compilation Android, sinon l'édition de
> liens échoue sans que rien n'indique la vraie cause.
Pour que le téléphone voie l'endpoint local :
```bash
adb reverse tcp:8080 tcp:80
```
> **Le tunnel est volatil.** Il disparaît dès que le lien USB se réinitialise —
> câble rebranché, appareil re-énuméré, serveur adb relancé — et l'application
> ne dit alors que « failed to connect ». À vérifier en premier :
> `adb reverse --list` doit répondre `UsbFfs tcp:8080 tcp:80`. Un test direct
> depuis l'appareil tranche entre le tunnel et le serveur :
> `adb shell curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/server-php/sync.php`
> — `404` signifie que la chaîne est bonne (c'est la réponse voulue sans jeton),
> `000` que rien n'écoute.
## Le piège de la porte sans clé
`USE_BIOMETRIC` **ne fait pas partie de la liste de permissions par défaut de
Delphi**. Sans elle, `CanAuthenticate` ne répond pas « pas de biométrie » : il
lève une `java.lang.SecurityException`. Le défaut ne se voit donc ni à la
compilation, ni au démarrage, mais à la première tentative d'ouverture — après
que le cache a déjà été écrit sous une clé protégée par authentification.
Le résultat est le pire cas possible : **un cache fermé derrière une porte que
l'application ne sait pas ouvrir**. Trois corrections, dont une seule est la
permission :
1. Les deux `uses-permission` dans le modèle de manifeste.
2. `BiometricAvailable` et `AuthenticateThen` attrapent ce qui remonte de la
couche Java. L'appelant a un chemin pour chaque `TBioOutcome`, il n'en a
aucun pour une exception.
3. **`SaveVault` ne reçoit plus `True` en dur** mais `BiometricAvailable`. On
ne demande une clé sous authentification que si l'invite peut réellement
s'afficher — sinon le cache serait illisible pour toujours, et la seule
sortie serait de l'effacer.
## 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.