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>
35 KiB
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 :
- À la synchronisation — télécharger, dériver (4 s, une fois), déchiffrer.
- 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.
- À 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.binle 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.PwdNameetPushUnlockModele décident, et le libellé se repose à chaqueAdoptVault— 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,
OpenVaultbascule dessus quand il n'y a plus de verrouillage utilisable, etOpenLocalNowquand 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 :
Settings → Scan the QR shown on the PC: aperçu caméra plein écran, décodage parThirdParty/ZXing(ZXing.Delphi, Apache 2.0).- Le lien lui-même, si un scanner extérieur l'ouvre :
intent-filterVIEWsur le schémapmserverdans le manifeste, intent de départ lu auFormCreateet suivants parTMessageReceivedNotification.
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 :
ShowSettingsErrorouvre 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.Diagnosetranche 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èderepos/). - Générateur de mot de passe : dé à côté du champ, à la création et à la
modification.
crypto.getRandomValuesavec 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.focusSearchpuiskbd→ShowSoftKeyboard: unfocus()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
AutofillServicecomme 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.jaret déclarée au manifeste avecBIND_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, plusieursPatchEntryFielddans unCommitChange. Le filtre des noms est celui deSaveEntryField(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 (
eporte son nom, dansDetailToJson). Le champfolderest 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, etREAD_EXTERNAL_STORAGEborné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 lienotpauth://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
ProbeRemotepose 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.comrend plusieurs adresses,140.82.121.6répond en 0,22 s et140.82.121.5n'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 phonepartout (le téléphone écrivaitnew entry from phone,icon fetched…,entries deleted…). Le détail reste dans le journal local. - Nom par défaut
data.jsonau lieu devault-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
.jsonn'est plus réécrite parnormalizeRepoUrl(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.xmlpermet donc le clair partout, etTSyncClient.RefusalReasonrefuse avant tout envoi unhttp://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 deparseCommandNote(js/app.js) —# titreouvre 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: nonedubodyé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 unetextareaqui 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.
Saveest 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) etnormalizeRepoUrlla 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 etaaptéchouait sans dire pourquoi.Z:\password-manager\Android64est désormais une jonction versE:\password manager\Android64: les scripts et le.dprojn'ont pas changé, et rien de machine-dépendant n'est entré dans le dépôt.
La release n'est plus
debuggable. Delphi écritandroid: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-asouvrait alors le dossier privé de l'application — doncvault.binet les réglages — sans accès root.build-apk.ps1force l'attribut àfalsedans le manifeste mis en scène, et seulement pour une release. Vérification :adb shell dumpsys package com.embarcadero.pmserverApkne doit plus listerDEBUGGABLEdanspkgFlags. Conséquence assumée : plus derun-asni de capture d'écran sur une release, c'est-à-dire plus d'inspection à distance.
Place disque. Le
.sode debug pèse 91 Mo (symboles), et le volumeZ:n'en fait que 2 Go. Le dépouiller avec lestripdu NDK le ramène à 54 Mo, et l'APK final à 16 Mo — maisstripé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 --listdoit répondreUsbFfs 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—404signifie que la chaîne est bonne (c'est la réponse voulue sans jeton),000que 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.
LoadVaulttriait « 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 viaLastStoreError; 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 :
- Les deux
uses-permissiondans le modèle de manifeste. BiometricAvailableetAuthenticateThenattrapent ce qui remonte de la couche Java. L'appelant a un chemin pour chaqueTBioOutcome, il n'en a aucun pour une exception.SaveVaultne reçoit plusTrueen dur maisBiometricAvailable. 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.