1073 lines
54 KiB
Markdown
1073 lines
54 KiB
Markdown
# Client Android — ce qu'il reste à faire
|
||
|
||
État au 2026-08-17. 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 | oui — ligne *Type* |
|
||
| `icon_b64` | oui | oui — pastille de la liste |
|
||
| `icon_custom` | oui | non — la provenance ne change rien à l'affichage |
|
||
| `attachments` — nom, type, taille | oui | oui — nommées, pas ouvrables |
|
||
| `attachments` — `content_b64` | oui | **non** — c'est l'enregistrement qui manque |
|
||
| `updated_at` | oui | oui |
|
||
| `created_at` | oui | **non** |
|
||
| `folders` (liste, couleur, icône) | oui | oui — le nom seulement |
|
||
| `avatar_b64`, `username` (compte) | oui | **non** |
|
||
| `tombstones` | oui | **non** — n'aura de sens qu'avec l'écriture |
|
||
| `favorite`, `pinned` | **non** — device-local par conception | — |
|
||
| corbeille | **non** — voyage dans les sauvegardes, pas la sync | — |
|
||
|
||
Ce qui manque encore ne demande donc **rien** au poste Windows : c'est de la
|
||
lecture côté téléphone. 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~~ — DEVENU INUTILE le 2026-08-17
|
||
|
||
Un nom de pièce jointe en arabe s'affichait en désordre : 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. `GlobalUseSkia`
|
||
réglait cela, au prix de `libsk4d.so` (25 Mo) et d'un **APK passé de 13 à 22 Mo**.
|
||
|
||
**Retiré**, avec le style vendu, quand l'interface est passée au moteur du
|
||
système (§3 bis) : Skia ne servait qu'aux libellés FMX, et il n'en reste aucun.
|
||
Le moteur fait sa propre mise en forme — vérifié sur l'appareil, le même nom
|
||
arabe se lit correctement. L'APK **retombe à 12,7 Mo**.
|
||
|
||
Ce qui reste vrai de cet épisode, et qui gouverne encore la fabrication :
|
||
`Deploy` reconstruit `library\lib` avant `aapt`, donc tout ce qu'on y pose à la
|
||
main disparaît — aujourd'hui nos icônes, hier Skia. 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.
|
||
[build-apk.ps1](build-apk.ps1) enchaîne les quatre étapes, y compris la
|
||
régénération de `web.res` — l'oublier embarque la page d'avant l'édition, et le
|
||
symptôme se lit alors comme un bug applicatif.
|
||
|
||
## 1 quater. ~~La comparaison de sha ne se declenche pas~~ — RESOLU le 2026-08-17
|
||
|
||
Mesure sur appareil, deux ouvertures successives du cache :
|
||
|
||
```
|
||
Vault opened from cache in 139 ms
|
||
displayed in 14 ms <- l''ouverture ne coute rien
|
||
known sha: 03960ff908fb0d09a900ab69f8738fd581aef98f
|
||
remote sha: 03960ff908fb0d09a900ab69f8738fd581aef98f
|
||
decrypted in 5492 ms <- alors qu''elles sont identiques
|
||
```
|
||
|
||
Les deux empreintes s''affichent **egales** et `PMA.SyncClient` les compare
|
||
**differentes** : la branche `ssNotModified` n''est pas prise, et l''avertissement
|
||
place juste apres, qui ne s''affiche que si `LRes.ETag = FETag`, ne sort pas non
|
||
plus. Seul un caractere invisible explique cela, et une relecture du chemin
|
||
complet ne l''a pas trouve.
|
||
|
||
**Ce qui est ecarte** : le transport Gitea est bien choisi (le journal ecrit
|
||
« known sha » et non « If-None-Match », ligne conditionnee par `IsGitea`) ;
|
||
`SaveVault` enregistre l''empreinte qu''on lui passe ; `vault.bin` est bien
|
||
reecrit (191 146 octets, horodatage a jour) ; et le dechiffrement reussit, donc
|
||
le conteneur extrait est le bon.
|
||
|
||
**Contourne, pas corrige** : la comparaison passe par `SameText` sur des valeurs
|
||
elaguees - correct en soi sur un hexadecimal, ou deux empreintes distinctes ne
|
||
peuvent pas differer par la seule casse ou un blanc - et le journal affiche
|
||
desormais les deux **longueurs**. Le prochain rafraichissement reel dira ce
|
||
qu''etait le caractere de trop.
|
||
|
||
**Rendu peu visible** par `REFRESH_MIN_MINUTES` : l''ouverture n''appelle plus le
|
||
serveur qu''au plus une fois par quart d''heure. Le cout ne se paie donc plus a
|
||
chaque ouverture, mais il se paiera encore une fois par quart d''heure tant que
|
||
la comparaison reste inerte.
|
||
|
||
---
|
||
## 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"`**~~ — **fait le 2026-08-17**, mais
|
||
pas en le retirant : remplacé par un
|
||
[network_security_config.xml](android-res/xml/network_security_config.xml) qui
|
||
n''autorise le clair que vers `127.0.0.1` et `localhost`, **nommément**, et
|
||
l''interdit partout ailleurs. L''attribut ne faisait pas cette distinction :
|
||
une URL mal saisie envoyait le jeton en clair sur le réseau sans que rien ne
|
||
le signale. Maintenant elle échoue tout de suite, ce qui est le comportement
|
||
voulu — bruyant, pas silencieux. L''essai local par `adb reverse` continue de
|
||
passer.
|
||
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`.
|
||
|
||
**Suite, 2026-08-21 — vider n'est pas copier.** Une vignette blanche
|
||
« copié » apparaissait longtemps après le geste, une fois sur deux. Elle
|
||
venait de nous : l'effacement automatique à 45 s écrivait une chaîne vide
|
||
via `setPrimaryClip`, ce que le système lit comme une **copie** et annonce
|
||
comme telle. Le commentaire du code affirmait que « le résultat visible est
|
||
le même » ; il ne l'est pas. `clearPrimaryClip` depuis l'API 28, avec le
|
||
repli au-dessous — repli inoffensif par construction, la vignette n'existant
|
||
qu'à partir d'Android 12.
|
||
|
||
Une fois sur deux seulement, parce que le minuteur ne vide que si le
|
||
presse-papiers contient **toujours** notre texte : copier autre chose
|
||
entre-temps annule l'effacement, donc la vignette.
|
||
|
||
Ce qui reste hors de portée : le bouton *Envoyer à l'appareil* de cette
|
||
vignette appartient à Android. Le drapeau masque le contenu, il ne retire
|
||
pas l'action.
|
||
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**, une rangée de pastilles avec leur compte,
|
||
alimentée par le tableau `folders`. Le filtrage se fait dans la page, donc
|
||
sans aller-retour, 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**. Elles descendent telles quelles dans
|
||
la page, en URI `data:` : le moteur décode, y compris le SVG, que FMX ne
|
||
savait pas charger. Sans icône, l'entrée retombe sur son initiale.
|
||
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. **Liste virtuelle** le jour où la reconstruction à chaque frappe se voit. Le
|
||
`innerHTML` complet est refait à chaque caractère ; sur quelques centaines
|
||
d'entrées c'est imperceptible. Marqué `ponytail:` dans la page, pas avant.
|
||
|
||
---
|
||
|
||
## 3 bis. ~~Faudrait-il des composants Delphi du tout ?~~ — TRANCHÉ le 2026-08-17
|
||
|
||
**Non. Il n'en reste aucun.** Les trois écrans sont une page HTML rendue par le
|
||
moteur du système ([web/app.html](web/app.html), servie par
|
||
[PMA.WebUI.pas](PMA.WebUI.pas)). Une sonde a décidé plutôt qu'un débat : la même
|
||
liste peinte par le moteur, en quarante lignes de CSS, rendait mieux que ce qui
|
||
avait coûté la journée à peindre à la main — et mettait l'arabe en forme seule.
|
||
|
||
Ce que la migration a réglé, et qui revenait à chaque écran : un gabarit absent
|
||
du style chargé, un ordre d'alignement laissé au hasard, un `HitTest` qui avale
|
||
un appui, une vue native qui se peint par-dessus tout. Le dernier à partir a été
|
||
l'écran de déverrouillage, pour une raison précise : dans un `TEdit` Android le
|
||
curseur ne se place pas où on le touche, donc un jeton long est incorrigible.
|
||
|
||
**Le pont n'ouvre aucun port.** `evaluateJavascript` descend les données,
|
||
une navigation `cmd://` interceptée remonte les actions. Deux pièges y sont
|
||
enterrés, à ne pas re-découvrir :
|
||
|
||
- FMX répond **toujours** au moteur qu'il n'a pas pris la navigation en charge.
|
||
Sur un schéma inconnu, le moteur essaie donc de la charger et remplace la page
|
||
par son écran d'erreur. D'où l'**iframe jetable**, où l'échec reste confiné.
|
||
- Le chargement de la page ne transporte pas l'encodage
|
||
(`loadDataWithBaseURL` reçoit `nil`), et `Navigate` refuse une URL `data:`.
|
||
**`app.html` doit donc rester en ASCII pur** ; les données, elles, passent par
|
||
une chaîne JNI et n'ont pas ce problème.
|
||
|
||
**Les secrets ne traversent pas le pont.** La liste reçoit noms, sites et
|
||
identifiants — ce qui est déjà à l'écran. Copier depuis une ligne envoie un
|
||
**rang**, et Delphi relit le coffre chez lui. Seule l'entrée ouverte a ses
|
||
valeurs dans le tas JS, effacées par `PM.wipe` au verrouillage.
|
||
|
||
Reste en Delphi, et y restera : Keystore, biométrie, presse-papiers sensible,
|
||
`FLAG_SECURE`, réseau, déchiffrement.
|
||
|
||
**Ce que `js/` n'a pas donné.** Le CSS du poste est réutilisable presque tel
|
||
quel et c'est lui qui porte l'apparence ; `app.js` non — 12 000 lignes qui
|
||
supposent la session, le CSRF et l'API complète. La page du téléphone est
|
||
autonome.
|
||
|
||
**Le thème n'est pas encore exactement celui de la recherche globale.** Le
|
||
dégradé, les blocs translucides et les arrondis sont repris ; espacements,
|
||
élévation et densité restent au jugé. C'est maintenant du CSS, donc peu coûteux
|
||
à reprendre capture en main.
|
||
|
||
## 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**~~ — **fait le 2026-08-17**, et vérifié par une
|
||
implémentation tierce. `EncryptContainer` produit le conteneur exact du
|
||
poste ; sel de 32 octets et IV de 12 tirés de `SecureRandom` (Android) ou
|
||
`BCryptGenRandom` (Windows), jamais `Random`.
|
||
|
||
**La vérification compte autant que le code.** Un aller-retour avec
|
||
soi-même ne prouve que la cohérence interne : il passait alors que le
|
||
conteneur portait deux défauts. C'est Node — mêmes primitives que WebCrypto
|
||
— qui les a montrés en relisant un conteneur produit par le téléphone :
|
||
|
||
- le base64 de Delphi est **coupé à 76 caractères**, celui de WebCrypto ne
|
||
l'est jamais. Les décodeurs tolérants l'acceptent, mais dépendre de leur
|
||
tolérance n'est pas une garantie ; `B64NoWrap` produit désormais la même
|
||
forme.
|
||
- le littéral accentué du contrôle sortait **déjà abîmé du compilateur** —
|
||
ce fichier n'a pas de marque d'ordre d'octets, et Delphi le lit en ANSI.
|
||
Le défaut était dans le test, pas dans le chiffrement, mais seul le
|
||
contrôle croisé pouvait le distinguer.
|
||
|
||
Le contrôle tourne au lancement en `DEBUG` et dépose `selfcheck.json` dans
|
||
le répertoire privé, pour être relu depuis le PC :
|
||
`adb shell run-as … cat …/files/selfcheck.json`.
|
||
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**~~ — **faite**, éditeur en ligne sur la ligne du
|
||
mot de passe. `updated_at` est bumpé en UTC par `PMA.VaultPatch`.
|
||
6. ~~**Le conflit 412**~~ — **écrit**, sous forme de **409** (le code de Gitea),
|
||
trois essais, en rejouant tirer→patcher→publier. **Jamais éprouvé** : il
|
||
faudrait publier depuis le poste pendant que le téléphone enregistre.
|
||
|
||
### Vérifié sur appareil — 2026-08-17
|
||
|
||
```
|
||
save: attempt 1 - GET
|
||
save: decrypt+patch in 4519 ms
|
||
save: re-encrypt in 23 ms
|
||
save: published, new sha 802515d4...
|
||
```
|
||
|
||
Les 23 ms mesurent l'économie de la clé réutilisée : la seconde dérivation
|
||
valait les mêmes 4,5 secondes que la première.
|
||
|
||
**Ce qui reste ouvert**, par ordre d'importance :
|
||
|
||
- le **409** n'a jamais tourné ;
|
||
- **pas d'édition hors ligne** : le téléphone patche l'instantané qu'il vient
|
||
de tirer, donc sans réseau *Save* échoue. C'est le prix de ne pas avoir de
|
||
fusion à écrire. Une file de modifications en attente la ramènerait, avec
|
||
l'arbitrage qui va avec ;
|
||
- **un seul champ** est modifiable. `PatchEntryField` prend déjà le nom du
|
||
champ, donc l'identifiant ou le site coûteraient une ligne ; créer ou
|
||
supprimer une entrée, en revanche, exige uuid et tombstones.
|
||
|
||
### L'interface, remise d'aplomb — 2026-08-21
|
||
|
||
Quatre défauts trouvés en s'en servant, tous nés du même endroit : le rail
|
||
pose les vues au repos hors du cadre, à `translateX(100%)`.
|
||
|
||
- **Le panneau glissait de côté.** `overflow:hidden` n'interdit que le geste ;
|
||
le moteur garde le droit de déplacer une boîte pour amener à l'écran le champ
|
||
qui vient de prendre le focus, et les vues hors cadre lui en laissent la
|
||
place. Toute boîte qui glisse revient maintenant à zéro, attrapée en phase de
|
||
capture — sauf la bande de dossiers, seule à défiler latéralement de plein
|
||
droit. Premier essai raté : j'avais clampé les panneaux défilants, la CSS
|
||
était bien dans la bibliothèque livrée et le comportement n'avait pas bougé.
|
||
- **Le clavier changeait de forme et ne se refermait plus.** La touche `→|`
|
||
demande « champ suivant », et le suivant dans le document est sur une vue au
|
||
repos. Aucun champ d'une vue au repos ne garde le focus désormais, et la
|
||
validation replie le clavier au lieu de sauter ; dans l'éditeur, elle
|
||
enregistre.
|
||
- **Le bouton « replier le clavier » ne faisait rien**, et c'était chez nous :
|
||
la vue web n'étant pas un champ que le système connaît, il n'intercepte pas
|
||
la touche retour pour nous, et `OnHardwareBack` la consommait toujours — le
|
||
coffre se verrouillait pendant que le clavier restait déplié.
|
||
`hideSoftInputFromWindow` dit s'il y avait quelque chose à replier : c'est le
|
||
signal synchrone qui permet de ne consommer que dans ce cas.
|
||
- **La rangée d'édition dépassait le cadre** : le champ gardait `width:100%` et
|
||
trois boutons s'ajoutaient à côté.
|
||
|
||
Mesuré sur l'appareil par `dumpsys input_method` avant et après, pas déduit :
|
||
`mInputShown` restait à `true`, il passe à `false` et l'application reste au
|
||
premier plan.
|
||
|
||
**L'essai du chemin d'écriture est retiré** — bouton, procédure, branche du
|
||
pont et le crochet de débogage qui le révélait. Il prouvait ce chemin avant le
|
||
premier vrai PUT ; ce chemin porte le coffre maintenant.
|
||
|
||
### 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
|
||
|
||
### Fait — 2026-08-21
|
||
|
||
- **Icône, nom, version** : « Coffre », `versionCode=1`, `versionName=1.0.0`.
|
||
- **Permissions : dix devenues trois.** Le manifeste demandait localisation
|
||
fine et grossière, caméra, téléphone, état du téléphone et stockage externe —
|
||
les défauts de Delphi, aucun utilisé. Sur un coffre à mots de passe ce sont
|
||
exactement les mauvais signaux : celui qui lit la fiche y voyait une demande
|
||
de position et d'appareil photo sans explication possible. La liste est
|
||
désormais **écrite à la main** dans le modèle : `INTERNET`, `USE_BIOMETRIC`,
|
||
`USE_FINGERPRINT`.
|
||
*Piège rencontré* : nommer le marqueur de substitution dans le commentaire qui
|
||
explique son retrait suffit à le faire revenir — la substitution est
|
||
textuelle et ne regarde pas si elle tombe dans un commentaire.
|
||
- **Les secrets de banc d'essai ne sont plus compilés en release.**
|
||
`PMA.LocalConfig.inc` n'est inclus qu'en débogage ; en release les deux
|
||
constantes valent la chaîne vide. Vérifié dans le binaire, en UTF-8 **et en
|
||
UTF-16** — Delphi range ses chaînes en UTF-16, et un grep ASCII rendait zéro
|
||
des deux côtés, donc ne prouvait rien :
|
||
|
||
```
|
||
debug jeton=1 motdepasse=1
|
||
release jeton=0 motdepasse=0
|
||
```
|
||
|
||
**Le fichier reste en place, et c'est décidé** (2026-08-21) : il n'entre plus
|
||
dans une release, il est ignoré par git, et le jeton qu'il contient n'ouvre
|
||
qu'un dépôt d'essai. Le garder évite de retaper quarante caractères à chaque
|
||
réinstallation du banc. Ce qui vaudrait révocation, c'est un jeton donnant
|
||
accès à autre chose que ce dépôt — pas celui-ci.
|
||
|
||
- **Le journal quitte l'écran d'accueil en release**, sans quitter
|
||
l'application : un appui long de 1,5 s sur la ligne d'état le ramène. Il ne
|
||
contient rien de secret — le jeton n'y figure que par empreinte — mais un
|
||
bouton « Log » permanent est du bruit de développement, et les diagnostics
|
||
doivent rester atteignables le jour où quelque chose cloche à distance.
|
||
- **La configuration Release compilait pas du tout**, et personne ne le savait :
|
||
les déclarations des deux auto-contrôles étaient sous `{$IFDEF DEBUG}`, leurs
|
||
**implémentations** non. La classe ne les déclarant plus, leurs corps
|
||
devenaient des membres inconnus. Invisible tant qu'on ne construit qu'en
|
||
débogage — ce qui a été le cas jusqu'ici.
|
||
|
||
### Reste : la signature
|
||
|
||
Un magasin de clés dédié, jamais la clé de débogage — celle-ci est partagée par
|
||
toutes les installations de Delphi, donc n'authentifie rien. Le mot de passe du
|
||
magasin appartient à l'utilisateur ; il n'a sa place ni ici ni dans le dépôt.
|
||
|
||
```
|
||
keytool -genkeypair -v -keystore pmserver-release.jks -alias pmserver -keyalg RSA -keysize 4096 -validity 10000
|
||
```
|
||
|
||
Puis, dans Delphi : *Project > Options > Provisioning*, configuration **Release**,
|
||
pointer le fichier `.jks` et l'alias. Le magasin se range **hors du dépôt** et
|
||
se sauvegarde ailleurs : le perdre interdit toute mise à jour de l'application
|
||
déjà installée, Android refusant une signature différente.
|
||
|
||
Une release se construit ensuite comme le débogage, en changeant la
|
||
configuration ; `android-client/build-apk.ps1` ne connaît que Debug pour
|
||
l'instant, et ne saura signer que le jour où le magasin existe.
|
||
|
||
---
|
||
|
||
## 6. Remplacer l'endpoint par un dépôt Gitea
|
||
|
||
Piste sérieuse pour le §2 item 2 : au lieu d'héberger `sync.php` en HTTPS avec
|
||
un certificat, déposer l'instantané chiffré dans un dépôt **privé** du Gitea
|
||
déjà en service. Plus rien à héberger, plus de certificat à renouveler, et
|
||
l'accès depuis l'extérieur vient gratuitement.
|
||
|
||
**La mécanique tombe terme à terme sur ce qui est déjà écrit.** L'API rend le
|
||
fichier avec son `sha` et exige ce même `sha` pour écrire ; un `sha` périmé
|
||
donne un **409**. C'est exactement le rôle de l'`ETag` et du **412** : la
|
||
boucle relire-refusionner-renvoyer du poste Windows, bornée à trois essais,
|
||
fonctionne sans changer de forme.
|
||
|
||
### Lire
|
||
|
||
```
|
||
GET /api/v1/repos/{owner}/{repo}/contents/vault-sync.json?ref=main
|
||
Authorization: token <PAT>
|
||
```
|
||
|
||
Réponse : `{ "content": "<base64>", "encoding": "base64", "sha": "<blob sha>" }`.
|
||
Le `content` est le conteneur `pm-encrypted-export-v1`, déjà chiffré — Gitea ne
|
||
voit que des octets. Le `sha` prend la place de l'ETag, et se range avec le
|
||
coffre comme lui, puisqu'il décrit le clair détenu.
|
||
|
||
**404** = pas encore d'instantané, à traiter comme le 404 actuel : premier
|
||
envoi, pas une erreur.
|
||
|
||
### Écrire
|
||
|
||
```
|
||
PUT /api/v1/repos/{owner}/{repo}/contents/vault-sync.json
|
||
Authorization: token <PAT>
|
||
{ "message": "sync", "content": "<base64>", "sha": "<sha lu>",
|
||
"branch": "main" }
|
||
```
|
||
|
||
Sans `sha`, Gitea refuse d'écraser un fichier existant ; avec un `sha` périmé
|
||
il répond **409**. C'est le `If-Match` qu'on avait dû implémenter à la main.
|
||
|
||
### Le jeton
|
||
|
||
**Gitea ne sait pas limiter un jeton à un dépôt** — vérifié sur 1.26.1. Ses
|
||
portées sont par *catégorie* et valent pour tout le compte : `read:repository`
|
||
donne la lecture de **tous** les dépôts que le compte peut lire. Rien à voir avec
|
||
les jetons fins de GitHub. (Ce paragraphe disait le contraire ; c'était faux.)
|
||
|
||
Ce qu'on peut faire, par ordre de coût :
|
||
|
||
- **`read:repository` seul**, tant que le téléphone ne fait que lire. C'est le
|
||
choix actuel : un jeton qui ne peut rien écrire coûte peu s'il fuite.
|
||
- L'écriture ne se justifiera qu'au §4, et pour publier depuis le poste c'est
|
||
**un autre jeton, sur une autre machine**.
|
||
- Vraie isolation, si elle devient nécessaire : un **compte Gitea dédié**,
|
||
collaborateur du seul dépôt de sync.
|
||
|
||
Il remplace le jeton Bearer actuel et se range au même endroit — `settings.bin`,
|
||
scellé par la clé AES du Keystore, lisible sans authentification. Le
|
||
raisonnement ne change pas : il n'ouvre que du chiffré.
|
||
|
||
### Ce que ça coûte, et pourquoi pas GitHub
|
||
|
||
**L'historique de Git ne s'efface pas.** Chaque synchronisation est un commit,
|
||
et changer de mot de passe de sync n'enlève rien : toutes les versions
|
||
antérieures restent, chacune chiffrée sous le mot de passe de son époque. Un
|
||
ancien mot de passe faible resterait attaquable indéfiniment. Sur un serveur
|
||
qui écrase un fichier, ce problème n'existe pas.
|
||
|
||
C'est vivable sur **son propre** Gitea — le disque est à soi, l'historique se
|
||
purge. Ça ne l'est pas sur GitHub, où le chiffré part chez un tiers, répliqué
|
||
et sauvegardé, avec un dépôt rendu public par erreur pour scénario du pire :
|
||
attaquable hors ligne, par n'importe qui, pour toujours.
|
||
|
||
### État : la lecture fonctionne contre un vrai dépôt — 2026-08-17
|
||
|
||
[PMA.SyncClient.pas](PMA.SyncClient.pas) parle les deux transports, **reconnus à
|
||
l'URL** : `/api/v1/repos/` → Gitea, sinon l'endpoint PHP. Le PHP est gardé le
|
||
temps que le poste Windows publie aussi dans le dépôt ; sans lui la migration
|
||
exigerait de tout basculer d'un coup, des deux côtés, sans retour possible.
|
||
|
||
Ce qui change côté Gitea, et rien d'autre : l'en-tête est `token <PAT>` et non
|
||
`Bearer` (l'un à la place de l'autre donne un 401 muet), le corps est du JSON
|
||
dont il faut extraire `content` en base64, et **la comparaison d'empreinte se
|
||
fait dans le client** — l'API ne connaît pas `If-None-Match`, donc le corps est
|
||
retéléchargé, mais la dérivation de clé, elle, est bien épargnée. Le champ
|
||
`ETag` du résultat porte le `sha` : l'appelant n'a pas à savoir lequel des deux
|
||
transports lui répond.
|
||
|
||
**Vérifié sur téléphone** contre `Zaki/pm_sync` (Gitea 1.26.1), conteneur déposé
|
||
par l''interface web :
|
||
|
||
```
|
||
GET .../api/v1/repos/Zaki/pm_sync/contents/vault-sync.json
|
||
HTTP 200 in 1079 ms
|
||
304: unchanged, vault served from cache
|
||
4 entries, 2 in trash
|
||
```
|
||
|
||
Le premier appel télécharge, déchiffre et écrit le cache avec le `sha` ; le
|
||
second retrouve le même `sha` et épargne la dérivation. Le conteneur d''export
|
||
porte la corbeille, d''où les 2 en corbeille — un instantané de sync n''en aurait
|
||
aucune.
|
||
|
||
Deux frottements rencontrés en chemin, tous deux corrigés :
|
||
|
||
- les coordonnées n''étaient enregistrées qu''après un **déchiffrement** réussi,
|
||
donc un mot de passe faux faisait perdre l''URL et le jeton à chaque essai. Un
|
||
`200` prouve l''adresse et le jeton ; c''est là qu''elles sont retenues.
|
||
- le rafraîchissement au lancement est **silencieux** et utilise le mot de passe
|
||
rangé avec le coffre, pas le champ. Changer de conteneur demande donc un
|
||
*Sync* explicite au moins une fois.
|
||
|
||
### Le poste publie aussi — vérifié le 2026-08-17
|
||
|
||
Les deux bouts parlent Gitea, et le trajet complet a tourné : le poste pousse
|
||
(`sync from test`, 2ᵉ commit dans `pm_sync`), le téléphone lit l''instantané
|
||
publié. La preuve tenait dans une entrée : *gitea token read*, créée sur le
|
||
poste, est apparue sur le téléphone sans jamais passer par `sync.php` ni WebDAV.
|
||
|
||
Côté poste, tout est confiné à deux endroits : le pont ([UMainForm.pas](../delphi-backend/UMainForm.pas))
|
||
change l''en-tête et le type de contenu, et [app.sync.js](../js/app.sync.js) lit
|
||
et construit l''enveloppe JSON. Le `sha` remplace l''ETag partout, et le **409**
|
||
de Gitea est traité comme le **412** de WebDAV — on rejoue pull→fusion→push.
|
||
|
||
Trois détails qui coûtent une soirée si on les redécouvre :
|
||
|
||
- `sha` **omis** et non vide au premier envoi : Gitea lit sa présence comme
|
||
« remplace cette version précise ».
|
||
- `branch` omis aussi, pour prendre la branche par défaut du dépôt plutôt qu''un
|
||
nom devine.
|
||
- le jeton du poste doit être en **`write:repository`** ; celui du téléphone
|
||
reste en lecture.
|
||
|
||
**Piège rencontré, sans rapport avec la sync** : `applySettingsTab` associe les
|
||
sections de réglages aux onglets **par le texte de leur intitulé**. Renommer
|
||
« Sync (WebDAV) » a fait retomber la section sur `general` — pas perdue, mais
|
||
sous le mauvais onglet, ce qui est pire qu''absente.
|
||
|
||
**Reste à faire** : le PUT depuis le téléphone (§4 seulement — la lecture
|
||
n''écrit rien), retirer WebDAV le jour où il n''a plus d''usage, et une purge
|
||
périodique de l''historique — sans quoi le dépôt grossit
|
||
d'un instantané complet à chaque synchronisation, pièces jointes comprises.
|
||
|
||
## 7. Se servir de l'application seule — compte local
|
||
|
||
Aujourd'hui le téléphone ne sait que **lire un coffre venu d'ailleurs** : sans
|
||
URL, sans jeton et sans mot de passe de synchronisation, il n'affiche rien.
|
||
On veut qu'il serve seul, comme le poste : on s'inscrit, le coffre vit là, et
|
||
la synchronisation devient un ajout facultatif.
|
||
|
||
### Ce que ça entraîne, et qu'il vaut mieux dire tout de suite
|
||
|
||
Un coffre vide qu'on ne peut pas remplir ne sert à rien. **S'inscrire entraîne
|
||
donc créer des entrées depuis le téléphone** — précisément ce que §4 avait
|
||
écarté. C'est le vrai morceau ; l'écran de connexion n'en est que la façade.
|
||
|
||
### Le coffre local devient l'autorité, chiffré sous le mot de passe
|
||
|
||
Aujourd'hui `vault.bin` porte l'instantané **en clair**, scellé par le Keystore
|
||
(et, depuis peu, par un code). Le conteneur chiffré sous mot de passe, lui,
|
||
n'existe qu'au bout du réseau. Un compte local inverse la charge :
|
||
|
||
- un fichier local porte **exactement ce que porterait le dépôt** —
|
||
`EncryptContainer(instantané, mot de passe, 600k)` ;
|
||
- `vault.bin` reste ce qu'il est : le cache d'ouverture rapide.
|
||
|
||
Deux raisons, pas une : c'est le modèle du poste — le mot de passe maître
|
||
protège le coffre au repos, pas seulement le matériel — et **le format reste
|
||
celui du dépôt**, donc la synchronisation continue de marcher sans convertir
|
||
quoi que ce soit.
|
||
|
||
### Le nom d'utilisateur
|
||
|
||
Le conteneur le porte déjà (`username`, écrit par `buildSyncSnapshot`). Un seul
|
||
compte par appareil : plusieurs coffres sur un même téléphone est une envie
|
||
supposée, pas un besoin constaté. Le nom saisi se compare à celui du conteneur
|
||
après déchiffrement — c'est le mot de passe qui ouvre, le nom qui identifie.
|
||
|
||
### S'inscrire
|
||
|
||
Forge un instantané vide, le chiffre sous le mot de passe choisi, l'écrit
|
||
localement. Aucun réseau. **Le poste ne connaîtra pas ce compte** : il crée les
|
||
siens dans SQLite et rien ne les rapproche aujourd'hui. Un coffre né sur le
|
||
téléphone qu'on voudra ensuite ouvrir sur le poste demandera un import.
|
||
|
||
### Par étapes
|
||
|
||
1. **Identité** : écran connexion / inscription, conteneur local, coffre vide.
|
||
2. **Créer une entrée** — l'étape qui rend l'application utile seule. Un uuid
|
||
neuf, `updated_at` posé ; pas de tombstone, on ne supprime pas encore.
|
||
3. **Modifier n'importe quel champ** — fait le 2026-08-21. `Name`, `Site`,
|
||
`Username`, `Mot de passe` (le corps pour une note) sont éditables. Le nom du
|
||
champ voyage dans la ligne du détail (`TDetailRow.Field`) plutôt que d'être
|
||
déduit du libellé côté page : un intitulé est de la prose, et le jour où il
|
||
change, la page enverrait un nom de champ inexistant. Delphi ne le croit pas
|
||
sur parole pour autant — quatre noms admis, le reste refusé, parce que
|
||
l'instantané porte aussi des tombstones et des pièces jointes que
|
||
`PatchEntryField` écraserait sans rien y comprendre.
|
||
Une ligne vide s'affiche désormais **si elle est éditable** : un identifiant
|
||
absent qu'on ne pourrait pas ajouter serait une impasse. Vider est permis,
|
||
sauf sur un mot de passe où ce serait presque toujours une fausse manoeuvre.
|
||
**Dossier et secret 2FA** — ajoutés le 2026-08-21. Le dossier n'avait pas de
|
||
ligne à lui : il partageait « Folder / tags » avec les étiquettes, donc rien
|
||
n'était modifiable. Il se **choisit** dans la liste des dossiers connus
|
||
plutôt que de se taper — une faute de frappe rangerait l'entrée dans un
|
||
dossier que rien n'affiche, ni ici ni sur le poste, et elle semblerait
|
||
disparue. Les étiquettes restent en lecture seule : c'est un **tableau**
|
||
dans l'instantané, et l'écrire comme une chaîne casserait sa forme pour le
|
||
poste.
|
||
|
||
Le **secret** 2FA a désormais sa ligne, masquée comme un mot de passe. Le
|
||
*code* avait sa case depuis longtemps, mais il est calculé et renouvelé
|
||
toutes les trente secondes ; c'est le secret qui se pose, se change et se
|
||
retire, et rien ne permettait de le faire depuis le téléphone.
|
||
|
||
**Icônes plutôt que mots** sur copier-utilisateur, copier-mot-de-passe,
|
||
copier, montrer et modifier — quatre mots par ligne laissaient peu de place
|
||
au nom de l'entrée. Sans pastille : le dessin seul, cible maintenue à 44 px.
|
||
Chaque icône porte son `aria-label`.
|
||
|
||
*Piège CSS, de la même famille* : ne jamais donner à un **état du `body`** le
|
||
nom d'une classe que porte déjà un élément. Le voile d'attente s'appelait
|
||
`busy` et l'état aussi — donc `.busy { display: none }` s'appliquait au
|
||
`body`, et la page entière s'éteignait dès qu'une opération commençait. Les
|
||
états portent des noms que rien d'autre ne porte (`is-busy`, `typing`,
|
||
`selecting`, `on-list`, `on-detail`).
|
||
|
||
*Piège, à retenir avant d'ajouter le prochain bouton-icône* : la cible de
|
||
l'appui devient le `<svg>` ou l'un de ses traits, jamais le bouton. Lire
|
||
`ev.target.getAttribute('data-…')` rend alors `null` et le gestionnaire
|
||
retombe sur le cas par défaut — ici, ouvrir l'entrée au lieu de copier. Le
|
||
doigt tombant tantôt sur le glyphe tantôt sur la marge, cela ne ratait
|
||
qu'une fois sur deux, ce qui est la pire des pannes à diagnostiquer.
|
||
**Remonter au bouton (`closest`) avant de lire quoi que ce soit.**
|
||
|
||
**Supprimer** — fait le 2026-08-21. `DeleteEntry` retire la ligne **et** pose
|
||
la pierre tombale `{uuid, deleted_at}` : les deux, sinon rien. Retirer la
|
||
ligne seule ne supprime pas — le poste tient toujours la sienne, et le
|
||
prochain tirage la ramènerait comme une entrée qu'on n'a jamais eue. Et
|
||
l'heure compte : l'arbitrage du poste garde une entrée modifiée *après* la
|
||
suppression plutôt que de la tuer une seconde fois.
|
||
|
||
**Sans retour depuis le téléphone**, et c'est délibéré : la corbeille du
|
||
poste est un drapeau `deleted` qui ne voyage pas dans la synchronisation, donc
|
||
une suppression qui s'y arrêterait ne se propagerait nulle part.
|
||
|
||
**Et elle ne va pas non plus dans la corbeille du poste** — vérifié dans le
|
||
code serveur : recevoir une pierre tombale déclenche
|
||
`DELETE FROM vault_entries`, un effacement dur. Supprimer depuis le téléphone
|
||
retire donc la ligne des deux côtés, en contournant la corbeille. **Le seul
|
||
filet est la sauvegarde du poste.** Il n'y a rien à afficher comme corbeille
|
||
ici : le téléphone n'en reçoit jamais le contenu.
|
||
|
||
**La corbeille, c'est l'historique du dépôt.** Chaque publication est un
|
||
commit Gitea — l'API des contenus en crée un à chaque écriture, avec le
|
||
message que le téléphone envoie (`entries deleted from phone`…). L'instantané
|
||
d'avant la suppression est donc toujours là, consultable dans l'interface web
|
||
du dépôt et récupérable par un import. Ce n'est pas un écran, mais c'est un
|
||
vrai filet — et il rattrape aussi ce qu'une corbeille ne rattraperait pas :
|
||
une modification malheureuse, un import raté.
|
||
|
||
L'action vit dans le **menu de l'entrée** (les trois points, en tête de
|
||
détail), et non plus en bouton pleine largeur au bas de chaque écran : rouge
|
||
et présent en permanence pour un geste qu'on fait trois fois par an. Deux
|
||
gestes délibérés — ouvrir, puis confirmer — et rien qui traîne sous les yeux
|
||
le reste du temps.
|
||
4. **Réconcilier avec la synchronisation** — fait le 2026-08-21.
|
||
|
||
Jusqu'ici les deux situations ne se rencontraient pas : un coffre local
|
||
n'avait pas de dépôt, un coffre tiré d'un dépôt n'avait pas de conteneur
|
||
local. Le jour où les deux existent, la règle « le conteneur local fait
|
||
autorité » fait que les enregistrements **n'atteignent jamais le dépôt** —
|
||
et les deux divergent en silence, la pire des issues.
|
||
|
||
*Sync* fond donc les deux, **une fois**, puis **efface le conteneur local** :
|
||
le dépôt devient l'autorité et l'appareil se comporte dès lors comme
|
||
n'importe quel autre. C'est ce qui rend la divergence impossible plutôt
|
||
qu'improbable. L'ordre compte — on publie d'abord, on efface ensuite ;
|
||
l'inverse perdrait le coffre entier si la publication échouait.
|
||
|
||
**Le distant sert de base, jamais le local.** Il porte ce que le téléphone
|
||
ne sait pas reconstruire : le contenu des pièces jointes, l'avatar, la
|
||
couleur des dossiers, et ce que le poste ajoutera demain. Repartir du local
|
||
amputerait tout cela, et le poste adopterait la version amputée au tirage
|
||
suivant — la perte silencieuse contre laquelle `PMA.VaultPatch` existe.
|
||
|
||
L'arbitrage est celui du poste, terme à terme : dernier écrit gagnant sur
|
||
`updated_at`, une pierre tombale l'emporte sur une entrée plus ancienne
|
||
qu'elle, mais **pas** sur une entrée modifiée après — celle-là a été
|
||
ressuscitée volontairement. Les horodatages étant de l'ISO 8601 UTC des deux
|
||
côtés, millisecondes comprises, l'ordre lexicographique **est** l'ordre
|
||
chronologique : aucune date à analyser.
|
||
|
||
Dépôt vide : rien à fusionner, on publie. Le 404 de Gitea étant ambigu —
|
||
fichier absent ou dépôt qu'on n'a pas le droit de voir — on ne cherche pas à
|
||
trancher : publier tranche, un jeton sans droit échouant au PUT avant que
|
||
rien n'ait été effacé.
|
||
|
||
`MergeSelfCheck` vérifie les quatre règles au démarrage en débogage. La
|
||
fusion ne tourne jamais contre un vrai coffre avant d'avoir tourné là.
|
||
|
||
### Vérifié : le poste reprend ce que le téléphone crée — 2026-08-21
|
||
|
||
Deux entrées créées sur le téléphone, puis synchronisées depuis le poste :
|
||
importées sans rien à corriger. C'est ce qui valide `AddEntry` — la forme de
|
||
l'entrée, l'uuid, les horodatages — autrement que par relecture du code.
|
||
|
||
### Le coût de la dérivation, et où il est parti
|
||
|
||
Ajouter une entrée prenait cinq à sept secondes. Le journal a tranché :
|
||
`key derived in 4395 ms`. Chaque enregistrement redérivait le mot de passe
|
||
maître pour retrouver la clé qui venait de servir.
|
||
|
||
Deux temps :
|
||
|
||
1. **La clé vit le temps de la session.** Réutilisée seulement si le conteneur
|
||
porte le même sel et le même nombre d'itérations — le poste peut avoir
|
||
rechiffré le sien avec un sel neuf, et une clé qui ne convient pas échoue
|
||
sur la marque de GCM en accusant un mot de passe qui serait bon.
|
||
2. **Elle est rangée dans le cache**, à côté du coffre en clair et du mot de
|
||
passe qui ouvre le conteneur. C'est ce voisinage qui rend l'ajout
|
||
acceptable : elle n'expose rien que ce fichier ne détienne déjà. Sans elle,
|
||
le *premier* enregistrement de chaque session repayait les 4,5 s, puisque
|
||
ouvrir depuis le cache ne dérive rien.
|
||
|
||
Un cache écrit par une version antérieure ne porte pas de clé : `LoadVault`
|
||
rend `nil`, on redérive une fois, et l'écriture suivante la range.
|
||
|
||
Reste dérivant une fois : un coffre **fraîchement inscrit** — `EncryptContainer`
|
||
ne rend pas la clé qu'il vient de dériver.
|
||
|
||
**Mesuré sur l'appareil**, au deuxième ajout d'entrée :
|
||
|
||
```
|
||
save: attempt 1 - GET
|
||
opened with the key already in hand, in 48 ms
|
||
save: decrypt+patch in 64 ms
|
||
save: re-encrypt in 23 ms
|
||
save: published, new sha e14eabcf...
|
||
```
|
||
|
||
48 ms là où le journal disait 4395. Ce qui reste dans l'attente est le GET et
|
||
le PUT vers le dépôt — plus rien de calculatoire.
|
||
|
||
### Ce que coûte réellement un enregistrement — mesuré 2026-08-21
|
||
|
||
```
|
||
save: publishing against the snapshot in hand
|
||
save: decrypt+patch in 23 ms
|
||
save: re-encrypt in 23 ms
|
||
save: PUT 267700 chars in 2745 ms
|
||
```
|
||
|
||
Le calcul pèse 46 ms. **Tout le reste est le conteneur qui monte** : 267 Ko à
|
||
chaque écriture, dont l'essentiel est une pièce jointe de 116 Ko. C'est le
|
||
plancher de ce format — le conteneur est monolithique, il n'y a pas de
|
||
« publier seulement l'entrée modifiée ».
|
||
|
||
Le tirage préalable, lui, a disparu : il coûtait 3,2 s et ne servait qu'à
|
||
s'assurer de modifier la dernière version publiée, ce que le sha prouve
|
||
gratuitement. Avec la clé retenue, l'enregistrement est passé de 5–7 s à ~2,8 s.
|
||
|
||
#### À REPRENDRE : les 2,7 s qui restent
|
||
|
||
Elles sont entièrement l'envoi. Et il part **deux fois encodé** : l'API de
|
||
contenus de Gitea exige du base64 dans du JSON, alors que le conteneur est déjà
|
||
du base64 — 267 700 caractères deviennent ~357 Ko sur le réseau, à ~130 Ko/s.
|
||
|
||
Le poids vient d'une seule chose : **une pièce jointe de 116 Ko**, soit ~155 Ko
|
||
dans le JSON en clair sur ~200 Ko de contenu — **environ 78 % de ce qui voyage
|
||
à chaque écriture**, pour une image que le téléphone ne sait même pas ouvrir.
|
||
Les onze entrées pèsent le reste.
|
||
|
||
Observé à l'usage, une fois la clé retenue et le tirage supprimé : **1–2 s**
|
||
quand la liaison est bonne, **jusqu'à 4–5 s** quand elle l'est moins. L'écart
|
||
est celui du réseau, pas du code — les 46 ms de chiffrement sont les mêmes dans
|
||
les deux cas.
|
||
|
||
Trois voies, par coût croissant :
|
||
|
||
1. **Sortir la pièce jointe du coffre synchronisé** (depuis le poste). Aucune
|
||
ligne de code, l'enregistrement tombe sous la seconde — et ça vérifie le
|
||
diagnostic ci-dessus.
|
||
2. **Publier en arrière-plan** : l'entrée apparaît aussitôt, l'envoi continue
|
||
derrière. Ne rend rien plus rapide, supprime l'attente. Contrepartie réelle :
|
||
aujourd'hui « enregistré » veut dire « publié », et fermer l'application
|
||
pendant l'envoi perdrait l'entrée. À faire en écrivant le cache local AVANT
|
||
l'envoi, ce qui met le cache en avance sur le distant — la situation même
|
||
contre laquelle le sha protège, donc à traiter avec soin.
|
||
3. **Comprimer avant de chiffrer** : ~30 %, mais il faut changer le format des
|
||
deux côtés, poste compris. Disproportionné.
|
||
**Conséquence : le chemin 409 est devenu porteur.** C'est lui,
|
||
désormais, qui garantit qu'un enregistrement n'écrase pas un autre appareil,
|
||
et il n'a toujours jamais tourné. Le provoquer : synchroniser depuis le poste,
|
||
puis enregistrer depuis le téléphone sans synchroniser d'abord.
|
||
|
||
### Le clavier : personne ne dit ce qu'il recouvre — 2026-08-21
|
||
|
||
Le bas du formulaire restait sous le clavier, sans moyen d'y accéder. **Trois
|
||
sources ont été interrogées sur ce qu'il recouvre, les trois répondent comme
|
||
s'il n'existait pas** :
|
||
|
||
- `visualViewport.height` — identique à `innerHeight`, clavier levé ;
|
||
- `innerHeight` — inchangé, la fenêtre reste en plein écran ;
|
||
- `android:windowSoftInputMode="adjustResize"` — sans effet : Android l'ignore
|
||
pour une fenêtre plein écran, ce qu'est celle de FMX. Retiré, pour ne pas
|
||
laisser croire qu'il porte quelque chose.
|
||
|
||
La mesure qui a tranché, clavier levé :
|
||
|
||
```
|
||
focus: inner 719 visual 719 | box client 659 scroll 659 | body=on-detail
|
||
```
|
||
|
||
`client` = ce que la boîte montre, `scroll` = ce qu'elle contient. Égaux :
|
||
aucun débordement, donc rien à faire défiler. Le clavier recouvrait le bas
|
||
d'une page qui se croyait en pleine hauteur.
|
||
|
||
**On a donc cessé de demander.** Le temps de la saisie, le panneau réserve 60 %
|
||
de hauteur sous son contenu — c'est du débordement, donc ça défile — et le champ
|
||
focalisé remonte en haut, mais **seulement s'il est dans le bas du panneau** :
|
||
un champ déjà visible ne doit pas sauter sous l'œil pour rien.
|
||
|
||
Deux corrections antérieures, posées par raisonnement sans instrument,
|
||
reposaient toutes deux sur une mesure qui vaut zéro ici. La leçon vaut d'être
|
||
écrite : sur cet écran, **instrumenter d'abord**.
|
||
|
||
### Le code se changeait tout seul — corrigé 2026-08-21
|
||
|
||
Le code du cache est passé de `0000` à `00000000` sans que personne ne le
|
||
décide. Cause : **le code tapé et le code sous lequel le cache est scellé
|
||
étaient la même variable**, et toute écriture du cache rescellait donc sous ce
|
||
qui traînait dans le champ. Une synchronisation — qui ne demande pourtant aucun
|
||
code — suffisait à le changer, en silence.
|
||
|
||
Les deux sont séparés maintenant. Seule **l'ouverture du coffre** touche au
|
||
sceau, parce que c'est le seul moment où l'on sait que l'utilisateur connaît le
|
||
code courant : le déchiffrement vient de le prouver. Tout le reste — sync,
|
||
rafraîchissement de fond, enregistrement d'entrée — réécrit le cache sous le
|
||
code déjà en place.
|
||
|
||
D'où un second champ, *new unlock code*, visible seulement quand un code existe :
|
||
il faut ouvrir avec l'actuel pour que le nouveau prenne. Sans lui, la correction
|
||
aurait enfermé l'appareil sous le code posé par accident.
|
||
|
||
### Le chemin d'écriture, vérifié de bout en bout — 2026-08-21
|
||
|
||
Le poste, après synchronisation :
|
||
|
||
```
|
||
Sync complete - pulled 4 new . 1 updated . 1 deleted . pushed 11 entries
|
||
```
|
||
|
||
Les trois verbes d'un coup, et depuis l'autre bout : **créer** (4), **modifier**
|
||
(1), **supprimer** (1). Le `1 deleted` est celui qui comptait — la disparition
|
||
locale ne prouvait rien, c'est la pierre tombale qui devait voyager.
|
||
|
||
Les étapes 1 à 3 de cette section sont donc closes.
|
||
|
||
### Le conflit rend 422, pas 409 — corrigé 2026-08-21
|
||
|
||
Le chemin de reprise a enfin tourné, et il ne reconnaissait pas ce qu'il
|
||
recevait. Un poste qui publie entre notre lecture et notre écriture nous vaut un
|
||
**422**, pas un 409 : l'enregistrement échouait au lieu de reprendre.
|
||
|
||
Gitea rend 409 quand le sha envoyé ne correspond plus, et 422 quand il manque ou
|
||
que la demande est jugée irrecevable — dont le cas « ce fichier existe déjà »
|
||
d'une création. Les deux disent la même chose pour nous : **le sha n'est plus le
|
||
bon**. Relire et recommencer est la bonne réponse aux deux, et elle est sans
|
||
danger — la reprise republie avec le sha frais, donc elle n'écrase jamais rien ;
|
||
un corps réellement mal formé échouerait de nouveau et sortirait au bout de
|
||
trois essais, avec le texte de Gitea.
|
||
|
||
C'est exactement ce qu'un chemin jamais exercé cache : il était écrit, il
|
||
compilait, il était même commenté — et il ne se déclenchait pas.
|
||
|
||
**Vérifié ensuite, de bout en bout** :
|
||
|
||
```
|
||
save: PUT 268162 chars in 813 ms
|
||
save: 422 - another device published, re-reading
|
||
save: attempt 2 - GET
|
||
key derived in 4397 ms
|
||
save: decrypt+patch in 4414 ms
|
||
save: PUT 268060 chars in 1710 ms
|
||
save: published, new sha bc44e5e6...
|
||
```
|
||
|
||
Conflit détecté, instantané frais retiré, modification appliquée **dessus**,
|
||
publication acceptée. Rien d'écrasé. **§4 est close.**
|
||
|
||
Une chose apprise au passage : `key derived in 4397 ms` au second essai. Le
|
||
poste avait rechiffré son conteneur avec un **sel neuf**, donc la clé en main ne
|
||
convenait plus — c'est précisément le cas contre lequel la réutilisation se
|
||
garde, et on le voit se déclencher pour la première fois. Coût : une dérivation
|
||
par publication du poste, pas davantage, la nouvelle clé étant retenue à son
|
||
tour.
|
||
|
||
### Ce qui devient facultatif
|
||
|
||
Sans dépôt configuré, *Sync* n'a rien à faire : les réglages du serveur
|
||
restent, mais l'application ne doit plus se comporter comme si le réseau était
|
||
la seule source.
|
||
|
||
---
|
||
|
||
## 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.
|