Until now the two situations never met: a local vault had no repository, a pulled vault had no local container. The day both exist, the rule "the local container is the authority" means saves never reach the repository - and the two diverge in silence, which is the worst outcome available. Sync now merges them once and then deletes the local container: the repository becomes the authority and the device behaves like any other. That makes divergence impossible rather than unlikely. Order matters - publish first, delete after; the reverse would lose the whole vault if the publish failed. The remote is the base, never the local. It carries what the phone cannot rebuild: attachment bodies, the avatar, folder colours, and whatever the desktop adds next. Starting from the local would amputate all of it, and the desktop would adopt the amputated version on its next pull. Arbitration is the desktop's, term for term: last write wins on updated_at, a tombstone beats an older entry but not one edited after it - that one was revived on purpose. Timestamps are ISO 8601 UTC on both sides, milliseconds included, so lexicographic order IS chronological order. MergeSelfCheck asserts the four rules at startup in debug. The merge never runs against a real vault before it has run there.
Client Android — état et décisions
Ce qui reste à faire est dans PLAN.md.
Lecture seule de l'instantané chiffré du coffre. La crypto est validée sur appareil réel contre un vecteur produit par WebCrypto.
Ce qui est mesuré, pas supposé
| Question | Réponse | Mesure |
|---|---|---|
| Le conteneur s'ouvre-t-il en Delphi ? | oui | clair de 572 octets, empreinte identique |
| AES-GCM par JNI fonctionne-t-il ? | oui | javax.crypto, tag concaténé, rien à découper |
Android encode-t-il le char[] en UTF-8 ? |
oui | clé identique sur un mot de passe non-ASCII |
| Le PBKDF2 natif est-il plus rapide ? | à peine | 3869 ms contre 4139 ms |
Le natif est adopté — c'est du code de plateforme plutôt que le nôtre — avec le Pascal en repli, seul disponible hors Android.
La conséquence architecturale
4 secondes pour ouvrir un instantané, et c'est incompressible. PBKDF2 ne bénéficie d'aucune accélération matérielle, et on ne peut pas baisser les 600 000 itérations : elles sont inscrites dans le conteneur produit par le poste Windows.
Le piège serait de payer ces 4 secondes à chaque ouverture de l'application. Ce n'est pas nécessaire, parce que le coût est lié au conteneur, pas à la session — et chaque export génère un nouveau sel, donc une mise en cache par mot de passe ne servirait à rien.
L'architecture qui en découle :
- À la synchronisation — télécharger, dériver (4 s, une fois), déchiffrer.
- Puis stocker le coffre localement chiffré sous une clé du Android Keystore, adossée à la biométrie. AES est accéléré matériellement : la relecture est instantanée.
- À l'ouverture — biométrie, puis lecture locale. Aucune dérivation.
Les 4 secondes ne se paient donc qu'à l'arrivée d'un instantané nouveau, ce qui est acceptable et compréhensible pour l'utilisateur.
Le coffre déchiffré contient tous les mots de passe en clair. Il ne doit jamais toucher le disque autrement que chiffré sous la clé du Keystore, et la sauvegarde automatique Android doit être désactivée pour l'application.
Chaîne validée de bout en bout
Sur appareil : GET authentifié (80 ms) → déchiffrement (3893 ms) → lecture du
JSON, entrées vivantes et corbeille comptées séparément. Second appel : 304,
rien de retéléchargé, rien de re-déchiffré.
L'ETag n'est retenu que si le déchiffrement a réussi. Le conserver dès la réception paraît naturel et c'est un piège : après un échec, le prochain appel répond 304 et l'application se retrouve sans données tout en se croyant à jour. L'ETag appartient au clair détenu, pas au téléchargement.
L'interface
Trois écrans dans une seule forme, construits en code (mainApk.pas) :
déverrouillage, liste, détail. Une forme par écran aurait coûté trois .fmx
pour des dispositions triviales.
Afficher d'abord, synchroniser ensuite. Au lancement, le coffre local
s'ouvre tout seul (biométrie si la clé l'exige) et la liste apparaît sans
réseau ni dérivation ; le GET part derrière, en silence. Un 304 ne se voit
pas, un échec réseau non plus — hors ligne est un état normal, et une alerte à
chaque lancement apprendrait surtout à ignorer les alertes. Seul un instantané
nouveau se manifeste, en rafraîchissant la liste.
C'est ce qui justifie de ranger le mot de passe de sync dans le fichier chiffré, à côté du coffre : sans lui, un instantané nouveau ne peut pas être ouvert, et chaque modification faite sur le poste ferait apparaître une invite au lancement. Le fichier contient déjà tous les mots de passe en clair ; celui-ci n'y ajoute presque rien en exposition. Ce qui est tapé dans le champ l'emporte sur ce qui est rangé — c'est ainsi qu'on corrige un mot de passe qui ne convient plus.
L'écran de déverrouillage s'adapte à ce que l'appareil détient : coffre local présent → Ouvrir (biométrie si la clé l'exige) ; absent → seul le téléchargement peut en produire un. La liste filtre à la frappe sur le nom, le site, l'identifiant et les étiquettes — pas sur le mot de passe : personne ne cherche par mot de passe, et le comparer allongerait son séjour en mémoire.
Le détail est construit par entrée, pas d'après un gabarit fixe : site, identifiant, mot de passe et champs personnalisés se ramènent tous à un intitulé, une valeur, et le fait qu'elle soit secrète. Les traiter séparément aurait dupliqué révélation et copie autant de fois qu'il y a de sortes de champ. Deux exceptions assumées : le corps d'une note, qui est stocké dans le champ mot de passe et s'affiche donc sous un autre intitulé, sans masque ; et le code 2FA, seule ligne vivante, rafraîchie chaque seconde et placée en tête — c'est ce qu'on vient chercher sur un téléphone.
La liste est triée par nom — l'ordre de l'instantané est celui de la base, arbitraire à l'œil — et porte l'icône de l'entrée quand elle en a une, décodée une fois par coffre et non à chaque frappe. Une liste déroulante filtre par dossier ; il n'y a pas de filtre d'étiquette, la recherche texte les couvrant déjà.
Le TOTP (PMA.Totp.pas) accepte le base32 nu comme l'URI
otpauth://, les deux circulant dans les instantanés selon la façon dont
l'entrée a été créée. Vérifié contre les six vecteurs de la RFC 6238.
Deux règles tenues dans l'interface, pas ailleurs :
-
L'écran est masqué dès que l'application passe derrière, sur
WillBecomeInactive, qui précède la capture de l'aperçu des applications récentes — sans quoi la liste des comptes y resterait visible. C'est cette partie qui protège, et elle est immédiate et sans condition.Le clair, lui, bénéficie d'un sursis de trente secondes. Verrouiller instantanément rendait le geste le plus courant pénible : copier un mot de passe, basculer pour le coller, revenir, et devoir se réauthentifier avec le code 2FA de nouveau derrière une invite. L'écran étant déjà masqué, garder le clair le temps d'un aller-retour n'expose rien de visible.
-
Rien ne détruit le cache. Seule une écriture réussie le remplace. Aucune routine de lecture, aucun diagnostic d'indisponibilité ne l'efface — trois fois dans la même journée un verdict erroné a coûté le cache, et avec lui la trace de sa propre cause. Un fichier devenu illisible ne coûte que sa taille ; le prochain téléchargement l'écrase.
-
Le presse-papiers se vide après 45 s, et seulement si son contenu est toujours celui qu'on y a mis : l'utilisateur a pu copier autre chose entre-temps.
L'écriture n'existe pas. Le poste Windows reste la source ; modifier depuis le
téléphone demanderait le PUT côté serveur et une fusion côté client.
Tester sans téléphone
Tout sauf le Keystore et la biométrie tourne en Win64. PMA.Crypto a une
branche Windows adossée à CNG (bcrypt.dll) — même primitive, aucune DLL à
livrer, et la clé dérivée est identique à celle d'Android (vérifié sur le
vecteur, mot de passe non-ASCII compris). PMA.Store répond « pas de coffre »
hors Android, donc chaque lancement retélécharge et redérive.
"C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dcc64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\Win64\release" pmserverApk.dpr
selftest.dpr vérifie la chaîne crypto contre le vecteur en console — c'est ce
qui a remplacé le bouton Test crypto du banc d'essai. Code de sortie = nombre
d'échecs :
"C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dcc64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\Win64\release" android-client\selftest.dpr
Mesures sur ce poste : PBKDF2 Pascal 1670 ms, CNG 528 ms, clés identiques.
Diagnostiquer un échec de déchiffrement
try-open.js ouvre un conteneur avec Node, donc avec les primitives du
navigateur :
node try-open.js <fichier> <mot-de-passe>
Il sépare deux causes que l'application ne peut pas distinguer — un tag GCM qui ne vérifie pas signifie mot de passe faux OU fichier altéré, sans plus. Si Node ouvre le fichier et pas le client, le défaut est dans le client.
Le mot de passe de sync n'est pas le mot de passe maître. Il n'est affiché nulle part :
prefs.binle garde chiffré par DPAPI, et l'application sait seulement le définir. En cas d'oubli, le redéfinir puis relancer un sync pour rechiffrer l'instantané distant.
Fichiers
PMA.Crypto.pas |
PBKDF2 (Pascal + natif JNI), AES-GCM, ouverture du conteneur |
PMA.SyncClient.pas |
GET authentifié, ETag, statuts distingués |
PMA.Store.pas |
coffre local chiffré sous une clé du Keystore |
PMA.Biometric.pas |
authentification via FMX.BiometricAuth |
PMA.Vault.pas |
l'instantané en mémoire : lecture et recherche |
PMA.Totp.pas |
codes TOTP (RFC 6238), base32 ou otpauth:// |
PMA.ScreenGuard.pas |
FLAG_SECURE (Release seulement) |
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.
Fabriquer l'APK en ligne de commande
L'EDI y arrive seul ; MSBuild non, et l'erreur ne dit pas pourquoi.
Le .dproj déclare les classes de déploiement Android (AndroidSplashStyles,
Android_LauncherIcon96…) mais aucun fichier rattaché à ces classes : l'EDI
les fournit implicitement, depuis son gestionnaire de déploiement. Hors EDI,
aapt est donc appelé sur un dossier res qui n'a jamais été rempli, et se
plaint d'un répertoire absent — pas d'une configuration manquante.
De quoi il a besoin, mis en place à la main sous Android64\Debug\pmserverApk\ :
| Destination | Source |
|---|---|
res\ (arbre complet) |
produit par une compilation depuis l'EDI, n'importe quelle plateforme Android — c'est la seule chose que MSBuild ne sait pas régénérer. Il ne pèse que 60 Ko ; s'il disparaît d'un nettoyage, une compilation EDI le refait |
res\values\styles.xml, values-v21, values-v31 |
Android64\Debug\styles*.xml |
res\values\colors.xml, values-night-v21\colors.xml |
Android64\Debug\colors*.xml |
res\values\strings.xml |
Android64\Debug\strings.xml |
res\drawable\splash_image_def.xml, drawable-anydpi-v21 |
Android64\Debug\splash_image_def*.xml |
res\drawable-anydpi-v26\ic_launcher.xml, -v33 |
Android64\Debug\ic_launcher*.xml |
AndroidManifest.xml |
Android64\Debug\AndroidManifest.xml |
classes\*.dex |
Android64\Debug\pmserverApk.classes\*.dex |
library\lib\arm64-v8a\libpmserverApk.so |
Android64\Debug\libpmserverApk.so |
Le thème a besoin de sa ressource, une fois (MSBuild ne compile pas les
.rc référencés par {$R} — c'est une commodité de l'EDI, pas du compilateur) :
cd android-client\theme && brcc32 theme.rc -fotheme.res
Ensuite msbuild pmserverApk.dproj /p:Platform=Android64 /p:Config=Debug /t:Deploy produit et signe bin\pmserverApk.apk, et adb install -r l'installe.
Le correctif durable serait d'écrire ces entrées une fois dans le .dproj.
Enfin, le rendu du texte passe par Skia, ce qui ajoute libsk4d.so à
livrer. Deploy reconstruit library\lib juste avant d'appeler aapt, donc
toute bibliothèque posée à la main y disparaît — et les entrées de déploiement
ne fonctionnent pas ici. D'où un dernier passage qui refait aapt → zipalign →
signature sur le dossier déjà prêt, sans le vider :
android-client\repack-apk.ps1
Place disque. Le
.sode debug pèse 91 Mo (symboles), et le volumeZ:n'en fait que 2 Go. Le dépouiller avec lestripdu NDK le ramène à 54 Mo, et l'APK final à 16 Mo — maisstripécrit sa copie temporaire à côté de la cible, donc il faut écrire vers un autre volume puis recopier. Prévoir ~200 Mo libres avant une compilation Android, sinon l'édition de liens échoue sans que rien n'indique la vraie cause.
Pour que le téléphone voie l'endpoint local :
adb reverse tcp:8080 tcp:80
Le tunnel est volatil. Il disparaît dès que le lien USB se réinitialise — câble rebranché, appareil re-énuméré, serveur adb relancé — et l'application ne dit alors que « failed to connect ». À vérifier en premier :
adb reverse --listdoit répondreUsbFfs tcp:8080 tcp:80. Un test direct depuis l'appareil tranche entre le tunnel et le serveur :adb shell curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/server-php/sync.php—404signifie que la chaîne est bonne (c'est la réponse voulue sans jeton),000que rien n'écoute.
Le cache : écrire sans invite, lire sous authentification
Validé sur appareil le 2026-08-15, sur un téléphone protégé par un schéma et aucune empreinte enrôlée :
cache ecrit - protege par biometrie/code (aucune invite à l'écriture)
--- redémarrage à froid, saisie du schéma ---
4 entrees, 2 en corbeille (pas de ligne « dechiffre en … ms »)
HTTP 304 en 136 ms · 304 : inchange, coffre deja ouvert
Une clé AES du Keystore ne pouvait pas convenir : setUserAuthenticationRequired
exige une authentification récente pour chiffrer autant que pour déchiffrer,
et l'écriture suit une synchronisation silencieuse. Le cache ne s'écrivait donc
jamais, sauf dans les secondes suivant un déverrouillage — un test qui passe,
puis ne passe plus.
C'est une paire RSA : la partie publique enveloppe une clé AES jetable sans invite, la partie privée — protégée — la déballe. L'authentification n'est exigée qu'à la lecture.
Trois pièges, aucun visible à la compilation :
USE_BIOMETRIC absente de la liste Delphi |
CanAuthenticate lève au lieu de répondre False |
L'invite vit dans BiometricFragmentActivity |
à déclarer au manifeste, sinon ActivityNotFoundException |
setDigests() n'autorise que le digest du message |
celui du MGF1 est distinct, défaut SHA-1 |
Le troisième est le plus retors : le chiffrement passe, parce que la clé publique reconstruite est servie par le fournisseur logiciel qui accepte n'importe quel MGF1. On produit un chiffré valide que le matériel refuse de lire, et le symptôme n'apparaît qu'au lancement suivant.
Aucune routine de lecture n'efface le cache.
LoadVaulttriait « clé perdue » contre « pas authentifié » sur le texte de l'exception et supprimait le fichier quand il penchait pour la première. Un tri erroné détruisait le cache en emportant la seule trace de sa cause. Elle rapporte désormais viaLastStoreError; l'appelant décide.
Le piège de la porte sans clé
USE_BIOMETRIC ne fait pas partie de la liste de permissions par défaut de
Delphi. Sans elle, CanAuthenticate ne répond pas « pas de biométrie » : il
lève une java.lang.SecurityException. Le défaut ne se voit donc ni à la
compilation, ni au démarrage, mais à la première tentative d'ouverture — après
que le cache a déjà été écrit sous une clé protégée par authentification.
Le résultat est le pire cas possible : un cache fermé derrière une porte que l'application ne sait pas ouvrir. Trois corrections, dont une seule est la permission :
- Les deux
uses-permissiondans le modèle de manifeste. BiometricAvailableetAuthenticateThenattrapent ce qui remonte de la couche Java. L'appelant a un chemin pour chaqueTBioOutcome, il n'en a aucun pour une exception.SaveVaultne reçoit plusTrueen dur maisBiometricAvailable. On ne demande une clé sous authentification que si l'invite peut réellement s'afficher — sinon le cache serait illisible pour toujours, et la seule sortie serait de l'effacer.
Piège de compilation rencontré
Cette installation de Delphi ne produisait aucun APK FireMonkey
fonctionnel : fmx.dex.jar avait été renommé sur disque et retiré de la
configuration de l'EDI. Symptôme : l'application s'installe puis se ferme
aussitôt, avec ClassNotFoundException: FMXNativeActivity dans adb logcat.
Le projet référence désormais explicitement fmx.dex.jar dans EnabledSysJars.
Après un tel changement, supprimer les classes*.dex intermédiaires : la
compilation les réutilise sans les régénérer, et le correctif semble sans effet.