Files
r-zakarya b1c0c1e11b feat(apk): notes de commandes, une commande copiee a la fois
Bloc Commands en tete d'une note "commands", groupe par groupe, avec un
bouton copier. ParseCommandNote est le portage du decoupage du poste
(groupes, lignes continuees). La copie passe par un rang, comme les autres
lignes du detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:39:38 +01:00
..

Client Android — état et décisions

Ce qui reste à faire est dans PLAN.md.

Lecture seule de l'instantané chiffré du coffre. La crypto est validée sur appareil réel contre un vecteur produit par WebCrypto.

Ce qui est mesuré, pas supposé

Question Réponse Mesure
Le conteneur s'ouvre-t-il en Delphi ? oui clair de 572 octets, empreinte identique
AES-GCM par JNI fonctionne-t-il ? oui javax.crypto, tag concaténé, rien à découper
Android encode-t-il le char[] en UTF-8 ? oui clé identique sur un mot de passe non-ASCII
Le PBKDF2 natif est-il plus rapide ? à peine 3869 ms contre 4139 ms

Le natif est adopté — c'est du code de plateforme plutôt que le nôtre — avec le Pascal en repli, seul disponible hors Android.

La conséquence architecturale

4 secondes pour ouvrir un instantané, et c'est incompressible. PBKDF2 ne bénéficie d'aucune accélération matérielle, et on ne peut pas baisser les 600 000 itérations : elles sont inscrites dans le conteneur produit par le poste Windows.

Le piège serait de payer ces 4 secondes à chaque ouverture de l'application. Ce n'est pas nécessaire, parce que le coût est lié au conteneur, pas à la session — et chaque export génère un nouveau sel, donc une mise en cache par mot de passe ne servirait à rien.

L'architecture qui en découle :

  1. À la synchronisation — télécharger, dériver (4 s, une fois), déchiffrer.
  2. Puis stocker le coffre localement chiffré sous une clé du Android Keystore, adossée à la biométrie. AES est accéléré matériellement : la relecture est instantanée.
  3. À l'ouverture — biométrie, puis lecture locale. Aucune dérivation.

Les 4 secondes ne se paient donc qu'à l'arrivée d'un instantané nouveau, ce qui est acceptable et compréhensible pour l'utilisateur.

Le coffre déchiffré contient tous les mots de passe en clair. Il ne doit jamais toucher le disque autrement que chiffré sous la clé du Keystore, et la sauvegarde automatique Android doit être désactivée pour l'application.

Chaîne validée de bout en bout

Sur appareil : GET authentifié (80 ms) → déchiffrement (3893 ms) → lecture du JSON, entrées vivantes et corbeille comptées séparément. Second appel : 304, rien de retéléchargé, rien de re-déchiffré.

L'ETag n'est retenu que si le déchiffrement a réussi. Le conserver dès la réception paraît naturel et c'est un piège : après un échec, le prochain appel répond 304 et l'application se retrouve sans données tout en se croyant à jour. L'ETag appartient au clair détenu, pas au téléchargement.

L'interface

Trois écrans dans une seule forme, construits en code (mainApk.pas) : déverrouillage, liste, détail. Une forme par écran aurait coûté trois .fmx pour des dispositions triviales.

Afficher d'abord, synchroniser ensuite. Au lancement, le coffre local s'ouvre tout seul (biométrie si la clé l'exige) et la liste apparaît sans réseau ni dérivation ; le GET part derrière, en silence. Un 304 ne se voit pas, un échec réseau non plus — hors ligne est un état normal, et une alerte à chaque lancement apprendrait surtout à ignorer les alertes. Seul un instantané nouveau se manifeste, en rafraîchissant la liste.

C'est ce qui justifie de ranger le mot de passe de sync dans le fichier chiffré, à côté du coffre : sans lui, un instantané nouveau ne peut pas être ouvert, et chaque modification faite sur le poste ferait apparaître une invite au lancement. Le fichier contient déjà tous les mots de passe en clair ; celui-ci n'y ajoute presque rien en exposition. Ce qui est tapé dans le champ l'emporte sur ce qui est rangé — c'est ainsi qu'on corrige un mot de passe qui ne convient plus.

L'écran de déverrouillage s'adapte à ce que l'appareil détient : coffre local présent → Ouvrir (biométrie si la clé l'exige) ; absent → seul le téléchargement peut en produire un. La liste filtre à la frappe sur le nom, le site, l'identifiant et les étiquettes — pas sur le mot de passe : personne ne cherche par mot de passe, et le comparer allongerait son séjour en mémoire.

