09048410e1
The launch-time auto-open received boUnavailable and wiped the cache, seconds after the write path had proved the device could authenticate. The diagnosis was wrong and the reaction irreversible. An automatic action now never clears the vault: it logs the cause and leaves the user on the unlock screen. Only an explicit tap on Open, followed by a clean refusal from the system, may clear it. PMA.Biometric keeps the reason in LastBiometricError, and a Java-layer exception now maps to boFailed rather than boUnavailable - an exception says nothing about the device. The log memo also wraps: it was cutting off exactly the end of the message, which is where the cause is named. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
12 KiB
Markdown
241 lines
12 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 (constatée le 2026-08-15)
|
|
|
|
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.
|
|
|
|
## 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** — 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.** Aujourd'hui seul le
|
|
passage en arrière-plan verrouille ; le poste Windows a en plus une minuterie.
|
|
|
|
---
|
|
|
|
## 3. Compléter la lecture — moyen
|
|
|
|
Rangé par ce que ça apporte réellement sur un téléphone.
|
|
|
|
1. **Pièces jointes** — elles sont dans l'instantané, personne ne les lit.
|
|
Lister nom/taille par entrée, et enregistrer. **Décision à prendre avant
|
|
d'écrire quoi que ce soit** : où enregistrer sur Android 10+. Le répertoire
|
|
privé de l'application ne sert à rien à l'utilisateur, et *Téléchargements*
|
|
passe par `MediaStore` ou un `Intent` de partage. C'est cette question, pas
|
|
le code, qui bloque. Attention aussi à la mémoire : `content_b64` est déjà
|
|
décodé en RAM avec le reste de l'instantané, un coffre avec des PDF pèse.
|
|
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.
|