Files
Password-Manager/android-client/README.md
T
r-zakarya 0604344c60 feat(sync): http:// limite au reseau local, Basic des la 1re requete
APK : le clair est permis par Android et trie dans TSyncClient.RefusalReason
(IsLocalHost : RFC 1918, 127.x, .local, .lan, nom sans point) - chacun pointe
vers son propre WebDAV sans IP connue a la compilation ; vers Internet, HTTPS.
PC : Authorization Basic envoye d'emblee, plus de 401 anonyme ni de PUT du
coffre envoye deux fois.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:26:17 +01:00

619 lines
34 KiB
Markdown

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