Le détail est construit par entrée, pas d'après un gabarit fixe : site, identifiant, mot de passe et champs personnalisés se ramènent tous à un intitulé, une valeur, et le fait qu'elle soit secrète. Les traiter séparément aurait dupliqué révélation et copie autant de fois qu'il y a de sortes de champ. Deux exceptions assumées : le corps d'une note, qui est stocké dans le champ mot de passe et s'affiche donc sous un autre intitulé, sans masque ; et le code 2FA, seule ligne vivante, rafraîchie chaque seconde et placée en tête — c'est ce qu'on vient chercher sur un téléphone.

La liste est triée par nom — l'ordre de l'instantané est celui de la base, arbitraire à l'œil — et porte l'icône de l'entrée quand elle en a une, décodée une fois par coffre et non à chaque frappe. Une liste déroulante filtre par dossier ; il n'y a pas de filtre d'étiquette, la recherche texte les couvrant déjà.

Le TOTP (PMA.Totp.pas) accepte le base32 nu comme l'URI otpauth://, les deux circulant dans les instantanés selon la façon dont l'entrée a été créée. Vérifié contre les six vecteurs de la RFC 6238.

Deux règles tenues dans l'interface, pas ailleurs :

  • L'écran est masqué dès que l'application passe derrière, sur WillBecomeInactive, qui précède la capture de l'aperçu des applications récentes — sans quoi la liste des comptes y resterait visible. C'est cette partie qui protège, et elle est immédiate et sans condition.

    Le clair, lui, bénéficie d'un sursis de trente secondes. Verrouiller instantanément rendait le geste le plus courant pénible : copier un mot de passe, basculer pour le coller, revenir, et devoir se réauthentifier avec le code 2FA de nouveau derrière une invite. L'écran étant déjà masqué, garder le clair le temps d'un aller-retour n'expose rien de visible.

  • Rien ne détruit le cache. Seule une écriture réussie le remplace. Aucune routine de lecture, aucun diagnostic d'indisponibilité ne l'efface — trois fois dans la même journée un verdict erroné a coûté le cache, et avec lui la trace de sa propre cause. Un fichier devenu illisible ne coûte que sa taille ; le prochain téléchargement l'écrase.

  • Le presse-papiers se vide après 45 s, et seulement si son contenu est toujours celui qu'on y a mis : l'utilisateur a pu copier autre chose entre-temps.

L'écriture n'existe pas. Le poste Windows reste la source ; modifier depuis le téléphone demanderait le PUT côté serveur et une fusion côté client.

Tester sans téléphone

Tout sauf le Keystore et la biométrie tourne en Win64. PMA.Crypto a une branche Windows adossée à CNG (bcrypt.dll) — même primitive, aucune DLL à livrer, et la clé dérivée est identique à celle d'Android (vérifié sur le vecteur, mot de passe non-ASCII compris). PMA.Store répond « pas de coffre » hors Android, donc chaque lancement retélécharge et redérive.

"C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dcc64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\Win64\release" pmserverApk.dpr

selftest.dpr vérifie la chaîne crypto contre le vecteur en console — c'est ce qui a remplacé le bouton Test crypto du banc d'essai. Code de sortie = nombre d'échecs :

"C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dcc64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\Win64\release" android-client\selftest.dpr

Mesures sur ce poste : PBKDF2 Pascal 1670 ms, CNG 528 ms, clés identiques.

Diagnostiquer un échec de déchiffrement

try-open.js ouvre un conteneur avec Node, donc avec les primitives du navigateur :

node try-open.js <fichier> <mot-de-passe>

Il sépare deux causes que l'application ne peut pas distinguer — un tag GCM qui ne vérifie pas signifie mot de passe faux OU fichier altéré, sans plus. Si Node ouvre le fichier et pas le client, le défaut est dans le client.

Le mot de passe de sync n'est pas le mot de passe maître. Il n'est affiché nulle part : prefs.bin le garde chiffré par DPAPI, et l'application sait seulement le définir. En cas d'oubli, le redéfinir puis relancer un sync pour rechiffrer l'instantané distant.

Fichiers

