fa424a3bc7
Removing the screen lock permanently invalidates every Keystore key that requires authentication, so the cache was unreadable for good and re-downloading was the only way out. The automatic open kept the cache and reported; only the explicit tap cleared it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
16 KiB
Markdown
328 lines
16 KiB
Markdown
# Client Android — ce qu'il reste à faire
|
|
|
|
État au 2026-08-15. Le client lit l'instantané et l'affiche ; il n'écrit rien.
|
|
Ce document liste le reste, dans l'ordre où je le ferais.
|
|
|
|
Voir [README.md](README.md) pour l'architecture et les décisions déjà prises.
|
|
|
|
---
|
|
|
|
## 0. Ce que l'instantané contient déjà
|
|
|
|
À vérifier avant de réclamer quoi que ce soit au poste Windows : la plupart des
|
|
manques côté téléphone ne demandent **aucun** changement côté serveur.
|
|
|
|
| Champ | Dans l'instantané | Lu par le client |
|
|
|---|---|---|
|
|
| `uuid`, `title`, `site`, `username`, `password` | oui | oui |
|
|
| `folder`, `tags`, `kind` | oui | oui |
|
|
| `totp_secret` | oui | oui |
|
|
| `custom_fields` (`label`/`value`/`is_secret`) | oui | oui |
|
|
| `template` (carte, clé SSH, serveur…) | oui | **non** |
|
|
| `icon_b64`, `icon_custom` | oui | **non** |
|
|
| `attachments` (avec `content_b64`) | oui | **non** |
|
|
| `created_at`, `updated_at` | oui | **non** |
|
|
| `folders` (liste, couleur, icône) | oui | **non** |
|
|
| `avatar_b64`, `username` (compte) | oui | **non** |
|
|
| `tombstones` | oui | **non** |
|
|
| `favorite`, `pinned` | **non** — device-local par conception | — |
|
|
| corbeille | **non** — voyage dans les sauvegardes, pas la sync | — |
|
|
|
|
Autrement dit : dossiers, icônes, pièces jointes et horodatages sont déjà là,
|
|
il ne manque que la lecture. Seules les favoris/épinglés et la corbeille
|
|
demanderaient une décision côté Windows — et pour les deux, la décision
|
|
actuelle est délibérée.
|
|
|
|
---
|
|
|
|
## 1. À vérifier sur téléphone — aucun code
|
|
|
|
L'émulateur n'a ni empreinte ni écran de verrouillage, donc `PMA.Store` est
|
|
retombé sur la clé sans contrainte à chaque essai. Ces trois points n'ont
|
|
jamais été exercés :
|
|
|
|
1. **Création de la clé sous authentification** — `SaveVault(…, True)` doit
|
|
écrire un en-tête à `2`, et le journal doit dire « protégé par
|
|
biométrie/code » et non « AUCUNE protection ».
|
|
2. **Ouverture** — `VaultNeedsAuth` → invite système → déchiffrement dans les
|
|
30 s de validité de la clé. Au-delà, `LoadVault` échoue **sans** effacer le
|
|
cache : la garde sur `UserNotAuthenticated` est écrite mais jamais éprouvée.
|
|
3. **Cache d'un lancement à l'autre** — fermer, rouvrir, *Ouvrir le coffre* :
|
|
ni réseau ni mot de passe, et 0 ms de dérivation.
|
|
|
|
Un quatrième cas mérite d'être provoqué exprès : **enrôler une nouvelle
|
|
empreinte** invalide la clé définitivement
|
|
(`KeyPermanentlyInvalidatedException`). Le cache doit alors être effacé et
|
|
retéléchargé, pas rester illisible. C'est l'autre branche de la même garde.
|
|
|
|
---
|
|
|
|
## 1 bis. ~~La décision qui bloque le cache~~ — RÉSOLU le 2026-08-15
|
|
|
|
Mesuré sur appareil (schéma d'écran, aucune empreinte enrôlée) :
|
|
|
|
```
|
|
cache ecrit - protege par biometrie/code (aucune invite à l'écriture)
|
|
--- redémarrage à froid, saisie du schéma ---
|
|
4 entrees, 2 en corbeille (aucune ligne « dechiffre en … ms »)
|
|
HTTP 304 en 136 ms
|
|
304 : inchange, coffre deja ouvert
|
|
```
|
|
|
|
Les points §1.1, §1.2, §1.3 et §1.5 sont donc validés d'un coup : clé créée sous
|
|
authentification, ouverture par schéma, cache survivant à un démarrage à froid
|
|
sans dérivation, et rafraîchissement de fond silencieux.
|
|
|
|
**Trois pièges se cachaient derrière, aucun visible à la compilation** :
|
|
|
|
1. `USE_BIOMETRIC` absente de la liste par défaut de Delphi — et
|
|
`CanAuthenticate` lève au lieu de répondre `False`.
|
|
2. FMX n'affiche pas l'invite depuis notre activité : il lance
|
|
`BiometricFragmentActivity`, qui doit être déclarée au manifeste.
|
|
3. **Le digest du MGF1.** `setDigests()` n'autorise que celui du message ;
|
|
celui du MGF1 est une autorisation distincte dont le défaut est SHA-1.
|
|
Le chiffrement passait quand même — la clé publique reconstruite est servie
|
|
par le fournisseur logiciel, qui accepte n'importe quel MGF1 — et produisait
|
|
un chiffré valide que le matériel refusait de lire. Le symptôme
|
|
n'apparaissait qu'au lancement suivant.
|
|
|
|
**§1.4 validé aussi** : une minute d'attente, téléphone verrouillé puis
|
|
déverrouillé, réouverture → la fenêtre de 30 s avait expiré, l'invite est
|
|
réapparue, et le cache était intact. C'est précisément le cas qui détruisait le
|
|
fichier avant que `LoadVault` cesse de trancher lui-même.
|
|
|
|
**§1.6 validé** (2026-08-15), par la variante la plus radicale : suppression du
|
|
verrouillage d'écran, qui invalide définitivement toute clé Keystore exigeant
|
|
une authentification. La clé privée RSA disparue, `vault.bin` est illisible
|
|
pour toujours, et l'application a bien reparti sur un retéléchargement.
|
|
|
|
Ce qui compte est *quand* elle a effacé : l'ouverture automatique au lancement
|
|
a constaté l'indisponibilité et **gardé le cache** ; il a fallu un appui
|
|
explicite sur *Open vault* pour qu'elle le supprime. Diagnostic sûr, puis geste
|
|
de l'utilisateur — la règle posée après l'incident du matin tient.
|
|
|
|
**§1 est donc complet, six points sur six.**
|
|
|
|
<details>
|
|
<summary>Le diagnostic d'origine, conservé pour la trace</summary>
|
|
|
|
## La décision qui bloquait le cache
|
|
|
|
Le cache ne s'écrit presque jamais. Journal sur appareil :
|
|
|
|
```
|
|
dechiffre en 4446 ms
|
|
cache non ecrit : EJNIException - android.security.key…
|
|
```
|
|
|
|
**Cause.** Une clé Keystore créée avec `setUserAuthenticationRequired` +
|
|
`setUserAuthenticationValidityDurationSeconds` exige une authentification
|
|
récente **pour chiffrer autant que pour déchiffrer**. Or l'écriture suit un
|
|
téléchargement silencieux, sans invite : passé la fenêtre de 30 s depuis le
|
|
dernier déverrouillage, `Cipher.init(ENCRYPT_MODE)` lève. La toute première
|
|
écriture avait réussi seulement parce que le téléphone venait d'être
|
|
déverrouillé — d'où un test qui passe puis ne passe plus.
|
|
|
|
Conséquence : l'application fonctionne, mais redérive 4,4 s à chaque
|
|
lancement. Le cache, qui est toute la raison d'être de `PMA.Store`, ne sert
|
|
jamais.
|
|
|
|
**Trois issues, par ordre de justesse :**
|
|
|
|
1. **Paire de clés asymétrique** — c'est le motif documenté par Android pour
|
|
« écrire à tout moment, lire sous authentification ». La partie publique
|
|
chiffre sans invite, la partie privée (protégée) déchiffre. Comme RSA ne
|
|
chiffre pas 16 Ko, il faut un chiffrement hybride : une clé AES aléatoire
|
|
par écriture, enveloppée par la clé publique. Plus de fenêtre de validité,
|
|
plus de course contre le temps. ~80 lignes de JNI.
|
|
2. **Authentifier avant d'écrire** — une invite à chaque synchronisation de
|
|
fond, ce qui détruit précisément le silence recherché.
|
|
3. **Allonger la fenêtre** — atténue sans corriger, et affaiblit la garantie
|
|
proportionnellement.
|
|
|
|
Retenir la 1. Les deux autres échangent une propriété de sécurité contre du
|
|
confort, ce qui est le mauvais sens pour un gestionnaire de mots de passe.
|
|
|
|
</details>
|
|
|
|
## 1 ter. Écritures complexes — Skia (2026-08-15)
|
|
|
|
Un nom de pièce jointe en arabe s'affichait en désordre. Ce n'était pas un
|
|
réglage manqué : le moteur de texte d'origine de FMX ne met pas en forme les
|
|
écritures complexes — pas de réordonnancement bidirectionnel, pas de liaison
|
|
des lettres. Séparer la taille du nom limitait les dégâts sans les supprimer.
|
|
|
|
`GlobalUseSkia := True` avant `Application.Initialize` remplace le moteur de
|
|
rendu ; Skia embarque HarfBuzz et fait ce travail. Le prix est `libsk4d.so`,
|
|
25 Mo (8,9 Mo compressés), et un **APK qui passe de 13 à 22 Mo**.
|
|
|
|
Conséquence sur la fabrication : `Deploy` reconstruit `library\lib` avant
|
|
`aapt`, donc la bibliothèque posée à la main disparaît. D'où
|
|
[repack-apk.ps1](repack-apk.ps1), qui refait *aapt → zipalign → signature* sur
|
|
le dossier déjà prêt. Une entrée `DeployFile` en bonne et due forme a été
|
|
tentée d'abord : MSBuild l'ignore, comme il ignore toutes les autres ici.
|
|
|
|
## 2. Durcissement — petit, à faire avant tout usage réel
|
|
|
|
Par ordre de gravité, pas de taille.
|
|
|
|
1. ~~**`android:allowBackup="false"`**~~ — **fait**. Le répertoire privé
|
|
contient `vault.bin` ; la sauvegarde Google n'a rien à y faire, même si ce
|
|
qui en sortirait serait chiffré par une clé restée dans la puce.
|
|
2. **Retirer `usesCleartextTraffic="true"`** du manifeste, et servir
|
|
l'endpoint en HTTPS avec un vrai certificat. L'attribut est marqué
|
|
TEST-ONLY ; le jeton Bearer circule à chaque appel. **Gardé pour l'instant** :
|
|
le retirer casserait l'essai local par `adb reverse`, qui passe en clair.
|
|
3. ~~**`FLAG_SECURE`**~~ — **fait**, [PMA.ScreenGuard.pas](PMA.ScreenGuard.pas),
|
|
**en Release seulement** : en debug le drapeau interdit aussi les captures
|
|
d'écran de développement. Le vérifier demande donc une compilation Release.
|
|
Complémentaire du masquage sur `WillBecomeInactive`, pas redondant : celui-ci
|
|
traite l'aperçu des récentes, le drapeau traite en plus les captures
|
|
manuelles, l'enregistrement d'écran et la duplication vers un écran externe.
|
|
4. ~~**Marquer le presse-papiers sensible**~~ — **fait et vérifié** : Android
|
|
affiche `•••••` au lieu du contenu. Passe par `ClipboardManager` en direct,
|
|
le drapeau étant un extra du `ClipDescription` qu'`IFMXClipboardService`
|
|
n'expose pas. Voir [PMA.Clipboard.pas](PMA.Clipboard.pas). Ancien texte :
|
|
depuis Android 13 le système
|
|
affiche un aperçu de ce qui vient d'être copié, mot de passe compris.
|
|
`ClipDescription` + extra `android.content.extra.IS_SENSITIVE`.
|
|
5. **Sortir l'URL et le jeton du code source.** Ce sont encore des constantes.
|
|
Le mot de passe de sync, lui, est déjà rangé dans le fichier chiffré par le
|
|
Keystore ; reste à en faire autant pour les deux autres, et à supprimer le
|
|
préremplissage depuis [PMA.LocalConfig.inc](PMA.LocalConfig.template.inc).
|
|
6. ~~**Verrouillage sur inactivité au premier plan**~~ — **fait**, 3 minutes,
|
|
échantillonné toutes les 15 s. Reste à constater passivement sur appareil.
|
|
|
|
7. **Synchroniser passait à côté de la porte** — corrigé le 2026-08-15 : il
|
|
reconstruisait le coffre depuis le serveur et l'affichait sans jamais
|
|
demander l'authentification, soit le même résultat qu'*Ouvrir le coffre*, à
|
|
un bouton près. Signalé par l'utilisateur, pas par un test — une porte
|
|
verrouillée ne se remarque que si on essaie l'autre.
|
|
|
|
---
|
|
|
|
## 3. Compléter la lecture — moyen
|
|
|
|
Rangé par ce que ça apporte réellement sur un téléphone.
|
|
|
|
1. **Pièces jointes** — *listées*, pas encore enregistrables.
|
|
|
|
Une seule pièce jointe a fait passer le cache de **13 à 174 Ko** : le
|
|
contenu voyage en base64 dans l'instantané, et il est déchiffré en mémoire
|
|
avec le reste. `TVault` lit donc nom, type et taille, et **jette
|
|
`content_b64`** — le garder pour tout le coffre alors que rien ne l'affiche
|
|
serait payer sans rien acheter.
|
|
|
|
Le détail affiche l'existence du document. Ce qui manque est
|
|
l'enregistrement, et ce qui le bloque n'est pas le code mais **où un fichier
|
|
atterrit sur Android 10+** : le répertoire privé ne sert à rien à
|
|
l'utilisateur, *Téléchargements* passe par `MediaStore`, le partage par un
|
|
`Intent`. Le jour venu, le contenu se relira depuis le JSON brut.
|
|
|
|
À surveiller : quelques PDF suffiraient à peser plusieurs Mo, retéléchargés
|
|
entièrement à chaque instantané nouveau.
|
|
2. ~~**Filtre dossier**~~ — **fait**, liste déroulante à côté de la recherche,
|
|
alimentée par le tableau `folders`, et le choix survit à un rafraîchissement
|
|
de fond. **Pas de filtre d'étiquette** : la recherche texte couvre déjà les
|
|
étiquettes, en taper le nom suffit.
|
|
3. ~~**Icônes**~~ (`icon_b64`) — **fait**, décodées une fois par coffre et non à
|
|
chaque frappe (la liste est reconstruite à chaque caractère). Une icône SVG
|
|
n'est pas chargeable par FMX : l'entrée retombe sur ses initiales.
|
|
4. ~~**`template`**~~ — **fait**, affiché comme *Type* dans le détail. Adapter
|
|
en plus les intitulés d'une carte bancaire reste à voir.
|
|
5. ~~**Tri par nom**~~ — **fait** dans `TVault.Search`. L'ordre de l'instantané
|
|
est celui de la base, donc arbitraire à l'œil.
|
|
6. **`TListView` à la place de `TListBox`** le jour où la reconstruction à
|
|
chaque frappe se voit. Marqué `ponytail:` dans le code, pas avant.
|
|
|
|
---
|
|
|
|
## 4. Le chemin d'écriture — le gros morceau
|
|
|
|
C'est ce qui ferait du téléphone un second appareil plutôt qu'une consultation.
|
|
À ne pas commencer avant que le reste soit stable : une erreur ici perd des
|
|
données, alors qu'une erreur en lecture n'affiche qu'une ligne de travers.
|
|
|
|
### La contrainte qui gouverne tout le reste
|
|
|
|
**Le client doit republier ce qu'il n'a pas compris, à l'identique.**
|
|
|
|
`TVault` ne lit qu'un sous-ensemble des champs. S'il resérialise le coffre à
|
|
partir de ce qu'il a lu, un envoi depuis le téléphone efface silencieusement
|
|
`icon_b64`, `template`, `attachments`, `created_at`, l'avatar, les
|
|
tombstones — et le poste Windows adopterait cette version au prochain tirage,
|
|
puisque la fusion est un dernier-écrit-gagne sur `updated_at`.
|
|
|
|
Donc : **garder le `TJSONObject` d'origine** et ne modifier en place que ce que
|
|
l'utilisateur a changé. C'est une contrainte de conception, pas une
|
|
optimisation — elle décide de la forme de `TVault` avant la première ligne
|
|
d'interface d'édition.
|
|
|
|
### Dans l'ordre
|
|
|
|
1. ~~**`updated_at` dans `TVaultEntry`**~~ — **fait**, et déjà affiché comme
|
|
*Modifié le* dans le détail. Rien n'était arbitrable sans lui.
|
|
2. **AES-GCM en chiffrement.** On n'a que le déchiffrement, des deux côtés
|
|
(JNI et CNG). Il faut aussi un sel de 32 octets et un IV de 12 octets tirés
|
|
d'un générateur cryptographique — `SecureRandom` côté Android,
|
|
`BCryptGenRandom` côté Windows. **Jamais** `Random`.
|
|
3. **`PUT` dans [sync.php](../server-php/sync.php)**, qui répond aujourd'hui
|
|
405. Même authentification, `If-Match` obligatoire, **412** si l'ETag ne
|
|
correspond plus, écriture atomique (fichier temporaire puis renommage —
|
|
sans quoi une coupure laisse un conteneur tronqué, donc un coffre perdu),
|
|
et un plafond de taille.
|
|
4. **La fusion**, calquée sur `applyRemoteSnapshot` côté JS : dernier-écrit-
|
|
gagne par `uuid` sur `updated_at`, tombstones qui propagent les
|
|
suppressions, et l'arbitrage de résurrection (une entrée modifiée après sa
|
|
date de suppression survit à la tombstone).
|
|
5. **L'interface d'édition.** Modifier un champ, marquer l'entrée sale, bumper
|
|
`updated_at` en UTC (`toISOString` côté JS, `NowUTC` côté Delphi — mélanger
|
|
heure locale et UTC a déjà décalé l'arbitrage une fois).
|
|
6. **Le conflit 412** — retirer, refusionner, renvoyer, avec un nombre
|
|
d'essais borné. Le poste Windows fait exactement cela, en trois essais.
|
|
|
|
### Ce qu'on peut s'épargner
|
|
|
|
La création et la suppression d'entrées peuvent attendre : modifier un mot de
|
|
passe depuis le téléphone couvre l'essentiel du besoin, et n'exige ni
|
|
tombstones ni génération d'uuid.
|
|
|
|
---
|
|
|
|
## 5. Empaquetage
|
|
|
|
- Icône, nom d'application, numéro de version.
|
|
- Signature avec un magasin de clés dédié, pas la clé de débogage.
|
|
- Revue des permissions : `INTERNET` suffit.
|
|
- Retirer le journal de diagnostic de l'écran de déverrouillage, ou le mettre
|
|
derrière un appui long.
|
|
- Le mot de passe préchargé depuis `PMA.LocalConfig.inc` doit disparaître.
|
|
|
|
---
|
|
|
|
## Ordre proposé
|
|
|
|
1. **Vérifier §1 sur téléphone** — c'est ce qui valide le travail déjà fait, et
|
|
rien d'autre ne peut le remplacer.
|
|
2. §2 item 4 (presse-papiers sensible) puis 6 (verrouillage sur inactivité).
|
|
3. §3 item 1, une fois tranché où atterrissent les pièces jointes.
|
|
4. §4 à partir de l'étape 2, si l'écriture vaut son coût.
|
|
|
|
---
|
|
|
|
## Vérifier le code Android sans le déployer
|
|
|
|
Le compilateur croisé va jusqu'à l'édition de liens, qui échoue seulement
|
|
parce que `ldandroid.exe` appartient au NDK et n'est pas dans le `PATH` :
|
|
|
|
```bash
|
|
"C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dccaarm64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\android64\release" pmserverApk.dpr
|
|
```
|
|
|
|
`F1026 File not found: 'ldandroid.exe'` **sans erreur au-dessus** signifie que
|
|
tout a compilé. C'est ce qui permet de valider des déclarations JNI sans
|
|
attendre un APK — le pont construit ses signatures à partir des types déclarés,
|
|
et une signature fausse ne se voyait jusqu'ici qu'à l'exécution. Cela ne
|
|
prouve pas qu'une signature *correspond* à une méthode Java réelle, seulement
|
|
qu'elle est cohérente : le déploiement reste le juge final.
|