# Client Android — état et décisions > Ce qui reste à faire est dans [PLAN.md](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](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. ```bash "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 : ```bash "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 : ```bash node try-open.js ``` 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 `
` Le navigateur expose chaque `id` comme variable globale : `toast` désignait l'élément `
`, 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. ## 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) : ```bash 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 : ```bash 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 : ```bash 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.