PMA.Crypto.pas PBKDF2 (Pascal + natif JNI), AES-GCM, ouverture du conteneur
PMA.SyncClient.pas GET authentifié, ETag, statuts distingués
PMA.Store.pas coffre local chiffré sous une clé du Keystore
PMA.Biometric.pas authentification via FMX.BiometricAuth
PMA.Vault.pas l'instantané en mémoire : lecture et recherche
PMA.Totp.pas codes TOTP (RFC 6238), base32 ou otpauth://
PMA.ScreenGuard.pas FLAG_SECURE (Release seulement) — bloque aussi adb exec-out screencap
ThirdParty/ZXing/ ZXing.Delphi (Apache 2.0), décodage du QR des réglages
selftest.dpr vérification console : conteneur + TOTP
PMA.TestVector.pas le vecteur embarqué, plus référencé par l'interface
make-testvector.js régénère le vecteur avec WebCrypto
testvector.md valeurs attendues, étage par étage

L'interface est mainApk.pas, à la racine du dépôt.

Trois façons d'ouvrir le coffre — 2026-09-17

Settings → Unlock with : le verrouillage d'écran (schéma, code, empreinte, visage), un code d'application, ou le mot de passe qui ouvre le coffre. Le bouton en place porte une coche, et la note dit lequel est actif. Le coffre lui-même n'est jamais touché : seul le scellement de vault.bin change (VER_WRAP ↔ VER_PIN).

  • Le nom du mot de passe suit l'origine du coffre : « sync password » pour un coffre venu du dépôt, « vault password » pour un coffre créé sur le téléphone. TForm8.PwdName et PushUnlockMode le décident, et le libellé se repose à chaque AdoptVault — sinon un téléchargement laissait l'ancien.
  • Revenir au verrouillage d'écran demande de retaper le code courant : retirer une protection exige de la connaître.
  • Sans verrouillage d'écran sur l'appareil, le bouton est grisé et dit pourquoi au lieu de rester muet.

La copie de secours, pour survivre au schéma retiré

Retirer ou réinitialiser le verrouillage d'écran fait détruire par Android la clé qui scelle vault.bin : le coffre devenait illisible et il fallait le retélécharger. SaveVault écrit donc, tant que le cache est en VER_WRAP, une seconde copie vault-recover.bin scellée sous le mot de passe (format VER_PIN : clé d'appareil sans contrainte + PBKDF2), qu'Android n'invalide jamais.

  • Tout autre scellement l'efface : en mode code ou mot de passe, elle n'apporterait rien.
  • À l'ouverture, OpenVault bascule dessus quand il n'y a plus de verrouillage utilisable, et OpenLocalNow quand le système vient de déclarer la clé morte (LastStoreKeyInvalidated). L'accueil demande alors le mot de passe au lieu d'annoncer un coffre illisible.
  • Rescellé immédiatement après cette ouverture : sous le nouveau verrouillage s'il existe (clé neuve, l'ancienne est morte), sinon sous le mot de passe. Le coffre ne reste jamais sans protection.
  • Un coffre venu seulement de la synchro avant cette version n'a pas de copie de secours : lui, il se retélécharge.

Scanner le QR des réglages — 2026-09-17

Le poste affiche un QR (Settings → Sync → QR for phone) qui porte pmserver://sync?u=&t= — URL du dépôt et jeton, jamais le mot de passe de synchro. Deux chemins le reçoivent :

  1. Settings → Scan the QR shown on the PC : aperçu caméra plein écran, décodage par ThirdParty/ZXing (ZXing.Delphi, Apache 2.0).
  2. Le lien lui-même, si un scanner extérieur l'ouvre : intent-filter VIEW sur le schéma pmserver dans le manifeste, intent de départ lu au FormCreate et suivants par TMessageReceivedNotification.

Dans les deux cas rien n'est enregistré : les champs se remplissent, c'est Save qui valide. Le lien peut venir de n'importe où, et l'URL est sous les yeux de l'utilisateur au moment où il valide.

Quatre défauts rencontrés, tous invisibles à la compilation :

