f1b7dd9bff
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
513 lines
27 KiB
Markdown
513 lines
27 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.
|
|
|
|
## 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.
|