Files
Password-Manager/android-client/PLAN.md
T
r-zakarya 0bcf9b95a2 docs(android): a Gitea token cannot be limited to one repo
The plan said to create a personal token "limited to this repo". Gitea has
no such thing - checked against 1.26.1: its scopes are per category and
account-wide, so read:repository grants read on every repo the account can
read. That is not GitHub's fine-grained model, and the difference changes
what you actually do.

So the section now says what is available instead, cheapest first:
read:repository alone while the phone only reads, a separate token on the PC
when publishing lands, and a dedicated Gitea account as collaborator if real
isolation is ever needed.

Also recorded: the contents endpoint answers 404 unauthenticated on a
private repo, which is the same snapshot-absent-or-no-access ambiguity the
PHP endpoint has, and the client already treats it as "nothing to download".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:42:33 +01:00

24 KiB

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 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.

Le diagnostic d'origine, conservé pour la trace

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.

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, 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 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.

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 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, 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. 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.

  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, servie par 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. 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, 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.

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 est écrite, elle n'a jamais parlé à un vrai dépôt

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.

Rien de tout cela n'a encore atteint un vrai dépôt. Il faut, dans cet ordre : un dépôt privé, un fichier vault-sync.json publié par le poste Windows, un jeton limité à ce dépôt, puis l'URL de contenu complète dans Server settings. Tant que le poste publie sur sync.php, le téléphone continue de lire là.

Reste à faire : le PUT (§4 seulement — la lecture n'écrit rien), la même bascule côté poste Windows en remplacement de WebDAV, 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.

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 :

"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.