Symptôme Cause
L'appareil photo propose « Ouvrir avec » et des navigateurs il envoie tout texte à une recherche web ; d'où le schéma pmserver://, que seule l'app ouvre
Caméra noire au premier scan après l'autorisation la fenêtre d'autorisation fait passer par BecameActive, qui remettait la vue web par-dessus l'aperçu ; elle ne le fait plus pendant un scan
Écran noir, aucune image la caméra est tenue par une autre application (l'appareil photo, même en arrière-plan). Un guet de 3 s sans image le dit maintenant
QR lu mais ignoré le texte décodé arrive avec un caractère invisible en tête (104 lus pour 103 émis) : on cherche le lien dans la chaîne au lieu de comparer le début

Le décodage est lent et n'aboutit que sur une image de temps en temps — c'est un portage Delphi, pas le scanner natif de Google. Une image sur quatre part au décodage, jamais deux à la fois, et l'indice TRY_HARDER est indispensable pour un QR photographié sur un écran.

Créer un coffre ici : essais seulement (AllowCreateLocal)

Un coffre né sur le téléphone n'existe nulle part ailleurs — le poste ne le connaît pas, et personne ne peut le rendre à qui perd son mot de passe. Le define AllowCreateLocal, posé par {$IFDEF DEBUG} et jamais en release, garde ce chemin pour les essais.

En release l'accueil n'offre que Download vault (PM.setAuthMode("download")), et RegisterLocal refuse aussi : l'interdit ne tient pas qu'à l'écran. Dépôt vide (404) ou conteneur illisible, rien n'est créé — un coffre neuf masquerait un réglage à corriger.

Erreurs du dépôt, générateur, Save — 2026-09-18

  • Une erreur de réglage s'affiche là où on la corrige : ShowSettingsError ouvre les réglages avec le message en rouge au-dessus des champs du dépôt. Download vault sans URL ni jeton, jeton refusé, dépôt ou utilisateur inexistant, mauvais mot de passe de synchro, dépôt public.
  • Gitea répond 404 pour tout (fichier absent, dépôt ou propriétaire inexistant, dépôt privé invisible au jeton). TSyncClient.Diagnose tranche par deux requêtes de plus — le dépôt, puis l'utilisateur —, seulement quand quelqu'un attend la réponse. Même logique pour GitHub (racine de l'API = tout ce qui précède repos/).
  • Générateur de mot de passe : dé à côté du champ, à la création et à la modification. crypto.getRandomValues avec rejet (un modulo favoriserait les premiers caractères), 20 caractères, laissé visible une fois tiré.
  • Save ne s'allume que sur un vrai changement, comparé aux valeurs enregistrées : taper puis effacer le regrise. Après Save : « ✓ Saved », inactif, nouvelle référence.

Le mot de passe de synchro : celui qu'on tape l'emporte, mais se vérifie

Le cache porte le mot de passe de synchro. Rouvrir le coffre le reposait dans les réglages, par-dessus celui que l'utilisateur venait d'enregistrer : le changement disparaissait sans un mot (FPwdUserSet l'empêche désormais).

Et un mot de passe changé n'était jamais vérifié : le sha inchangé faisait répondre « rien n'a changé » sans rien déchiffrer. Tant que FPwdUserSet, le dépôt est donc retéléchargé en entier (Fetch('')), le déchiffrement tranche, puis la synchro reprend si des modifications attendaient. Les publications, elles, n'étaient pas en danger : CommitChange et PublishPending chiffrent avec FKey, la clé du dernier mot de passe qui a réellement ouvert le dépôt.

Copier plus vite — 2026-09-18

Settings → Copy and search, deux cases, sans Save (elles s'appliquent au toucher, fichier ui-prefs.txt dans le dossier privé, rien de secret) :

  • Recherche prête (cochée par défaut) : à l'ouverture du coffre et au retour dans Vault, le curseur est dans la recherche, le texte précédent sélectionné, et le clavier déplié (PM.focusSearch puis kbd → ShowSoftKeyboard : un focus() JavaScript seul pose le curseur sans ouvrir le clavier). Pas après un enregistrement ni un rafraîchissement de fond : le clavier surgirait au milieu d'autre chose.
  • Cacher Vault après une copie (décochée par défaut) : moveTaskToBack. Android ne laisse pas choisir l'app qui passe devant — selon la façon dont Vault a été ouvert (lanceur, apps récentes), c'est l'écran d'accueil qui revient, pas l'app d'avant. Le geste fiable est celui d'Android : glisser le long de la barre du bas, ou double appui sur le bouton des apps récentes.

2 minutes de grâce après une copie (COPY_GRACE_SECONDS), 30 s sinon : on part coller l'identifiant et on revient chercher le mot de passe, et le coffre se reverrouillait au milieu du geste.

Code 2FA sur chaque ligne : icône d'horloge sur les entrées qui ont un secret (hasTotp, un booléen — jamais le secret dans la page). Le code est calculé à l'instant de la copie ; le message dit combien de secondes il lui reste.

Idées gardées pour plus tard (décidé le 2026-09-18)

Rien n'est fait. Deux propositions retenues pour copier encore plus vite :

  • Identifiant puis mot de passe, en un appui (effort moyen). Un appui copie l'identifiant ; au retour dans Vault après l'avoir collé (BecameActive), le mot de passe de la même entrée est déjà dans le presse-papiers, avec un message qui le dit. À borner : seulement si le retour a lieu dans la grâce de 2 minutes, et une seule fois — sinon un retour sans rapport recopierait un mot de passe que personne n'a demandé.
  • Remplissage automatique Android (gros chantier, plusieurs jours). Un AutofillService comme Bitwarden : Android propose les comptes dans le champ de connexion de n'importe quelle app ou de Chrome, sans copier-coller. Exige une classe Java (Delphi ne sait pas l'écrire) compilée en .jar et déclarée au manifeste avec BIND_AUTOFILL_SERVICE, et une authentification (code, empreinte) dans le service avant de proposer quoi que ce soit : le coffre ne vit que dans l'app, ouverte ou non.

Modifier une entrée — 2026-09-25 à 09-28

Un formulaire pour toute l'entrée, ouvert par le crayon de l'en-tête. Le crayon par ligne a disparu : chaque validation était un enregistrement complet, donc un commit de plus dans le dépôt.

  • Un seul envoi, seulement ce qui a changé : setfields?f0=&v0=&f1=&v1=… → SaveEntryFields, plusieurs PatchEntryField dans un CommitChange. Le filtre des noms est celui de SaveEntryField (ce qui traverse le pont se traite comme une entrée extérieure).
  • La page ne connaît pas le schéma : elle rend une ligne par champ que Delphi a marqué modifiable (e porte son nom, dans DetailToJson). Le champ folder est une liste (folderSelect, avec + new folder), pas un champ libre — une faute de frappe rangerait l'entrée dans un dossier invisible.
  • Mot de passe et secret 2FA naissent masqués (création et édition), avec un œil (data-eye) pour les révéler.
  • Icône en tête du formulaire, trois boutons : From site (recherche automatique), Image… (sélecteur de photos d'Android via TTakePhotoFromLibraryAction, réduite à 128 px, provenance custom — le poste ne la remplacera jamais par une recherche), Remove. Une action d'icône passe par un enregistrement : le formulaire est rouvert ensuite avec ce qui y était tapé (editAgain). Manifeste : READ_MEDIA_IMAGES, et READ_EXTERNAL_STORAGE bornée à maxSdkVersion="32".
  • Scanner le QR 2FA d'un site : bouton QR du champ 2FA → scanqr?for=totp → même caméra que le scan des réglages (FScanTotp). Le lien otpauth:// part à la page (PM.totpFromQr), qui n'en garde que le secret ; le journal ne note que le fait. Un autre QR fait dire au bandeau « pas un code 2FA » et le scan continue.
  • Pas de menu ⋮ ni de crayon sur une entrée en création : l'écran de détail est réutilisé, et côté Delphi ces commandes visent la dernière entrée ouverte — Delete depuis la création en aurait supprimé une autre.

Piège : view nomme l'écran, pas ce qu'on y fait

Un gardien focusin défocalise tout ce qui prend le focus hors de l'écran courant en comparant view à l'identifiant de l'écran. Le formulaire vit dans l'écran detail ; poser view = 'edit' le faisait donc défocaliser chaque champ à l'instant où il prenait le focus — clavier ouvert, pas de curseur, rien ne rentrait. Trouvé en inspectant la page en direct par le protocole DevTools (APK debug, adb forward … localabstract:webview_devtools_remote_…) : le champ était atteignable et focalisé, ce qui éliminait le DOM et le CSS. view reste detail, un drapeau editing dit qu'on modifie, et la flèche comme la touche retour referment le formulaire avant de quitter l'entrée.

Piège : toast était un <div>

Le navigateur expose chaque id comme variable globale : toast désignait l'élément <div id="toast">, et sept appels toast('…') de la page levaient « toast is not a function » — le message ne s'affichait pas et le reste du gestionnaire ne s'exécutait pas (dont « Tap again to delete »). Une vraie function toast(text) globale l'emporte sur l'id.

Synchro : ce qui a changé — 2026-09-25 à 09-26

  • L'ouverture ne tire plus l'instantané. Elle ouvre depuis le cache, puis ProbeRemote pose une requête conditionnelle : si le dépôt a bougé, le témoin bleu du bouton Sync s'allume et l'utilisateur tire quand il veut. Le journal dit les trois cas : unchanged, it moved, no answer (HTTP 0) — ce dernier n'est pas « rien n'a changé ».
  • Dépôt vide = création. Un 404 à la lecture pendant une publication était traité comme un échec : rien ne partait jamais vers un dépôt neuf. Sans sha, l'API des contenus crée le fichier.
  • Réseau : 8 s pour se connecter, 30 s pour répondre, trois essais, et Connection: close. Mesuré : api.github.com rend plusieurs adresses, 140.82.121.6 répond en 0,22 s et 140.82.121.5 n'est joignable depuis ce réseau ni par l'application ni par un navigateur. Un navigateur passe à la suivante aussitôt ; l'application attendait son délai entier. Le poste applique la même règle.
  • Pas de clavier de recherche pendant une synchro : synchroniser coffre fermé ouvre d'abord le coffre, et ce chemin dépliait le clavier.
  • Messages de commit neutres : update from phone partout (le téléphone écrivait new entry from phone, icon fetched…, entries deleted…). Le détail reste dans le journal local.
  • Nom par défaut data.json au lieu de vault-sync.json — voir CLAUDE.md.

WebDAV — 2026-10-02

Troisième transport, à côté de Gitea et GitHub, éprouvé contre le WsgiDAV local du poste (E:\webdav.json, 0.0.0.0:8080) : lecture, 304 à l'ouverture, écriture 204, puis synchro du poste qui reprend la modification.

  • Reconnu au jeton, pas à l'URL : une URL ni Gitea ni GitHub dont le jeton s'écrit utilisateur:mot-de-passe → IsWebDav, authentification Basic. Pas de champ « utilisateur » de plus dans l'écran : le jeton en tient lieu (sans :, c'est toujours l'ancien endpoint PHP, en Bearer).
  • Le fichier tel quel, le même que publie le poste : GET / PUT sans enveloppe base64. L'ETag HTTP fait le sha : If-None-Match à la lecture (304 = rien à faire), If-Match à l'écriture, 412 → ssConflict, donc la même reprise relire-fusionner-republier que le 409/422 de Gitea. Sans ETag connu, pas de condition : le dernier écrit gagne, comme le poste.
  • Une URL qui finit en .json n'est plus réécrite par normalizeRepoUrl (elle en faisait une URL d'API Gitea) — sauf les formes longues d'un dépôt, /src/branch/… et /blob/….
  • http:// : réseau local seulement, pour que chacun (les amis compris) pointe vers son propre serveur sans adresse connue à la compilation. Android ne sait autoriser ni plage ni masque : network_security_config.xml permet donc le clair partout, et TSyncClient.RefusalReason refuse avant tout envoi un http:// dont l'hôte n'est pas local (IsLocalHost : RFC 1918, 127.x, .local, .lan, nom sans point). Vers Internet, HTTPS obligatoire, et le refus s'affiche dans les réglages. Risque assumé : un Wi-Fi étranger qui réutilise la même plage recevrait le mot de passe WebDAV en clair — le coffre, lui, reste chiffré.
  • Côté poste : pare-feu Windows (règle WebDAV 8080, LocalSubnet) et Portmaster (entrant autorisé pour Python) — sans le second, le port restait fermé malgré la règle.

Notes de commandes — 2026-10-10

Une note marquée Commands sur le poste (modèle commands) affiche en tête un bloc Commands : une commande par ligne sous le titre de son groupe, avec un bouton copier. Le texte entier reste dessous, dans Content.

  • Même découpage que le poste : ParseCommandNote (PMA.WebUI) est le portage de parseCommandNote (js/app.js) — # titre ouvre un groupe, une ligne finie par \, ` ou ^ se recolle à la suivante. Les deux doivent dire la même chose ; éprouvé sur les mêmes cas.
  • La copie passe par un rang (copy?f=cmd&k=), comme les autres lignes : Delphi relit la note et copie la commande.
  • Ni remplissage ni masquage ici : pas de fenêtre cible, et le corps des notes est déjà affiché en clair. Une note devient « Commands » depuis le poste (case Command list) ; la synchro porte le modèle.

Saisie : ce qui a résisté

  • user-select: none du body était hérité par les champs : le curseur ne suivait pas le doigt et le texte ne défilait jamais, donc la fin d'une URL restait hors d'atteinte. Les champs ont leur exception, mais ça n'a pas suffi : l'URL est désormais une textarea qui passe à la ligne et prend la hauteur de son texte.
  • La croix d'effacement couvrait toute la marge droite : toucher la fin du texte effaçait le champ. Elle est plus étroite que la marge.
  • Save est un geste : fermer les réglages n'enregistre plus rien. Une URL effacée par erreur revient en revenant en arrière.
  • L'URL s'affiche en forme courte (https://hôte/Owner/Repo) et normalizeRepoUrl la rallonge en URL d'API à l'enregistrement.

Fabriquer l'APK en ligne de commande

L'EDI y arrive seul ; MSBuild non, et l'erreur ne dit pas pourquoi.

Le .dproj déclare les classes de déploiement Android (AndroidSplashStyles, Android_LauncherIcon96…) mais aucun fichier rattaché à ces classes : l'EDI les fournit implicitement, depuis son gestionnaire de déploiement. Hors EDI, aapt est donc appelé sur un dossier res qui n'a jamais été rempli, et se plaint d'un répertoire absent — pas d'une configuration manquante.

De quoi il a besoin, mis en place à la main sous Android64\Debug\pmserverApk\ :

Destination Source
res\ (arbre complet) produit par une compilation depuis l'EDI, n'importe quelle plateforme Android — c'est la seule chose que MSBuild ne sait pas régénérer. Il ne pèse que 60 Ko ; s'il disparaît d'un nettoyage, une compilation EDI le refait
res\values\styles.xml, values-v21, values-v31 Android64\Debug\styles*.xml
res\values\colors.xml, values-night-v21\colors.xml Android64\Debug\colors*.xml
res\values\strings.xml Android64\Debug\strings.xml
res\drawable\splash_image_def.xml, drawable-anydpi-v21 Android64\Debug\splash_image_def*.xml
res\drawable-anydpi-v26\ic_launcher.xml, -v33 Android64\Debug\ic_launcher*.xml
AndroidManifest.xml Android64\Debug\AndroidManifest.xml
classes\*.dex Android64\Debug\pmserverApk.classes\*.dex
library\lib\arm64-v8a\libpmserverApk.so Android64\Debug\libpmserverApk.so

Le thème a besoin de sa ressource, une fois (MSBuild ne compile pas les .rc référencés par {$R} — c'est une commodité de l'EDI, pas du compilateur) :

cd android-client\theme && brcc32 theme.rc -fotheme.res

Ensuite msbuild pmserverApk.dproj /p:Platform=Android64 /p:Config=Debug /t:Deploy produit et signe bin\pmserverApk.apk, et adb install -r l'installe. Le correctif durable serait d'écrire ces entrées une fois dans le .dproj.

Enfin, le rendu du texte passe par Skia, ce qui ajoute libsk4d.so à livrer. Deploy reconstruit library\lib juste avant d'appeler aapt, donc toute bibliothèque posée à la main y disparaît — et les entrées de déploiement ne fonctionnent pas ici. D'où un dernier passage qui refait aapt → zipalign → signature sur le dossier déjà prêt, sans le vider :

android-client\repack-apk.ps1

La sortie Android vit sur E:. Z: est arrivé à 40 Ko libres le 2026-09-17 et aapt échouait sans dire pourquoi. Z:\password-manager\Android64 est désormais une jonction vers E:\password manager\Android64 : les scripts et le .dproj n'ont pas changé, et rien de machine-dépendant n'est entré dans le dépôt.

La release n'est plus debuggable. Delphi écrit android:debuggable="true" dans le manifeste même en Release, et l'APK livré le portait. Sur un téléphone où le débogage USB est actif, adb run-as ouvrait alors le dossier privé de l'application — donc vault.bin et les réglages — sans accès root. build-apk.ps1 force l'attribut à false dans le manifeste mis en scène, et seulement pour une release. Vérification : adb shell dumpsys package com.embarcadero.pmserverApk ne doit plus lister DEBUGGABLE dans pkgFlags. Conséquence assumée : plus de run-as ni de capture d'écran sur une release, c'est-à-dire plus d'inspection à distance.

Place disque. Le .so de debug pèse 91 Mo (symboles), et le volume Z: n'en fait que 2 Go. Le dépouiller avec le strip du NDK le ramène à 54 Mo, et l'APK final à 16 Mo — mais strip écrit sa copie temporaire à côté de la cible, donc il faut écrire vers un autre volume puis recopier. Prévoir ~200 Mo libres avant une compilation Android, sinon l'édition de liens échoue sans que rien n'indique la vraie cause.

Pour que le téléphone voie l'endpoint local :

adb reverse tcp:8080 tcp:80

Le tunnel est volatil. Il disparaît dès que le lien USB se réinitialise — câble rebranché, appareil re-énuméré, serveur adb relancé — et l'application ne dit alors que « failed to connect ». À vérifier en premier : adb reverse --list doit répondre UsbFfs tcp:8080 tcp:80. Un test direct depuis l'appareil tranche entre le tunnel et le serveur : adb shell curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/server-php/sync.php — 404 signifie que la chaîne est bonne (c'est la réponse voulue sans jeton), 000 que rien n'écoute.

Le cache : écrire sans invite, lire sous authentification

Validé sur appareil le 2026-08-15, sur un téléphone protégé par un schéma et 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                    (pas de ligne « dechiffre en … ms »)
HTTP 304 en 136 ms · 304 : inchange, coffre deja ouvert

Une clé AES du Keystore ne pouvait pas convenir : setUserAuthenticationRequired exige une authentification récente pour chiffrer autant que pour déchiffrer, et l'écriture suit une synchronisation silencieuse. Le cache ne s'écrivait donc jamais, sauf dans les secondes suivant un déverrouillage — un test qui passe, puis ne passe plus.

C'est une paire RSA : la partie publique enveloppe une clé AES jetable sans invite, la partie privée — protégée — la déballe. L'authentification n'est exigée qu'à la lecture.

Trois pièges, aucun visible à la compilation :

USE_BIOMETRIC absente de la liste Delphi CanAuthenticate lève au lieu de répondre False
L'invite vit dans BiometricFragmentActivity à déclarer au manifeste, sinon ActivityNotFoundException
setDigests() n'autorise que le digest du message celui du MGF1 est distinct, défaut SHA-1

Le troisième est le plus retors : le chiffrement passe, parce que la clé publique reconstruite est servie par le fournisseur logiciel qui accepte n'importe quel MGF1. On produit un chiffré valide que le matériel refuse de lire, et le symptôme n'apparaît qu'au lancement suivant.

Aucune routine de lecture n'efface le cache. LoadVault triait « clé perdue » contre « pas authentifié » sur le texte de l'exception et supprimait le fichier quand il penchait pour la première. Un tri erroné détruisait le cache en emportant la seule trace de sa cause. Elle rapporte désormais via LastStoreError ; l'appelant décide.

Le piège de la porte sans clé

USE_BIOMETRIC ne fait pas partie de la liste de permissions par défaut de Delphi. Sans elle, CanAuthenticate ne répond pas « pas de biométrie » : il lève une java.lang.SecurityException. Le défaut ne se voit donc ni à la compilation, ni au démarrage, mais à la première tentative d'ouverture — après que le cache a déjà été écrit sous une clé protégée par authentification.

Le résultat est le pire cas possible : un cache fermé derrière une porte que l'application ne sait pas ouvrir. Trois corrections, dont une seule est la permission :

  1. Les deux uses-permission dans le modèle de manifeste.
  2. BiometricAvailable et AuthenticateThen attrapent ce qui remonte de la couche Java. L'appelant a un chemin pour chaque TBioOutcome, il n'en a aucun pour une exception.
  3. SaveVault ne reçoit plus True en dur mais BiometricAvailable. On ne demande une clé sous authentification que si l'invite peut réellement s'afficher — sinon le cache serait illisible pour toujours, et la seule sortie serait de l'effacer.

Piège de compilation rencontré

Cette installation de Delphi ne produisait aucun APK FireMonkey fonctionnel : fmx.dex.jar avait été renommé sur disque et retiré de la configuration de l'EDI. Symptôme : l'application s'installe puis se ferme aussitôt, avec ClassNotFoundException: FMXNativeActivity dans adb logcat. Le projet référence désormais explicitement fmx.dex.jar dans EnabledSysJars.

Après un tel changement, supprimer les classes*.dex intermédiaires : la compilation les réutilise sans les régénérer, et le correctif semble sans effet.